# Buoy documentation
> Documentation pages from the Buoy navigation index. Platform and build support vary by tool. Each section lists its canonical URL. Interactive components may require the website. The curated index is https://buoy.gg/llms.txt.
# Overview
Source: https://buoy.gg/buoy/latest/docs/overview
Buoy puts devtools inside your React Native app. Inspect requests, storage, state and performance on the phone, in Buoy Desktop or from your coding agent.
## Start here
The quickest way in is to let your coding agent install it:
To install by hand, follow the [Quick Start](./quick-start). It goes from install to your first captured request.
## Where it runs
- **In your app.** A floating button opens the tools over your app, so QA and support can use them on the device that hit the bug. See [FloatingDevTools](./floating-devtools).
- **On your desktop.** [Buoy Desktop](./desktop) shows every connected app in one window. It's free with a Buoy account.
- **In your coding agent.** The [MCP server](./mcp) lets Claude Code, Cursor or Codex read and control the running app. Requires Pro.
- **In a chat inside the app.** [Ask Buoy](./tools/ask-buoy) (beta) drives the other tools in plain English, on the model endpoint you configure. Requires Pro.
## Pick your framework
- **[React Native and Expo](./quick-start)** has the full tool set.
- **[Flutter](./flutter/overview)** is in beta. It runs in debug builds only, and tool coverage differs from React Native.
- **[TV](./tv/overview)** is in beta for Apple TV and Android TV. Nothing renders on the TV screen; the desktop dashboard is the interface.
## What you can do
- **Inspect** requests, storage, React Query, Redux, Zustand and Jotai state, navigation, console output, image loads and re-renders.
- **Change** the running app: edit storage, refetch queries, dispatch actions, restore a [Time Machine](./tools/time-machine) snapshot, or switch to a test user through [Impersonate](./tools/impersonate).
- **Measure** FPS, CPU, memory and JavaScript thread time with [Bench](./tools/perf-monitor) and [JS Top](./tools/js-top).
Every tool is its own package, so you only install what you use. [Installation](./installation#available-packages) lists them all.
Some tools need app integration. Impersonation needs your backend to authorize the selected user, and state tools need access to the stores you want to inspect. Buoy doesn't bypass your app's authentication.
## For AI agents
- [buoy.gg/install.md](https://buoy.gg/install.md) holds the install instructions the prompt points to.
- [llms.txt](https://buoy.gg/llms.txt) indexes the docs, and [llms-full.txt](https://buoy.gg/llms-full.txt) has every page in one file.
- Add `.md` to any docs URL to get the page as Markdown.
- The [MCP server](./mcp) gives your agent tools to call against the running app.
## Build your own tools
Register any React component as a [custom tool](./custom-tools), such as a feature-flag panel or an order-state switcher. It appears in the same menu as the built-in tools.
## Next steps
- [Quick Start](./quick-start): install Buoy and inspect your first request
- [Installation](./installation): packages, account keys and troubleshooting
- [AI / MCP Server](./mcp): connect your coding agent
- [Custom Tools](./custom-tools): build a tool for your app
# Quick Start
Source: https://buoy.gg/buoy/latest/docs/quick-start
Install Buoy in a React Native or Expo app, then open Network Monitor and inspect a request your app made.
## Before you start
- A React Native app on 0.70 or newer, or an Expo app, with React 18 or newer.
- A debug build. Core and Network are JavaScript only, so Expo Go works.
- A Buoy account. Step 1 creates a free one if you don't have one yet.
## Let your agent do it
Claude Code, Cursor and Codex can do the whole install. Copy the prompt, paste it into your agent and review its changes. Then do the check in step 3.
To install by hand, follow the steps below.
## 1. Install
Install the core menu and Network Monitor from your app's directory:
Then sign in to your Buoy account:
```bash
npx --package=@buoy-gg/core buoy login
```
The command opens your browser, writes your key to an env file and adds that file to `.gitignore`. In Expo, a free key goes to `.env.development.local` as `EXPO_PUBLIC_BUOY_KEY`. That file is only read in development, so the key never ends up in a release build. A paid key goes to `.env.local`. React Native CLI apps get `BUOY_KEY` in `.env.local`. [Installation](./installation#get-your-key) has the details.
## 2. Mount the menu
Pick your setup. In all three, keep `FloatingDevTools` inside the same providers as your screens, so tools such as React Query can reach them.
#### Expo Router
Add Buoy to your root layout, `app/_layout.tsx` (or `src/app/_layout.tsx`). This example uses a Stack. Keep whichever navigator you already have.
```tsx
import { Stack } from "expo-router";
import { Buoy, FloatingDevTools } from "@buoy-gg/core";
Buoy.init({ licenseKey: process.env.EXPO_PUBLIC_BUOY_KEY });
export default function RootLayout() {
return (
<>
>
);
}
```
#### Expo
Render `FloatingDevTools` next to your existing `App` content, inside any providers.
```tsx
import { Buoy, FloatingDevTools } from "@buoy-gg/core";
Buoy.init({ licenseKey: process.env.EXPO_PUBLIC_BUOY_KEY });
export default function App() {
return (
<>
>
);
}
```
#### React Native CLI
Mount the menu in your root component. React Native doesn't load `.env.local` into `process.env` by itself, so pass `BUOY_KEY` in through the environment setup your app already uses.
```tsx
import { Buoy, FloatingDevTools } from "@buoy-gg/core";
// Read BUOY_KEY with the env loader your app already uses.
Buoy.init({ licenseKey: yourConfiguredKey });
export default function App() {
return (
<>
>
);
}
```
Restart the dev server so Metro picks up the new packages. In Expo:
```bash
npx expo start --clear
```
Open the app and tap the floating button. If Buoy asks you to set up an account, check that your key reached `Buoy.init`.
## 3. See your first request
Open **Network**, go back to your app and do something that makes an HTTP request, such as refreshing a list. Open Network again and select the request. You should see its URL, status, timing and response body.
If the list is empty, make sure the action sent a new request and didn't read cached data. [Network Monitor](./tools/network) lists the supported capture paths and overrides.
## 4. Add more tools
Each tool is its own package. Install the ones you want and restart the dev server, and Buoy adds them to the menu. [Installation](./installation#available-packages) lists every package.
A few tools need to be pointed at your app's data:
- **Zustand:** pass the stores you want to inspect through `zustandStores`. See the [Zustand setup](./tools/zustand).
- **Jotai:** register named atoms with `watchAtoms`, using your app's own store if it has a custom provider. See the [Jotai setup](./tools/jotai).
## Control who sees devtools
Render `FloatingDevTools` only for the users who should inspect your app. Use your app's existing authorization checks for internal users, QA, or support. A Buoy account key controls Buoy access; your app decides which users can reach the menu.
Start in development. Before enabling access in a shipped app, review the [component reference](./floating-devtools) and your plan's production restrictions.
## Next steps
- [Buoy Desktop](./desktop): see your connected apps in a desktop dashboard. Desktop is free to use. React Native apps need `@buoy-gg/external-sync` to connect.
- [AI / MCP Server](./mcp): let Claude Code, Cursor or Codex inspect and control the running app. Requires Pro.
- [Ask Buoy](./tools/ask-buoy): an in-app assistant that runs on the model endpoint you configure. Requires Pro.
- [Custom Tools](./custom-tools): add a tool that's specific to your app.
- [FloatingDevTools](./floating-devtools): component options and access controls.
## FAQ
### Do I need a license key to use React Buoy?
Use a Free or Pro Buoy account key for this setup. Run `npx --package=@buoy-gg/core buoy login` from your app's directory. Plan limits and paid features are listed on [pricing](https://buoy.gg/pricing).
### Does Buoy phone home?
Buoy makes account and license requests. Development telemetry is described in [Telemetry](./telemetry); disable that telemetry with `Buoy.init({ licenseKey: process.env.EXPO_PUBLIC_BUOY_KEY, telemetry: false })`. Disabling telemetry does not disable account validation or connections you configure for Desktop, MCP, or Ask Buoy.
### How do I add a tool to the menu?
Install its package and restart the development server. Check that tool's setup page for required integration, such as registering stores or placing the menu inside a provider.
### Will the devtools ship to my users?
The menu renders where you mount `FloatingDevTools`. Control access in your app. Desktop sync is disabled by default outside development; production sync requires explicit configuration and a Pro license. See [FloatingDevTools](./floating-devtools).
# Installation
Source: https://buoy.gg/buoy/latest/docs/installation
This is the reference for installing Buoy: packages, your account key, Desktop and MCP, and fixes for common problems. New to Buoy? [Quick Start](./quick-start) walks you through a first install and ends with a captured request.
## Requirements
Core and Network are JavaScript only and run in Expo Go. A few tools ship native code and need a development build, such as Bench's native CPU and memory metrics. Each tool's page says what it needs.
## Install with your agent
Paste the prompt into Claude Code, Cursor or Codex. The agent reads your `package.json`, installs the tools that match your app and mounts the menu. Review its changes before you commit them.
## Install by hand
Install the core menu and your first tool:
To install several tools at once, pick them here and copy the command:
## Available Packages
Each package adds one tool to the floating menu. Install only the ones you need.
## Register Your License Key
Buoy needs a Free or Pro account key. The plans have different history limits and features, and production access, MCP and Ask Buoy require Pro. See [pricing](https://buoy.gg/pricing) for details.
### Get your key
```bash
npx --package=@buoy-gg/core buoy login
```
This opens your browser, signs you in and writes the key to an env file, adding the file to `.gitignore` if it isn't there already. The variable name depends on your setup: `EXPO_PUBLIC_BUOY_KEY` on Expo and `BUOY_KEY` on bare React Native. The prefix matters, because Expo only inlines `EXPO_PUBLIC_` variables into the bundle.
On Expo, a free key goes to `.env.development.local`. Expo reads that file for `expo start` only, so the key never ends up in a release build. A paid key goes to `.env.local` so it can also reach a TestFlight or QA build if you want the tools there. Bare React Native always gets `.env.local`.
Read the key in your app and keep the menu inside the same providers as your screens. In React Native CLI, use your app's env loader to read `BUOY_KEY`, because writing an env file doesn't put it in `process.env`.
```tsx
import { Buoy, FloatingDevTools } from "@buoy-gg/core";
Buoy.init({ licenseKey: process.env.EXPO_PUBLIC_BUOY_KEY });
export default function App() {
return (
<>
>
);
}
```
### Or pass it directly
```tsx
```
This is fine for a solo project. On a team, use the env var instead. A key committed to a shared repo is shared by everyone who clones it, so it stops identifying a person and starts identifying a repository.
## Desktop & AI (optional)
The packages above power the in-app menu. Desktop is free with a Buoy account. MCP requires Pro.
- **Buoy Desktop** is a dashboard for macOS, Windows and Linux. Install the sync client in your app with `npm install @buoy-gg/external-sync`. It ships separately so apps that never use Desktop don't carry it. Then [download Buoy Desktop](https://github.com/Buoy-gg/Buoy-Desktop/releases/latest) and launch it. Your app finds it on its own, because the broker address comes from the Metro host. See [Buoy Desktop](./desktop).
- **AI / MCP Server** lets Claude Code, Cursor or any MCP editor drive your app:
```bash
npx -y @buoy-gg/mcp@latest init
```
See [AI / MCP Server](./mcp) for the full setup.
## TypeScript Support
All packages include TypeScript definitions. You don't need any `@types` packages.
## Monorepos & Enterprise Setups
- **`unstable_enablePackageExports: false` works.** Big monorepos often turn off Metro's package-exports resolution for older dependencies. Buoy's packages ship legacy resolution shims, so they resolve either way.
- **Physical devices find Desktop on their own.** The broker address comes from the Metro host, so devices on the same Wi-Fi reach your machine. `socketURL` overrides it for tunnels or a broker on another machine.
- **Scoped registries.** Every package lives under the `@buoy-gg` scope, so a proxy registry only needs a `.npmrc` scope rule (`@buoy-gg:registry=…`).
- **No on-device UI for end users.** Pass `headless` to `FloatingDevTools` for builds where only the desktop dashboard should see the session. See [FloatingDevTools](./floating-devtools).
## Troubleshooting
### The floating button doesn't appear
Check that `FloatingDevTools` renders, including any access condition you wrapped it in, and that you restarted the dev server after installing.
### An installed tool is missing from the menu
Restart Metro with `--clear`. Metro caches the "optional package missing" result from before you installed the package.
```bash
npx expo start --clear
```
### Buoy asks for account setup
Your key didn't reach `Buoy.init`. On Expo, check the variable starts with `EXPO_PUBLIC_` and reload the app after changing it. On React Native CLI, check your env loader passes `BUOY_KEY` through.
### An Android device over USB can't reach Desktop
Forward the broker port:
```bash
adb reverse tcp:42831 tcp:42831
```
### Network shows no requests
Make sure the action sent a new request and didn't read cached data. [Network Monitor](./tools/network) lists the supported capture paths.
## Next Steps
- [Quick Start](./quick-start): a first install, start to finish
- [FloatingDevTools](./floating-devtools): configuration options
- [Buoy Desktop](./desktop): the desktop dashboard
- [AI / MCP Server](./mcp): drive your app from your AI editor
- [Custom Tools](./custom-tools): build your own debugging tools
---
## FAQ
### Which React Buoy package do I install first?
`@buoy-gg/core` — it renders the floating menu. Every tool is a separate package (`@buoy-gg/network`, `@buoy-gg/storage`, and so on) that registers itself in the menu once installed, so you only ship the tools you actually use.
### Do I have to configure each tool after installing it?
No. Auto-discovery finds installed tool packages and adds them to the floating menu with no wiring. Only tools that need to reach into your app — passing your Zustand stores, or wiring impersonation to your user-search API — take extra props.
# Overview
Source: https://buoy.gg/buoy/latest/docs/flutter/overview
Buoy provides tools for inspecting Flutter apps: network requests, storage, state, navigation, and performance. Use the in-app menu or connect the app to [Buoy Desktop](../desktop). An AI editor can access supported tools through the [MCP server](../mcp) with Pro.
Flutter support is in beta. `BuoyDevTools` runs in debug mode; it does not enable tools in profile or release builds.
## Who It's For
Developers can inspect a failed request alongside the app state that produced it. QA can use configured tools to edit test data and exercise error states. Support teams can collect debugging context when your app grants them access.
Some tasks need app integration. Impersonation requires your backend to authorize the selected user, and state tools need access to the stores or providers you want to inspect. Buoy does not bypass your app's authentication.
## What You Get
| Tool | What It Does |
|------|--------------|
| **Network** | Inspect supported HTTP requests — `package:http`, dio, image loads, GraphQL |
| **Storage** | Browse and edit `shared_preferences` in real-time |
| **Environment** | Validate env/config with type checking and a health score |
| **Console** | A Chrome-DevTools-style console for every `print` / `debugPrint` / `log` |
| **Perf Monitor** | Live on-device HUD — FPS, jank, CPU, and memory |
| **Images** | Captured image loads with cache verdict, timing, oversize audit & failure diagnosis |
| **Riverpod** | Inspect provider state, live values, diffs, and history |
| **Route Inspector** | Track go_router navigation and browse your route structure |
| **Events Timeline** | Unified timeline across all tools with LLM-ready export |
| **Impersonate** | Inject configured headers for backend-authorized impersonation |
| **Image Overlay** | Overlay design mockups on your app for pixel-perfect comparison |
## Why Buoy
Install the tools you need and inspect the running app without adding a separate debug screen for each task. Desktop gives those tools more screen space, and MCP exposes supported actions to your AI editor. Follow each tool's setup instructions for app-specific configuration.
## Quick Start
```dart
import 'package:buoy/buoy.dart';
MaterialApp(
builder: (context, child) => BuoyDevTools(
licenseKey: 'YOUR_LICENSE_KEY',
child: child ?? const SizedBox.shrink(),
),
)
```
The umbrella registers its bundled tools. Individual packages require explicit registration. Get your license key at [buoy.gg/pricing](https://buoy.gg/pricing).
## Build Your Own Tools
Need something specific to your app? Add custom tools via the `tools` prop on `BuoyDevTools`. Build internal debugging utilities, feature flag toggles, or team-specific inspectors that integrate with the floating menu.
## Next Steps
- [Installation](./installation) — Add Buoy to your project
- [Quick Start](./quick-start) — Install and inspect your first request
- [AI / MCP Server](../mcp) — Let AI agents drive your running app
- [Custom Tools](./custom-tools) — Build your own debugging tools
- [Tools Reference](./tools/network) — Detailed docs for each tool
# Quick Start
Source: https://buoy.gg/buoy/latest/docs/flutter/quick-start
Open Buoy in a Flutter debug build, then inspect a request. You need a Free or Pro account key. Profile and release builds do not show the widget or start its tools.
## 1. Install the core
```bash
flutter pub add buoy
```
The `buoy` umbrella pulls in the whole suite. Prefer à la carte? See [Installation](./installation).
## 2. Add to your app
Wrap your app via `MaterialApp.builder` (or `CupertinoApp.builder`):
```dart
import 'package:flutter/material.dart';
import 'package:buoy/buoy.dart';
MaterialApp(
builder: (context, child) => BuoyDevTools(
deviceName: 'My App',
licenseKey: const String.fromEnvironment('BUOY_KEY'),
child: child ?? const SizedBox.shrink(),
),
)
```
A floating button appears in the corner of your app. Tap it to open the menu.
Get a key from your Buoy account and run `flutter run --dart-define=BUOY_KEY=YOUR_LICENSE_KEY`. See [Installation](./installation) for a complete app example and SDK requirements.
## 3. Add tools
The umbrella registers its bundled Flutter tools. Perform an action that makes an HTTP request, then open Network and select the new row. Check the URL and response. If no row appears, confirm that the action made a fresh request and uses a supported client.
Individual packages require explicit registration. Follow the standalone Network example in [Installation](./installation#available-packages).
### Riverpod providers
If you use Riverpod, add the Buoy observer to your `ProviderScope`:
```dart
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:buoy_riverpod/buoy_riverpod.dart';
void main() {
registerBuoyRiverpod();
runApp(
ProviderScope(
observers: const [buoyRiverpodObserver],
child: const MyApp(),
),
);
}
```
The observer reports provider changes to the Riverpod tool. See [Riverpod](./tools/riverpod) for package requirements and complete integration.
### go_router
If you use go_router, pass your router once at registration:
```dart
import 'package:buoy_routes/buoy_routes.dart';
registerBuoyRoutes(router: myGoRouter);
```
## Available tools
Use the umbrella for the bundled suite, or register a smaller set of individual packages.
## Control who sees devtools
Mount `BuoyDevTools` only when your app allows the current tester to inspect its data. Use your existing authorization checks. Omitting the widget also omits the initialization and connection it manages; it is not a desktop-only mode.
## Take it further
You can also connect this debug build to Desktop or MCP:
- **[Buoy Desktop](../desktop)** — mirror every tool to a full dashboard on macOS, Windows, or Linux, with a live performance HUD and multi-device switching.
- **[AI / MCP Server](../mcp)** — let Claude Code, Cursor, or any MCP editor inspect and control your running app. One command to wire it up:
```bash
npx -y @buoy-gg/mcp@latest init
```
Buoy Desktop is free to use; the MCP server is a Pro feature. Both connect to the same app you just set up — Flutter devices appear next to React Native ones.
## What's next
- [BuoyDevTools](./buoy-devtools) — Core widget reference
- [Buoy Desktop](../desktop) — The full desktop dashboard
- [AI / MCP Server](../mcp) — Drive your app from your AI editor
- [Custom Tools](./custom-tools) — Build your own debugging tools
---
## FAQ
### How do I add Buoy devtools to a Flutter app?
Run `flutter pub add buoy`, then wrap your app in `BuoyDevTools` via `MaterialApp.builder`. A floating button appears in the corner — tap it to open the menu. With the umbrella install, every Flutter tool is already registered.
### Do I need a license key to try it?
Use a Free or Pro account key. Pro enables paid capabilities such as MCP, but does not enable the Flutter widget in profile or release mode.
# Installation
Source: https://buoy.gg/buoy/latest/docs/flutter/installation
Install Buoy in an existing Flutter app and open its floating tool menu. The `BuoyDevTools` widget runs in debug mode; in profile and release builds it returns only your app's child widget.
## Requirements
The package manifests require Dart `^3.9.0` and Flutter `>=3.27.0`. Use a Flutter SDK that includes Dart 3.9 or a compatible newer Dart 3 release. Check your installed versions with:
```bash
flutter --version
```
## Quick Start
For the full suite, run this from your Flutter app's directory:
```bash
flutter pub add buoy
```
Wrap your app through `MaterialApp.builder`. A complete minimal `lib/main.dart` looks like this:
```dart
import 'package:flutter/material.dart';
import 'package:buoy/buoy.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
builder: (context, child) => BuoyDevTools(
licenseKey: const String.fromEnvironment('BUOY_KEY'),
child: child ?? const SizedBox.shrink(),
),
home: const Scaffold(
body: Center(child: Text('Open the Buoy menu to inspect this app.')),
),
);
}
}
```
Get your account key as described below, then start a debug build:
```bash
flutter run --dart-define=BUOY_KEY=YOUR_LICENSE_KEY
```
Tap the floating button. Confirm that the menu opens and includes Network. To check capture, perform an action in your app that makes a new HTTP request, then open Network and inspect it. The minimal app above opens the menu but does not make a request.
## Available Packages
The `buoy` umbrella registers its bundled tools. Some tools still need app-specific setup, such as connecting a router or supplying environment values. Their pages describe those steps.
To install only Network and the core widget:
```bash
flutter pub add buoy_core buoy_network
```
Use these imports in place of the umbrella import:
```dart
import 'package:buoy_core/buoy_core.dart';
import 'package:buoy_network/buoy_network.dart' show registerBuoyNetwork;
```
Register Network before `runApp`; keep the `MyApp` widget from the example above:
```dart
void main() {
WidgetsFlutterBinding.ensureInitialized();
registerBuoyNetwork();
runApp(const MyApp());
}
```
Individual tool packages need their registration calls. Adding a dependency alone does not register it with the core widget.
## Register Your License Key
Use a Free or Pro Buoy account key. Visit [pricing](https://buoy.gg/pricing) for account options and plan limits. Pass the key through `--dart-define=BUOY_KEY=...` as shown above; the widget reads it with `String.fromEnvironment`.
If the widget shows account setup, confirm that the build received the key and complete the prompt. A Pro key does not enable this widget in profile or release mode.
## Desktop & AI (optional)
- [Buoy Desktop](../desktop) provides a free desktop dashboard for connected apps.
- [AI / MCP Server](../mcp) lets Claude Code, Cursor, or another MCP editor inspect and control your app. MCP requires Pro.
For MCP configuration, run:
```bash
npx -y @buoy-gg/mcp@latest init
```
Keep the Flutter app running in debug mode with `BuoyDevTools` mounted. Follow the connection guide for your chosen surface.
## Devices
- iOS Simulator uses `localhost`; Android Emulator uses `10.0.2.2` to reach the computer running Desktop or the MCP broker.
- On a physical device, set `socketUrl` to your computer's LAN address, for example `BuoyDevTools(socketUrl: 'http://192.168.1.20:42831', child: child)`. Replace the address with your computer's address and keep the device on a network that can reach it. Allow local network access if iOS asks.
If the device does not appear, confirm that the broker is running, the address is correct, and the network or firewall allows the connection.
## Dart Support
Buoy's Flutter implementation is Dart. Its dependencies may include Flutter plugins, so follow any platform setup required by the packages you install.
## Monorepos & Enterprise Setups
After adding packages or changing tool registration, stop and restart the app. For individual packages, call each tool's registration function before using it.
Flutter and React Native devices can connect to the same Desktop or MCP broker. Physical Flutter devices need an explicit reachable `socketUrl`.
Keep `BuoyDevTools` mounted for the setup on this page. Removing it also removes the initialization and connection it manages; this guide does not provide a separate headless setup.
## Not on Flutter (yet)
React Query, Redux, Zustand, render highlighting, debug borders, and JS Top are React Native tools. Check each Flutter tool page for supported features; a matching tool name does not imply complete feature parity. See the [roadmap](https://buoy.gg/roadmap) for proposed additions.
## Next Steps
- [Quick Start](./quick-start): Flutter setup and usage
- [BuoyDevTools](./buoy-devtools): widget reference
- [Buoy Desktop](../desktop): desktop connection setup
- [AI / MCP Server](../mcp): connect your AI editor
- [Custom Tools](./custom-tools): add an app-specific tool
## FAQ
### What's the difference between the `buoy` umbrella and the individual packages?
`buoy` imports and registers its bundled tools. Individual packages let you choose a smaller set, with explicit registration for each tool. Both use a `BuoyDevTools` widget, but the umbrella exports its own wrapper around the core widget.
### Where do I mount BuoyDevTools in a Flutter app?
Use `MaterialApp.builder` or `CupertinoApp.builder` to wrap the child so the menu appears above your screens. Keep it inside any providers needed by your tools.
# Overview
Source: https://buoy.gg/buoy/latest/docs/tv/overview
TV is React Native — the same packages, the same install. What changes is the **surface**. A TV has
no touch, so Buoy mounts **headless** and renders nothing on the screen, and
[Buoy Desktop](../desktop) becomes the whole interface.
TV support is in beta. The documented target set includes Apple TV simulators and Android TV emulators. Check [Known limits](#known-limits) and test your app on its actual target hardware; these pages are not a test record for every device and build.
**Two ways to reach your running TV app:**
- **On your desktop** — Buoy Desktop, a free dashboard for macOS, Windows & Linux. Your TV device
appears in the same switcher as your phones, with the same panels.
- **Through your AI** — the [MCP server](../mcp) lets Claude Code, Cursor, or any MCP editor
inspect your live TV app (Pro).
There is no third way on TV: the in-app floating menu is deliberately absent.
## Why there is no floating menu on TV
TV uses D-pad navigation. A focusable debug overlay can enter that focus order and affect the behavior under test. Mount Buoy headless to keep its controls on Desktop and leave the app screen available for inspection.
## The two TV tools
These exist *because* of TV. Both are desktop surfaces — the packages you install only capture.
| Tool | Package | What it does |
|---|---|---|
| [TV Remote](../tools/tv-remote) | `@buoy-gg/tv-remote` | Press the D-pad, Select, Menu, holds, media keys and typed text from Buoy Desktop; record a macro and replay it with per-step confirmation. |
| [Focus Inspector](../tools/focus-inspector) | `@buoy-gg/focus-inspector` | Every focus move with its direction, the full focusable inventory, and flags for focus that gets **stuck**, **vanishes**, or is **never reached**. |
Use the remote event history and focus transitions together to record the sequence leading to a focus problem.
### Presses come from your Mac, not from inside the app
Buoy Desktop injects with `adb shell input keyevent` on Android TV and `idb ui key` on the Apple TV
simulator. Both travel the platform's real input pipeline, so focus moves through the same engine a
physical remote drives. The installed package only **observes** — it reports which events your app
actually received, which is what tells "the app handled that press" apart from "something swallowed
it."
The app package observes input events; host tools inject supported presses. A JavaScript handler call alone does not reproduce the platform focus-navigation path.
### What works where
| Target | Capture | Replay presses | How |
|---|---|---|---|
| Android TV emulator | ✅ | ✅ everything | `adb -s shell input keyevent` |
| Android TV device | ✅ | ✅ everything | the same, over `adb connect :5555` |
| Apple TV simulator | ✅ | ✅ except media keys | `idb ui key` / `ui button` / `ui text` |
| Apple TV device | ✅ | ❌ **record only** | no supported host-side injection exists |
Capture is pure JavaScript, so **recording works everywhere, including retail hardware.** That is
the record-on-retail workflow: a tester presses the physical remote on a rack device, the macro is
built from the app's own event stream, and replay runs against emulators and simulators.
## The rest of the suite
None of these needed TV-specific code — they are the same tools the phone examples use, available for inspection through the corresponding installed packages:
[Network](../tools/network) · [Storage](../tools/storage) · [Console](../tools/console) ·
[Env](../tools/env) · [Routes](../tools/routes) · [Events](../tools/events) ·
[React Query](../tools/react-query) · [Redux](../tools/redux) · [Zustand](../tools/zustand) ·
[Jotai](../tools/jotai)
## Known limits
- **No on-device UI, at all.** Anything that draws on the app to do its job — render highlighting,
debug borders, the image overlay — has nothing to draw into on TV.
- **A retail Apple TV can be recorded, not driven.** Apple's only supported path for pressing
buttons on physical hardware is an XCUITest runner paired to the device.
- **No media transport keys on a tvOS simulator.** `idb ui key` speaks the HID *keyboard* page,
which has no usages for play/pause, rewind, fast-forward, next or previous. Those steps report
`skipped-unsupported`, never silently pass. They work on Android.
- **Typed text never echoes.** It rides the platform's keyboard path and reaches the native text
field without touching the app's TV event pipe, so a text step is fire-and-wait.
- **Swipes and pans replay nowhere.** There is no touch surface on either platform, so a swipe
recorded from a physical Siri remote has no step that can reproduce it.
- **Tools beyond the list above are untested on TV.** Not blocked — just not device-verified yet.
## Coming soon
- MCP tools for the remote, so an agent can press the D-pad and replay macros.
- An XCUITest lane for retail Apple TV — the one target replay cannot reach today.
- Macros in CI, with each step's echo as the assertion.
Next: [Quick Start](./quick-start).
# Quick Start
Source: https://buoy.gg/buoy/latest/docs/tv/quick-start
Connect a React Native TV app to Buoy Desktop, then inspect remote input and focus. Buoy uses the same JavaScript packages as its phone integration.
## 1. Install the core
For plain Markdown readers, the command is:
```bash
npm install @buoy-gg/core @buoy-gg/external-sync
```
Configure a Free or Pro key with `npx --package=@buoy-gg/core buoy login`. The examples use Expo's `EXPO_PUBLIC_BUOY_KEY`; React Native CLI apps must supply the key through their own environment configuration.
## 2. Mount it headless
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
export default function App() {
return (
<>
{/* your app */}
{/* TV apps run headless — no bubble */}
>
);
}
```
`headless` mounts every installed tool's sync adapter plus the route, console and store
instrumentation, and renders **no on-device UI at all** — no bubble, no dial, no overlays. It is
not a TV-specific flag; it is the same mode shipped for field builds where only the desktop should
see the session. See [Overview](./overview#why-there-is-no-floating-menu-on-tv) for why that is the
right call on TV rather than a compromise.
Buoy adds **no native dependencies** to a TV app. Restart Metro after adding the packages. Follow the requirements of any other dependencies you add.
## 3. Add the TV tools
The TV tools register when installed. Other tools may need app-specific configuration. Add any other tool the same way
(`@buoy-gg/network`, `@buoy-gg/storage`, …) and it appears in the desktop dashboard.
## 4. Connect Buoy Desktop
Open [Buoy Desktop](../desktop) and your TV appears in the device switcher next to your phones.
An **Android TV emulator** needs the broker port forwarded once per device:
```sh
adb -s reverse tcp:42831 tcp:42831
```
Pass `-s ` explicitly — a physical phone is often attached at the same time, and `adb`
will otherwise pick the wrong one.
An **Apple TV simulator** connects on its own.
## 5. Press the remote from your desktop
Open **TV Remote** in Buoy Desktop, pair the device with an injection target, and press. Presses go
through the platform's real input pipeline, so they move focus exactly like a physical remote.
Injection needs `adb` on your `PATH` (Android SDK platform-tools) and, for the Apple TV simulator,
`idb` — which does not ship with Xcode:
```sh
brew tap facebook/fb && brew install idb-companion
pipx install fb-idb
```
Without it the Apple TV lane is disabled and the panel says so. A retail Apple TV can be **recorded
but not driven** — see the target table in the [Overview](./overview#what-works-where).
Then open **Focus** and drive the app: reported focus moves can be compared with observed direction inputs. Review flags against actual device behavior.
## Requirements
- `react-native-tvos` and the New Architecture (Fabric).
- Restart Metro after adding JavaScript tools. Your TV runtime and host input tools have separate setup requirements.
- Headless has **no license entry UI**. Configure your Free or Pro account key before mounting the headless tools. The
desktop dashboard works at the free tier; the MCP server requires Pro.
More detail in [Installation](./installation).
# Installation
Source: https://buoy.gg/buoy/latest/docs/tv/installation
Buoy on TV is the ordinary React Native install with one prop changed. This page is the complete
version of [Quick Start](./quick-start).
## Requirements
| | |
|---|---|
| React Native | `react-native-tvos` (the TV fork) |
| Architecture | New Architecture / Fabric |
| App type | Expo TV (`@react-native-tvos/config-tv`) or bare RN TV — both verified |
| Native deps added by Buoy | **None** — no podspecs, no gradle changes, no prebuild |
| Host tooling | `adb` for Android TV; `idb` for the Apple TV simulator (remote injection only) |
Expo Go cannot run a TV app at all — TV is always a prebuild + `expo run:*` app. That is a property
of TV, not of Buoy.
## Packages
Install the core, then any tools you want. Installed tools register themselves; there is no list to
maintain.
For plain Markdown readers, the command is:
```bash
npm install @buoy-gg/core @buoy-gg/external-sync
```
Configure a Free or Pro key with `npx --package=@buoy-gg/core buoy login`. The examples use Expo's `EXPO_PUBLIC_BUOY_KEY`; React Native CLI apps must supply the key through their own environment configuration.
The two TV-specific tools:
Everything else installs exactly as it does on a phone — [Network](../tools/network),
[Storage](../tools/storage), [Console](../tools/console), [Env](../tools/env),
[Routes](../tools/routes), [Events](../tools/events), [React Query](../tools/react-query),
[Redux](../tools/redux), [Zustand](../tools/zustand), [Jotai](../tools/jotai) are all
device-verified on both TV platforms.
## Mounting
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
export default function App() {
return (
<>
{/* your app */}
>
);
}
```
`headless` is required on TV in practice: without it the floating bubble renders, and on Android TV
its focusable views join your app's D-pad focus order. See
[Overview](./overview#why-there-is-no-floating-menu-on-tv).
`deviceName` is what shows in Buoy Desktop's switcher. Give each TV a distinct one — an Apple TV
simulator and an Android TV emulator running the same app are otherwise hard to tell apart.
## Connecting to the desktop
| Device | What it needs |
|---|---|
| Apple TV simulator | Nothing — it connects on its own. |
| Android TV emulator | `adb -s reverse tcp:42831 tcp:42831`, once per boot. |
| Physical TV device | A `socketURL` pointing at your machine's LAN address. |
Pass `-s ` to `adb` explicitly. A physical phone is often attached alongside the emulator,
and an unqualified `adb reverse` forwards the wrong device.
## Host tooling for the TV Remote
Replay is injected from your machine, so the binaries have to be findable:
- **Android** — `adb`, part of the Android SDK platform-tools.
- **Apple TV simulator** — `idb`, which does **not** ship with Xcode:
```sh
brew tap facebook/fb && brew install idb-companion
pipx install fb-idb
```
Buoy looks for both on your `PATH` and in the usual install locations, so the packaged desktop app
finds them even though a GUI app inherits a minimal `PATH`. If `idb` is missing, the Apple TV lane
is disabled and the panel says so rather than failing silently.
Recording needs none of this — capture is pure JavaScript and works on retail hardware.
## License keys
A verified Free or Pro account is required. Desktop is free to use; MCP and production access require Pro.
**Headless has no license entry UI** — there is no on-device screen to type into. On TV a key can
be supplied through initialization or the `licenseKey` prop, typically from your environment configuration. The desktop dashboard works at the
free tier; the [MCP server](../mcp) requires Pro and will refuse a TV device with no admitted account.
Don't have a key yet? Grab one at [buoy.gg/pricing](https://buoy.gg/pricing).
# FloatingDevTools
Source: https://buoy.gg/buoy/latest/docs/floating-devtools
The `FloatingDevTools` component is the entry point for React Buoy. It renders a draggable floating button that opens a menu containing all your installed debugging tools.
The dial shows the most recently opened tool first. Tools you haven’t opened keep their registration order. The order updates when you reopen the dial.
## Basic Usage
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
function App() {
return (
<>
>
);
}
```
Pass your account key and install the tools you need. Keep `FloatingDevTools` inside the providers those tools use. Don't have a key yet? Grab one at [buoy.gg/pricing](https://buoy.gg/pricing).
Without a verified account, the launcher shows **Sign in**. Open it to copy `npx buoy login`, run the command in your project, then reload your app. **Check connection** retries verification and shows feedback if your account is still disconnected. The sign-in card and launcher share their design across native and web.
## Environment Badge
The floating button automatically displays your current environment based on `NODE_ENV`, helping your team instantly know where they are. No configuration needed.
You can override it explicitly if your environment name doesn't match `NODE_ENV`:
```tsx
```
Supported values: `"local"`, `"dev"`, `"staging"`, `"qa"`, `"prod"`
## How Tools Auto-Register
When you install a Buoy tool package (like `@buoy-gg/network` or `@buoy-gg/storage`), it automatically registers itself with the floating menu. Some tools need additional registration or app configuration.
```bash
npm install @buoy-gg/network
```
Restart Metro after installing the package, then open Network to confirm discovery.
## Custom Tools
Need something specific to your app? Add your own tools:
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
import { View, Text } from "react-native";
const FeatureFlagTool = () => (
Toggle feature flags here
);
function App() {
return (
);
}
```
See [Custom Tools](./custom-tools) for more details on building your own debugging tools.
## Draggable Button
The floating button can be dragged anywhere on screen. It remembers its position between sessions, so it stays where your team likes it.
## Beyond the in-app menu
`FloatingDevTools` is also the source of truth for Buoy's other surfaces. The same tools you see in the menu sync out over a local broker, after you install `@buoy-gg/external-sync` and configure the connection. You can then:
- open the [Buoy Desktop](./desktop) dashboard and inspect the same live app on a full screen, or
- point an AI agent at your app with the [MCP server](./mcp).
Just connect either one to your running app. The broker address is derived automatically from the Metro dev server that served the bundle, so physical devices reach your machine with zero config (Android over USB: run `adb reverse tcp:42831 tcp:42831` once); pass `socketURL` in the `externalSync` prop only for tunnels or a broker on another machine. The current sync target and connection state show up in the menu's Settings tab under **DESKTOP SYNC**. Each install identifies itself with a per-device id minted on first connect, so a phone and a simulator (or a whole QA team) on the same build show up as separate devices; pass `deviceName`/`deviceId` in `externalSync` only to pin a label, and never the same `deviceId` on two devices. Sync is dev-only unless you ask for it — see [release builds](./desktop#release-builds) to profile a release build you own.
## Headless (sync-only) mode
For builds that ship to non-developers — field or associate builds where the desktop dashboard is the only debugging surface — mount the tools with no on-device UI at all:
```tsx
```
`headless` keeps every tool's sync adapter and route tracking running (so Buoy Desktop and the MCP server see the full session) but renders no floating button, dial, or overlays. Headless mode still requires a verified account. Supply the key through initialization or `licenseKey`; it has no on-device account entry screen. `requireLicense: false` does not bypass account admission. Sync still follows the same rule as any other build: on in dev, and in a release build only with [`externalSync.enableInRelease`](./desktop#release-builds) plus a Pro license — which is exactly what a field build wants.
## Next Steps
- [Custom Tools](./custom-tools) — Build team-specific debugging tools
- [Buoy Desktop](./desktop) — Inspect the same app on a full dashboard
- [AI / MCP Server](./mcp) — Drive your app from your AI editor
- [Quick Start](./quick-start) — Full setup walkthrough
## Web support (unreleased)
Register this package’s /web namespace in FloatingDevTools modules to use its shared panels and actions in a browser app. The browser build is available in this checkout and has not been published yet. See the [web setup guide](./web-preview.md) for registration, dependencies, and browser boundaries.
The browser dial supports Tab, arrow keys between tools, Enter to activate controls, and Escape to close. Its center button opens settings. Drag panel backgrounds or handles to move them; tabs and inputs keep their normal mouse behavior. Minimized tools stay above the floating bar and scroll when needed. They open below only when there is not enough room above for one row.
The web dial follows the shared Background selection in Settings, including changes made while the dial is open.
# Custom Tools
Source: https://buoy.gg/buoy/latest/docs/custom-tools
Register a React component through `FloatingDevTools.apps` to add an app-specific tool. Start with the counter below, then connect the component to your own state.
## The component toolkit
Custom tools don't start from a blank screen — `@buoy-gg/core` ships the same
UI kit our first-party tools are built from: theme tokens, badges, buttons,
list rows, JSON viewers, diff viewers and more. Everything below is the **real
component rendered live** (via react-native-web) — search or filter to find
the pieces your tool needs, then import them straight from `@buoy-gg/core`.
## Basic Custom Tool
```tsx
import { useState } from "react";
import { FloatingDevTools } from "@buoy-gg/core";
import { View, Text, Button } from "react-native";
const CounterDebugger = () => {
const [count, setCount] = useState(0);
return (
Count: {count}
);
};
function App() {
return (
);
}
```
Open Counter, tap Increment, then Reset. The value should return to zero. Keep your existing account setup from [Quick Start](./quick-start). Later snippets use app-owned components and stores as integration examples.
## Custom Tool Schema
Custom tools go in the `apps` prop, as `InstalledApp` objects:
```typescript
interface InstalledApp {
/** Unique id — used for persistence, settings, and the `custom:` sync namespace */
id: string;
/** Display name in the menu */
name: string;
/**
* Menu icon. A plain string ("AUTH", "🗑️") renders as an auto-fitting text
* icon; a ReactNode or a ({ slot, size }) => node component also work.
*/
icon: React.ReactNode | ((ctx: FloatingMenuRenderCtx) => React.ReactNode);
/** The React component to render */
component: React.ComponentType;
/** Optional description shown in settings */
description?: string;
/** Optional accent color */
color?: string;
/** Sync to Buoy Desktop and make it drivable over MCP — see below */
sync?: ToolSyncAdapter;
}
```
## Multiple Custom Tools
```tsx
```
## Accessing App State
Mount the menu inside the providers your tool reads. The example below assumes your app defines `useAuth`, uses QueryClientProvider, and imports View, Text, and Button from react-native:
```tsx
import { useAuth } from "./hooks/useAuth";
import { useQueryClient } from "@tanstack/react-query";
const AuthDebugger = () => {
const { user, logout } = useAuth();
const queryClient = useQueryClient();
const forceLogout = () => {
logout();
queryClient.clear();
};
return (
User: {user?.email ?? "Not logged in"}Role: {user?.role}
);
};
```
## Let the desktop, your AI, and Ask Buoy drive it
A custom tool doesn't have to stop at its own modal. Give it a `sync` adapter and it mirrors to [Buoy Desktop](./desktop) and becomes drivable over [MCP](./mcp) as `custom:` — whether or not its modal is open.
```tsx
apps={[
{
id: "feature-flags", // the agent and MCP reach it as `custom:feature-flags`
name: "Feature Flags",
component: FeatureFlagViewer,
icon: "🚩",
sync: {
version: 1,
getSnapshot: () => flagStore.getState(), // JSON-serializable, keep it small
subscribe: (onChange) => flagStore.subscribe(onChange), // returns an unsubscribe
actions: {
setFlag: ({ name, on }) => flagStore.set(name, on),
},
},
},
]}
```
The sync namespace is `custom:`. Validate action parameters at the adapter boundary and notify subscribers after writes. The example assumes `flagStore` implements the shown methods; only serialize data the connected client should read.
### And by Ask Buoy
[Ask Buoy](./tools/ask-buoy) is the one exception: it only offers what's in its catalog, so a synced custom tool is fully drivable from MCP while staying **invisible to the chat** until you describe it. Hand it a descriptor and the agent can use it:
```tsx
```
Descriptors get the same treatment as the built-ins: params validated against the schema before dispatch, `effect` deciding whether the call needs a human tap, and `release` refusing a call that couldn't really work in this build instead of letting the agent report a success that never happened. Write real param names and a real `summary` — a parameter the model is never shown the name of is a parameter it will guess at.
## Next Steps
- [FloatingDevTools](./floating-devtools) — Core component reference
- [Buoy Desktop](./desktop) — The full desktop dashboard
- [Ask Buoy](./tools/ask-buoy) — The in-app chat that drives every tool
- [AI / MCP Server](./mcp) — Drive your app from your AI editor
- [Quick Start](./quick-start) — Full setup walkthrough
# Floating Tools
Source: https://buoy.gg/buoy/latest/docs/custom-tools/floating-tools
A floating tool puts a few controls over your app without opening a modal. Drag its grip to move it. Tap the grip to hide it at the right edge, then tap again to restore it.
Two built-in tools ship a strip of their own: Network's throttling controls and Time Machine's restore bar, both opened from the tool's header. The DevTools bubble and custom strips render through the same `FloatingTool` implementation on each platform. Native consumers share `DraggableHeader`, position storage, hide/restore behavior and animations. Browser consumers share the existing pointer-event renderer and floating-tools state store. Tool content does not implement gestures.
## Install
```bash
pnpm add @buoy-gg/shared-ui
```
React Native apps can import these APIs from `@buoy-gg/shared-ui`. React DOM apps should use `@buoy-gg/shared-ui/floating`, which selects the browser renderer. React is a peer dependency; the native renderer also needs React Native. No Buoy account, Network package, or DevTools provider is required for a custom floating tool.
## Mount the host once
Place the host alongside your app's navigation, inside a full-screen root. It passes touches through the empty space around tools. It must stay mounted while users navigate.
```tsx
import { View } from 'react-native';
import { FloatingToolHost } from '@buoy-gg/shared-ui';
export function App() {
return (
);
}
```
`FloatingDevTools` already mounts this host. Do not mount a second one in an app using DevTools. Native system modals can appear above an app-level overlay; the host does not bypass platform modal layering.
## Build a custom controller
```tsx
import { useState } from 'react';
import { Text, TouchableOpacity } from 'react-native';
import {
FloatingTool,
openFloatingTool,
type FloatingToolRenderContext,
} from '@buoy-gg/shared-ui';
function RecordingTool({ id, revealToken, dismiss }: FloatingToolRenderContext) {
const [recording, setRecording] = useState(false);
return (
Recorder: {recording ? 'On' : 'Off'}
setRecording(value => !value)}
style={{ padding: 12 }}
>
{recording ? 'Stop' : 'Start'}Close
);
}
export function openRecorder() {
openFloatingTool({
id: 'com.example.recorder',
render: context => ,
});
}
```
The sample only changes a label. Connect Start and Stop to your recorder or feature-flag API. For ongoing operations, complete your Stop action before calling `dismiss()`.
For React DOM, use the `/floating` import and ordinary `button` and `span` elements inside the same component. Mount `` alongside your app. The browser grip also supports Enter and Space to hide or restore.
## Lifecycle and state
- Give each tool a stable, app-wide unique `id`. Use the same id in its registration and its `FloatingTool` props.
- Opening an existing id restores that instance and preserves its component state. `revealToken` lets the shared shell bring a hidden instance back.
- Hiding moves the tool; it does not stop an operation or unmount its content.
- `dismiss()` removes that tool's UI. It does not stop external work automatically. The tool controller owns that decision.
- Position persistence defaults to enabled, with separate keys for each id. Pass `enablePositionPersistence={false}` for a temporary tool.
- Reload restores positions when tools are opened again. It does not automatically open tools or restart their actions.
- Render a `FloatingTool` directly for a permanently mounted controller if you do not need the host registry.
## Layout
`variant="strip"` gives the larger control strip. The default `bubble` variant preserves the DevTools bubble layout. The shell supplies the grip and boundary handling; keep the content compact enough to fit the screen and give actions clear labels.
Set `dragFromSurface` to let users drag the background and non-interactive content with the same shared movement logic. Buttons keep their own gestures. Background taps do nothing; the grip still toggles hide and restore. The default is grip-only dragging.
Use `accessory` for a small profile picker or options panel attached to the strip. Use `handleAdornment` for a status dot that remains visible when the strip is hidden. Place clickable controls in the content area, not inside the drag grip.
Custom tools use independent positions. Move them apart when opening several at once. The host does not automatically rearrange existing tools.
## Verify your integration
The example app's **Network playground → Try custom floating tools** page runs two independent custom tools over an ordinary app button. It is the quickest way to see how the host, the saved positions and hide/restore behave together before you wire your own.
In your own app, check the strip near the screen edges, on a narrow screen and after rotation, and confirm your content's buttons still respond when `dragFromSurface` is on. Open two tools at once to see that their positions are independent.
Floating tools are new. The shared shell is covered by unit and browser tests, but native gesture and layout behaviour has not been validated across the full range of devices yet, so confirm the interactions you depend on in your own build.
### Restore an open tool after reload
Call `registerPersistentFloatingTool(registration, storage)` once at app startup. Pass the same stable `id` and `render` used with `openFloatingTool`, plus storage with async `getItem` and `setItem` methods. React Native consumers can pass `persistentStorage` from `@buoy-gg/shared-ui`. The registration remembers opening and dismissal; `FloatingTool` continues to own position storage. Restoring preserves the saved hidden state. Opening the tool explicitly reveals it.
The registration returns an unsubscribe function. Register only tools whose render context is available at startup; a remote device connection must be re-established before restoring controls for it. This API persists visibility, not the tool's action state.
# Telemetry
Source: https://buoy.gg/buoy/latest/docs/telemetry
Buoy's install ping is separate from account validation and the connections you configure for Desktop, MCP, or Ask Buoy. This page describes the ping and its opt-out; it does not describe all network traffic from those features.
## Turn it off
For the React Native SDK, include `telemetry: false` in your initialization:
```ts
import { Buoy } from '@buoy-gg/core';
Buoy.init({
licenseKey: process.env.EXPO_PUBLIC_BUOY_KEY,
telemetry: false,
});
```
This example uses Expo's environment variable. React Native CLI apps should pass the key from their configured environment loader.
For Desktop, set `BUOY_TELEMETRY=0` in its launch environment. The opt-out prevents this ping and its telemetry-state writes. It does not disable account validation, tool persistence, or configured remote connections.
## What it sends
The SDK sends four fields. Desktop adds `surface`:
| Field | Example | Meaning |
| --- | --- | --- |
| `installId` | `9f3c1d7a-…` | Random identifier saved locally for this installation |
| `version` | `7.0.22` | Installed Buoy version |
| `platform` | `ios` | Operating-system platform |
| `tier` | `free` | `free` or `pro` for an admitted account; `locked` when no account has been verified |
| `surface` | `desktop` | Added by Desktop to identify the sending application |
The install identifier is generated independently of hardware and account properties. It persists with local telemetry state; reinstalling does not necessarily remove that state.
## What it never sends
The ping payload does not include tool events, console messages, storage values, screenshots, email, account keys, app names, bundle IDs, repository names, or environment values.
This payload restriction does not apply to other features. Account validation sends the information needed to validate access. Desktop and MCP exchange tool data over their configured connection. Ask Buoy sends conversation context and tool results to your configured model endpoint. Read each feature's setup and data guidance before enabling it.
## When it fires
The React Native SDK checks the development flag before sending. Desktop checks on launch. Both throttle attempts to at most once per 24 hours for an installation and handle request failures without blocking normal startup.
The first SDK attempt for a new install prints a notice with the opt-out setting. Existing telemetry state suppresses repeated notices.
## Why it exists
The ping measures installations and activity separately from package downloads and account records. Package downloads can include CI runs, while download totals cannot distinguish an update from a new installation.
## Blocked networks
The ping endpoint is `https://buoy.gg/api/t`. Blocking that endpoint disables this measurement without disabling the tools.
Do not treat that as permission to block all of `buoy.gg`: sign-in and other configured features may need it. Account validation uses `buoy.gg/api/license`. Desktop, MCP, and your AI gateway have their own connection requirements.
# BuoyDevTools
Source: https://buoy.gg/buoy/latest/docs/flutter/buoy-devtools
The `BuoyDevTools` widget is the entry point for Buoy on Flutter — the analog of React Native's `FloatingDevTools`. It wraps your app and renders a draggable floating button that opens a menu containing all your registered debugging tools.
## Basic Usage
```dart
import 'package:buoy/buoy.dart';
MaterialApp(
builder: (context, child) => BuoyDevTools(
licenseKey: 'YOUR_LICENSE_KEY',
child: child ?? const SizedBox.shrink(),
),
)
```
The [`buoy` umbrella](./installation) explicitly registers its bundled tools on mount. Don't have a key yet? Grab one at [buoy.gg/pricing](https://buoy.gg/pricing).
Mount it from `MaterialApp.builder` (or `MaterialApp.router`) so it sits above your Navigator and survives every route change.
## Props
| Prop | Type | Default | Notes |
|------|------|---------|-------|
| `child` | `Widget` | required | Your app (usually the `MaterialApp` builder child) |
| `licenseKey` | `String?` | `null` | Free or Pro account key; widget remains debug-only |
| `deviceName` | `String?` | auto | Label in Buoy Desktop / MCP. Default: `'Flutter App (ios · 2c1d)'` — the app, the platform, and the last 4 of the install id |
| `deviceId` | `String?` | auto | Leave unset: each install mints `flutter-app-ios-<8 hex>` once and keeps it. If you pin one it MUST be unique per device — two devices under one id look like one device to the broker |
| `socketUrl` | `String?` | auto | LAN broker URL for physical devices |
| `tools` | `List` | `[]` | Extra custom tools beyond self-registered ones |
## How Tools Register
React Native discovers tools by optional-require; Dart has no equivalent, so Flutter tools register explicitly — but with the umbrella package you never write the call yourself:
- **Umbrella** — `package:buoy` calls the registration functions for its bundled tool list when `BuoyDevTools` mounts. Adding a separate dependency does not extend that list. Install and it shows up:
```bash
flutter pub add buoy
```
- **À la carte** — depend on the individual packages instead, call `registerBuoyNetwork()` (etc.) yourself before `runApp`, and mount `BuoyDevTools` from `buoy_core`.
A few tools still need one app-owned hook — the router for Routes, a `ProviderScope` observer for Riverpod, `BuoyImage` for Images, a search callback for Impersonate. The umbrella can't supply those, so each tool page notes its one line.
## Custom Tools
Need something specific to your app? Pass your own `BuoyTool`s via the `tools` prop (or `Buoy.registerTool` before mount):
```dart
import 'package:buoy/buoy.dart';
import 'package:flutter/material.dart';
MaterialApp(
builder: (context, child) => BuoyDevTools(
tools: [
BuoyTool(
id: 'flags',
name: 'Flags',
color: const Color(0xFF34D399),
icon: (size, color) => Icon(Icons.flag, size: size, color: color),
screenBuilder: (context) => const FeatureFlagDebugger(),
),
],
child: child ?? const SizedBox.shrink(),
),
)
```
See [Custom Tools](./custom-tools) for the full schema and desktop-sync adapters.
## Draggable Button
The floating button can be dragged anywhere on screen. It remembers its position between sessions, so it stays where your team likes it, and it tucks out of the way on its own while the dial or a tool is open. Tools you minimize dock above it and come back with a tap.
## Beyond the in-app menu
`BuoyDevTools` is also the source of truth for Buoy's other surfaces. The same tools you see in the menu sync out over a local broker, so once this is set up you can — with no extra code — also:
- open the [Buoy Desktop](../desktop) dashboard and inspect the same live app on a full screen, or
- point an AI agent at your app with the [MCP server](../mcp).
Simulators and emulators connect automatically. Physical devices take one `socketUrl` pointing at your machine:
```dart
BuoyDevTools(
socketUrl: 'http://192.168.1.20:42831',
child: child ?? const SizedBox.shrink(),
)
```
The current sync target and connection state show up in the menu's Settings tab under desktop sync.
## Debug builds only
`BuoyDevTools` renders your child and nothing else outside a debug build — no button, no dial, no sync, no capture. There is no flag to change that on Flutter yet, This describes initialization managed by the widget; separately initialized code has its own behavior.
Two React Native options have no Flutter counterpart today:
- **`environment`** — the RN floating button prints an environment badge (`local`, `dev`, `staging`, `qa`, `prod`). The Flutter bubble doesn't.
- **`headless` and release-build sync** — RN can mount sync-only with no on-device UI, and can opt a release build into sync with `externalSync.enableInRelease` plus a Pro license. Flutter's debug-only gate means neither applies yet.
Both are on the Flutter roadmap.
## Next Steps
- [Custom Tools](./custom-tools) — Build team-specific debugging tools
- [Buoy Desktop](../desktop) — Inspect the same app on a full dashboard
- [AI / MCP Server](../mcp) — Drive your app from your AI editor
- [Quick Start](./quick-start) — Full setup walkthrough
# Custom Tools
Source: https://buoy.gg/buoy/latest/docs/flutter/custom-tools
Add a custom widget with `BuoyTool`. The basic example mounts a counter so you can verify the tool opens and responds to input. Complete [Installation](./installation) first, including your account key and debug-mode widget setup.
## Basic Custom Tool
```dart
import 'package:buoy_core/buoy_core.dart';
import 'package:flutter/material.dart';
void main() {
runApp(MaterialApp(
builder: (context, child) => BuoyDevTools(
licenseKey: const String.fromEnvironment('BUOY_KEY'),
tools: [
BuoyTool(
id: 'counter',
name: 'Counter',
color: const Color(0xFFF87171),
icon: (size, color) => Icon(Icons.plus_one, size: size, color: color),
screenBuilder: (context) => const CounterDebugger(),
),
],
child: child ?? const SizedBox.shrink(),
),
home: const Scaffold(body: Center(child: Text('Open the Counter tool'))),
));
}
class CounterDebugger extends StatefulWidget {
const CounterDebugger({super.key});
@override
State createState() => _CounterDebuggerState();
}
class _CounterDebuggerState extends State {
int count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Count: $count'),
FilledButton(
onPressed: () => setState(() => count += 1),
child: const Text('Increment'),
),
FilledButton(
onPressed: () => setState(() => count = 0),
child: const Text('Reset'),
),
],
),
),
);
}
}
```
Run with `flutter run --dart-define=BUOY_KEY=YOUR_LICENSE_KEY`. Open Counter, increment it, then reset it to zero. Later sections are integration sketches for your own stores and widgets.
## Register Before Mount
Register before mounting when the tool also needs a Desktop or MCP adapter. This integration sketch assumes your app defines `MyApp`, `_count`, and `_listeners`; actions must notify those listeners after changes:
```dart
void main() {
if (kDebugMode) {
Buoy.registerTool(
BuoyTool(
id: 'counter',
name: 'Counter',
color: const Color(0xFF34D399),
icon: (size, color) => Icon(Icons.plus_one, size: size, color: color),
onPressed: (context) {
// Toggle-style: no screen — just run an action
},
),
adapter: ToolSyncAdapter(
version: 1,
getSnapshot: () => {'count': _count},
subscribe: (onChange) {
_listeners.add(onChange);
return () => _listeners.remove(onChange);
},
actions: {
'reset': (_) {
_count = 0;
for (final listener in List.of(_listeners)) {
listener();
}
},
},
),
);
}
runApp(const MyApp());
}
```
## BuoyTool Schema
| Field | Type | Notes |
|-------|------|-------|
| `id` | `String` | Stable id (dedupes with first-party tools) |
| `name` | `String` | Dial label (rendered uppercase) |
| `color` | `Color` | Accent on the dial |
| `icon` | `(size, color) → Widget` | Prefer a Buoy icon or Material glyph |
| `description` | `String?` | Shown in settings tool cards |
| `screenBuilder` | `WidgetBuilder?` | Full-screen tool host |
| `modalBuilder` | `BuoyToolModalBuilder?` | Draggable/resizable modal (wins over screen) |
| `onPressed` | `void Function(BuildContext)?` | Toggle-style — no UI opens |
## Multiple Custom Tools
```dart
BuoyDevTools(
tools: [
BuoyTool(
id: 'auth',
name: 'Auth',
description: 'View auth state',
color: const Color(0xFF818CF8),
icon: (size, color) => Icon(Icons.lock, size: size, color: color),
screenBuilder: (context) => const AuthDebugger(),
),
BuoyTool(
id: 'flags',
name: 'Flags',
description: 'Toggle features',
color: const Color(0xFFFBBF24),
icon: (size, color) => Icon(Icons.flag, size: size, color: color),
screenBuilder: (context) => const FeatureFlagViewer(),
),
],
child: child ?? const SizedBox.shrink(),
)
```
## Accessing App State
Keep the tool inside the providers it needs. This Riverpod example assumes your app defines `authProvider`; import ConsumerWidget and WidgetRef from flutter_riverpod and the UI widgets from flutter/material.dart:
```dart
class AuthDebugger extends ConsumerWidget {
const AuthDebugger({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(authProvider);
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text('User: ${user?.email ?? 'Not logged in'}'),
Text('Role: ${user?.role ?? '—'}'),
FilledButton(
onPressed: () => ref.read(authProvider.notifier).logout(),
child: const Text('Force Logout'),
),
],
);
}
}
```
## Next Steps
- [BuoyDevTools](./buoy-devtools) — Core widget reference
- [Buoy Desktop](../desktop) — The full desktop dashboard
- [AI / MCP Server](../mcp) — Drive your app from your AI editor
- [Quick Start](./quick-start) — Full setup walkthrough
# Buoy Desktop
Source: https://buoy.gg/buoy/latest/docs/desktop
Buoy Desktop is a native dashboard for macOS, Windows, and Linux that mirrors your on-device Buoy tools to a full-size window in real time. It includes a live performance HUD, multi-device switching, and remote control over the running app.
React Native and Flutter devices speak the **same protocol**, so they show up side by side in one dashboard.
## Requirements
- A verified Buoy account in Desktop and in the app. A connected device does not sign Desktop in.
- **A running app** with Buoy devtools installed and open on a device or simulator (React Native or Flutter).
- **React Native:** the `@buoy-gg/external-sync` package installed in the app — it's the sync client, and it ships separately from the tools (see below).
- The app and the desktop dashboard on the **same machine or local network**.
## Install
The download button above grabs the right build for your machine automatically; every build for macOS, Windows, and Linux is on the [GitHub releases page](https://github.com/Buoy-gg/Buoy-Desktop/releases/latest). It auto-updates, so you stay on the latest build. Buoy Desktop is free to use — a [Buoy Pro license](https://buoy.gg/pricing) unlocks full history and unlimited capture, See pricing for current plan allowances.
## Connect your app
Buoy tools sync to a local broker on **port 42831**. Launch Buoy Desktop first; it starts the broker and auto-detects connected devices. Use the device switcher in the title bar to choose which device every tool inspects — every install of your app is its own entry, named after the app and hardware (`Acme App (iPhone 17 Pro · 2c1d)`). If no device appears, the dashboard shows a troubleshooting panel with your machine's exact URLs and a test you can run from the phone's browser.
### React Native
First, install the sync client in your app. It's a separate package on purpose — apps that never use the desktop dashboard don't carry the sync code at all:
Restart Metro after installing it. In development, `FloatingDevTools` detects the package and derives the broker address from Metro. The device must be able to reach that address; use the options below when the derived host is unsuitable.
Need to point somewhere else? Pass `socketURL` in the `externalSync` prop:
- **Expo tunnel mode** — the derived host is the tunnel domain, which has no broker; pass your machine's LAN IP explicitly.
- **Android over USB** — just run `adb reverse tcp:42831 tcp:42831` once per cable session; no `socketURL` needed (the derived `localhost` is kept as-is on physical devices — the `10.0.2.2` rewrite only applies to emulators).
- **Broker on another machine** — pass that machine's `http://:42831`.
```tsx
```
> **Just installed a @buoy-gg package?** Restart Metro with `--clear` — Metro caches the "optional package missing" resolution, and a plain reload never picks the new package up.
#### Release builds
A shipped app must never dial a broker on a customer's phone, so sync is **off** whenever `__DEV__` is false. To profile a release build you own — a local `--configuration Release` run, an internal TestFlight/EAS build, a field build that ships headless — opt in explicitly. It also requires a real Pro license.
```tsx
```
There is no Metro server in a release bundle, so the broker host can't be derived and `socketURL` defaults to `http://localhost:42831`. That is already right for the iOS Simulator, the Android emulator, and Android over USB (`adb reverse tcp:42831 tcp:42831`) — pass it explicitly for a physical iOS device or a broker on another machine.
Most tools work the same in a release build — network capture, storage, console, the state tools, routes, images, assets, the performance HUD, and remote actions. Three things stay off, by design and not by choice:
- **Highlight Updates** (render counts, and the MCP `describe_screen` / `tap_element` / `measure_renders` calls) needs React's DevTools hook, which React only installs in dev builds.
- **Network response overrides** stay disabled — a shipped build must not be able to mock its own responses.
- **Reload** falls back to `expo-updates`; without that package installed, `reload_app` reports that it has no mechanism instead of reloading.
### Flutter
Run a debug build with `BuoyDevTools` mounted and your account configured. Flutter's widget does not enable this connection in profile or release mode.
- **iOS Simulator / Android Emulator** — connects automatically (`localhost` / `10.0.2.2`).
- **Physical devices** — pass your computer's LAN IP:
```dart
BuoyDevTools(
socketUrl: 'http://192.168.1.20:42831',
child: child ?? const SizedBox.shrink(),
)
```
iOS will show the Local Network permission prompt on first connect — tap Allow. After adding a new `buoy_*` package, do a **full restart** (hot reload won't pick up new registrations).
## What Desktop adds
- **Full-screen tools** — Network, Storage, Console, Routes, Events, Env, Images, Impersonate, and more — plus React Native–only panels (React Query, Redux/Zustand/Jotai, Bench, JS Top) when those packages are installed.
- **Live performance HUD** — Stream FPS, CPU, and memory from the device while you use it.
- **Multi-device** — Switch between every connected simulator and physical device (RN and Flutter mixed).
- **Remote actions** — Edit storage, navigate routes, and drive installed tools from your desk.
- **Screenshot tool** — Capture a region or a specific component from the iOS Simulator (React Native).
- **[Ask Buoy](./tools/ask-buoy), mirrored** — Follow a tester's in-app AI conversation live from your desk: what it says, what it changed, whether each change can be put back, and what the turn cost in tokens. Read-only, plus remote undo — there is no desktop composer on purpose, because conversations are started on the device. Keep the broker on a trusted development network; account admission does not authorize individual users to control particular devices.
- **Built-in troubleshooting** — A "no devices" panel shows your machine's exact URLs with a phone-browser test; the Diagnostics console streams the broker's own connection log (handshakes, disconnect reasons, version mismatches — replayed even if they happened before you opened it); offline devices are removable and age out after a day.
## How it works
The desktop app hosts the same local broker the [MCP server](./mcp) uses. Your app connects as a device; the dashboard connects as a "Dashboard" client and receives live state and sends actions over the external-sync protocol. Tool data travels between the device and the configured broker, including across your LAN for physical devices. Account validation and telemetry use separate services; see [Telemetry](./telemetry).
## What's Next
- [AI / MCP Server](./mcp) — Drive the same tools from your AI editor
- [React Native Quick Start](./quick-start) — Get Buoy into an RN app
- [Flutter Quick Start](./flutter/quick-start) — Get Buoy into a Flutter app
---
## FAQ
### How do I see my React Native devtools on my desktop?
Download Buoy Desktop for macOS, Windows, or Linux and open your app with Buoy running. The app derives the broker address from the Metro dev server that served the bundle, so simulators, emulators, and physical devices on the same Wi-Fi connect with zero config.
### Which port does Buoy Desktop use?
Buoy tools sync to a local broker on port 42831. Launch Buoy Desktop first — it starts the broker and auto-detects devices. For Android over USB, run `adb reverse tcp:42831 tcp:42831` once per cable session.
### Can I use it with a release build?
Sync is off whenever `__DEV__` is false, so a shipped app never dials a broker on a customer's phone. To profile a release build you own — a local Release run, an internal TestFlight/EAS build — opt in explicitly; it also requires a real Pro license.
# AI / MCP Server
Source: https://buoy.gg/buoy/latest/docs/mcp
Drive your Buoy dev tools from an AI coding assistant. The Buoy MCP server lets Claude Code, Cursor, and other MCP clients read your app's live runtime — network requests, storage, routes, console, and framework state — and take actions against your running app.
Works with **React Native and Flutter** on the same broker. Some capabilities below are React Native–only today (called out inline).
## Requirements
- **Buoy Pro** — the MCP is a Pro feature. Configure the MCP process account as well as the device account. Data and action tools require Pro; device discovery remains subject to broker admission.
- **Node.js 18+** on the machine running your editor.
- **A running app** with Buoy devtools open on a device or simulator (React Native or Flutter).
- **macOS + Xcode** — only for the `screenshot_component` tool (it captures the iOS Simulator). Other host-driven features, such as camera and TV input, also have platform-specific requirements; check their tool pages.
## Install
Run setup from the app project. Configure the MCP process with your account key through its supported environment configuration; a device key does not sign the MCP process in. Use a trusted development network for the broker.
> Installed Buoy with the [agent prompt](./quick-start)? Ask the same agent for "the Desktop and MCP step" — the install instructions it followed cover `@buoy-gg/external-sync` and `npx @buoy-gg/mcp init`.
One command wires the server into your editor and installs the Buoy skill:
```bash
npx -y @buoy-gg/mcp@latest init
```
Setup writes `.mcp.json` and `.cursor/mcp.json`, and updates `.vscode/mcp.json` when `.vscode` exists. Other server entries are preserved; rerunning replaces the Buoy entry, including customizations. It copies the bundled `buoy-optimize` skill, overwriting matching files on reruns. Save skill customizations before updating. Existing debugging-guide blocks are preserved and need separate review.
Then restart your editor (or reconnect the MCP server) and open your app with Buoy devtools running:
- **React Native** — install `@buoy-gg/external-sync`, restart Metro, and mount `` with your account key (broker address is derived from Metro; physical devices usually need no config). Profiling a **release build**? Sync is off there unless you opt in — see [release builds](./desktop#release-builds).
- **Flutter** — run a debug build and mount `BuoyDevTools` with your account key (simulators auto-connect; physical devices pass `socketUrl: 'http://:42831'`).
The generated npx entry launches `@buoy-gg/mcp@latest` and may need registry access. Review package updates and verify the connected device after restarting your editor.
### Corporate / private npm registries
If your machine's `.npmrc` points npm at a private registry that isn't reachable (common on work laptops, e.g. off-VPN), `npx @latest` would hang trying to download the package on every editor launch. `init` probes that registry first and, when it's unreachable, automatically installs a **pinned local copy** from public npm and writes a `node ` config instead . Package download is removed from the local server launch path; account and broker connections can still use the network. You can also force the behavior:
```bash
npx -y @buoy-gg/mcp@latest init --local # install a pinned local server
npx -y @buoy-gg/mcp@latest init --npx # launch through npx @latest
npx -y @buoy-gg/mcp@latest init --registry # registry the local install pulls from
```
To update the local server, run `npx -y @buoy-gg/mcp@latest init --local`.
## Updating
To refresh configuration, rerun setup. Matching skill files are overwritten, while existing debugging blocks are preserved. Save skill customizations and review debugging-guide updates separately:
```bash
npx -y @buoy-gg/mcp@latest init
```
The server also checks npm about once a day and, when a newer version is out, includes a one-line notice in the `list_devices` result so your assistant can prompt you to update.
## Manual configuration
If you'd rather wire it by hand, add this to your MCP config:
```json
{
"mcpServers": {
"buoy": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@buoy-gg/mcp@latest"],
"env": { "BUOY_VERIFY": "auto" }
}
}
}
```
`BUOY_VERIFY` controls the reminder to check a change on the device before
calling it fixed — see [Confirming a fix actually worked](#confirming-a-fix-actually-worked).
## Usage
Ask your assistant to start with `list_devices` to see connected devices and the tools each exposes. From there it can:
- **Inspect runtime** — `get_events` (network, state changes, route changes, storage writes, …), `get_snapshot`, and per-tool readers. Available sources depend on which packages the app installed (Flutter includes Riverpod; React Native includes Redux/Zustand/Jotai/React Query/renders when those packages are present).
- **Work a single HTTP request** — `get_network_requests` lists requests *with their ids* and marks which are pinned/saved; `network_action` pins or saves one (see below).
- **Take the app offline, or slow it down** *(React Native, development builds)* — `network_conditions` applies offline or added latency to the whole device; [response overrides](./tools/network) cover one specific URL (see below).
- **Take actions** — navigate routes, edit storage, and more via `call_action` or the tool-specific wrappers. React Native also exposes dispatch Redux / set Zustand/Jotai / invalidate React Query when those tools are installed.
- **Drive the UI** *(React Native)* — `describe_screen` and `tap_element` let the agent read what's on screen and interact with it, no screenshots (see below).
- **Benchmark performance** *(React Native Bench)* — `run_benchmark_batch` and the perf-monitor tools.
- **Profile the JS thread** *(React Native)* — `get_js_thread_top` returns a live "Task Manager" of which timers, Promise chains & callbacks eat JS-thread time (with freeze attribution); `get_js_thread_origin_detail` drills into one origin's scheduling site and stats.
- **Screenshot a component** *(React Native / iOS Simulator)* — `screenshot_component` locates a component by testID/name and returns a tight, cropped image.
- **Reload the app** *(React Native)* — `reload_app` restarts the JS bundle (see below).
A good starting prompt on React Native: **"buoy optimize"** starts a guided performance pass with the bundled skill. See [Buoy Optimize](./optimize).
## Confirming a fix actually worked
After changing app code, repeat the interaction that exposed the problem. Record the device, build, interaction, and observed result. A successful tool call is not proof that the user-visible problem is fixed.
For render work, compare the same interaction before and after with `measure_renders`. Check both the metrics and the UI, and repeat other affected interactions to catch regressions.
`BUOY_VERIFY` controls reminders based on selected source-file edits and tool calls. The watcher does not detect every kind of project change, and clearing a reminder does not prove correctness.
| Value | Behaviour |
| --- | --- |
| `auto` (default) | Reminds once per observed edit burst. |
| `always` | Repeats while observed edits remain unaccounted for by the tool-call policy. |
| `never` | Disables reminders. |
Report actual verification separately. Documentation-only edits generally need static checks; behavior changes need suitable behavioral evidence.
## Driving the UI (React Native)
Two tools let your agent operate a React Native app the way a user would — **without screenshots or pixel coordinates**, so it's fast and works on physical devices too:
- **`describe_screen`** walks the live React fiber tree and returns the on-screen elements as a compact, accessibility-style list — each with its label/text, `testID`, a normalized tap point, and (for controls) its type and current value, e.g. `Switch {toggle=true}`, `Slider {slider=75}`, `TextInput {text="Ada"}`. Inactive/covered navigator screens are pruned, so you see what's actually in front of the user.
- **`tap_element`** interacts with an element found by `testID`, `nativeTag`, or a fuzzy `query`. It invokes the element's handler directly in JS: tap a button (`onPress`), flip a switch or move a slider (`value`), or type into a field (`text`). Off-screen targets are scrolled into view first.
A typical loop is: `describe_screen` to see the options → `tap_element({ testID })` to act → `describe_screen` again to confirm the result. Prefer `testID` or `nativeTag` when a screen has repeated labels.
## Pinned requests as a handoff
`get_events` is the right tool for skimming activity, but it deliberately emits no request ids — so it can't be used to act on a specific call. **`get_network_requests`** covers that: the same compact one-line-per-request style, but each row leads with the id that every network action is keyed by, and marks which requests are 📌 pinned or 🔖 saved. Narrow it with `status: "errors"`, a URL `pattern`, or `includeBodies` when you need payloads.
**`network_action`** pins, saves, or clears. Pinned and saved requests ([RN](./tools/network) · [Flutter](./flutter/tools/network)) keep a snapshot subject to storage and body-size limits. They are separate from the live list, and availability after restart depends on successful persistence. Use retained records for handoffs:
- **You → your agent.** Pin the request that's broken, then ask the assistant to look at "the pinned request". `get_network_requests({ flagged: "pinned" })` reads available retained records, including earlier sessions when persistence succeeded.
- **Your agent → you.** An assistant that finds a failing call can pin it, so it's waiting at the top of your Network list when you next open the tool.
```
get_network_requests({ status: "errors" }) → 3. `fetch_1021` ❌ 500 POST /api/checkout …
network_action({ action: "pin", id: "fetch_1021" })
```
Requests kept from an earlier run of the app come back with ids prefixed `saved:` — they're snapshots, not live requests, so they can't collide with a fresh capture.
## Network conditions
**`network_conditions`** reads or sets the condition the device applies to new requests: `normal`, `offline`, `slow` (+500 ms) or `verySlow` (+2000 ms). Offline rejects intercepted HTTP(S) calls before they are sent, so an agent can walk your app's error and retry paths without a proxy and without touching the server. Latency adds one wait before dispatch; it does not cap bandwidth or change what NetInfo reports.
```
network_conditions({ action: "set", profile: "offline" })
tap_element({ testID: "checkout-submit" })
describe_screen() → the error state the app actually renders
network_conditions({ action: "set", profile: "normal" })
```
The condition lives in memory on the device. It resets on a full JS reload and is never persisted, so an agent that sets one should clear it when it's done rather than leave the next session offline. Setting a condition needs a development build and an admitted Free or Pro account; a release build refuses anything except `normal`. This control is a development preview, and native transports that bypass global fetch and React Native XHR are not affected by it.
## Reloading the app (React Native)
`reload_app` restarts the app's JS bundle from your editor — the same thing as shaking the device and hitting Reload, and the same primitive [Bench](./tools/perf-monitor) uses between benchmark cases. Use it when Fast Refresh didn't pick a change up, to clear leaked in-memory state before a measurement, or to re-run app startup.
In dev builds (including Expo Go and RN CLI) it uses React Native's `DevSettings.reload()`; otherwise it falls back to `expo-updates`, if your app installs it. By default the tool waits for the app to come back and reports how long the reload took, so your assistant can inspect the reconnection result — pass `wait: false` for fire-and-forget. All in-memory state is lost, so anything the assistant read before the reload is stale.
It ships with `@buoy-gg/core` itself, with supported app reload mechanisms. A failed or timed-out reload requires checking the app and connection.
## The buoy-optimize skill (React Native)
For a new skill installation, `init` adds a workflow for investigating rendering performance with [Bench](./tools/perf-monitor) and render measurements. Rerunning setup overwrites matching skill files; save customizations first. Ask your assistant for "buoy optimize" to compare a baseline and selected variants on the target device. [Buoy Optimize](./optimize) walks through a round, with a film and a worked example.
Keep device, build mode, workload and interaction comparable. Review failures, missing metrics and visual behavior before selecting a change. Simulator measurements do not establish results on physical hardware, and a ranking is not proof that the feature works correctly.
## How it works
Buoy tools run inside your app and sync to a local broker over the external-sync protocol. The MCP server connects to that broker as a "Dashboard" client — the same role the Buoy desktop app plays — or spawns its own broker in-process when no desktop app is running, so it works standalone.
React Native and Flutter devices appear together in `list_devices`.
## What's Next
- [Buoy Desktop](./desktop) — The full desktop dashboard on the same broker
- [React Native Quick Start](./quick-start) — Wire MCP against an RN app
- [Flutter Quick Start](./flutter/quick-start) — Wire MCP against a Flutter app
- [Network Monitor](./tools/network) · [Flutter Network](./flutter/tools/network) — Pin & save for agent handoffs
---
## FAQ
### How do I let Claude Code or Cursor debug my React Native app?
Run `npx -y @buoy-gg/mcp@latest init` from the app project, review the configuration changes described above, and restart the editor. Configure account access, app integrations and the broker connection, then inspect `list_devices`. Configuration alone does not establish a working app connection.
### Do I need Buoy Pro for the MCP server?
For data and actions, yes. Configure a verified account for the MCP process and the connected app. Reading runtime data and running actions require Pro.
### Does the MCP server work with Flutter?
Yes — React Native and Flutter apps connect to the same broker and the same server. A few capabilities are React Native–only today, and those are called out inline in the docs.
# Buoy Optimize
Source: https://buoy.gg/buoy/latest/docs/optimize
Buoy Optimize is a skill for your coding agent. You tell the agent which screen is slow. It builds each idea for a fix as its own version of that screen, runs every version on the device with [Bench](./tools/perf-monitor), and ranks them from the measurements. You check that each version still looks right. The agent keeps what's faster and correct, then starts the next round.
## How a round works
1. You describe the problem, for example "the light preview is slow with a lot of lights" or "this list drops frames when I scroll". Say "buoy optimize" to start the skill.
2. The agent keeps the current code as the baseline and builds each idea as a variant. Variants live on a development test route and are picked by a route parameter, so you can open any of them in the app.
3. Bench runs every variant on the connected device, several times each. It runs a throwaway warmup case first, shuffles the case order and waits between runs, so a warm phone or one lucky run is less likely to decide the ranking.
4. You open each variant and look at it. A variant that's faster but draws the wrong thing is out, whatever its numbers say. The agent can take screenshots, but you decide what looks right.
5. The agent keeps the variants that helped, drops the rest, combines ideas that work together and runs the next batch on the same workload.
It stops when the screen is fast enough, when the time budget you gave it runs out, or when the next step needs hardware or a decision from you. The final report names the chosen version, the device and build it was measured on, the settings, and what's still unconfirmed.
## Simulator first, then a real phone
Rounds on a simulator are quick and good for ruling ideas out. Simulator numbers only describe the simulator, though, so confirm the finalists on the phone your users have.
A phone gets warmer over a long batch, and a warm phone runs slower. Bench's Fast preset uses short cooldowns and assumes the phone stays cool, which is what a cold pack or cooling pad under the phone is for. The Slow preset waits 20 seconds between runs so a room-temperature phone can cool down on its own. Shuffling the case order is on in both, so any heat that's left spreads across every variant instead of piling up on the last one.
## What you need
- [Bench](./tools/perf-monitor) (`@buoy-gg/perf-monitor`) in a React Native development build. Bench's native dependencies don't run in Expo Go.
- The [Buoy MCP server](./mcp) in your editor. `npx -y @buoy-gg/mcp@latest init` sets it up and installs the `buoy-optimize` skill. Rerunning it overwrites the skill files, so save any changes you made to them first.
- Buoy Pro. Running benchmarks over MCP is a Pro feature.
The agent drives Bench with these MCP tools: `get_benchmark_settings`, `run_benchmark_batch`, `get_batch_report` and `compare_reports`. For a slow interaction rather than a slow screen, it starts with `measure_renders`, which counts renders during a set of steps and can compare them before and after a change.
## An example: 28 lights to 12,000
The film follows the light preview in EverLights, a Christmas light app. The first preview drew 28 lights. A real house needs thousands.
In the 12,000-light round, the agent tried three ways of producing each frame. Bench ran each one three times on an iPhone simulator:
| Version | UI FPS | JS FPS | CPU |
| --- | --- | --- | --- |
| The original 28-light preview | 60 | 59.2 | 68.6% |
| The current code at 12,000 lights | 49.1 | 8.9 | 115.7% |
| The chase moved to the UI thread, 12,000 lights | 47.5 | 47.1 | 22.8% |
The winning version stopped recomputing every light on the JavaScript thread. The chase effect only shifts colors along the strip, so the new version computes the colors once and moves them on the UI thread. JS FPS went from 8.9 to 47.1 and CPU dropped by about four fifths. UI FPS stayed in the high 40s, so this round didn't fix drawing speed, and the numbers still need confirming on a phone.
## What's next
- [Bench](./tools/perf-monitor): the benchmark runner Buoy Optimize drives
- [AI / MCP Server](./mcp): set up the server and the skill
- [Render Highlighter](./tools/highlight-updates): see which components re-render
## FAQ
### How do I make a slow React Native screen faster with AI?
Set up the [Buoy MCP server](./mcp), install [Bench](./tools/perf-monitor) in a development build, and ask your coding agent to "buoy optimize" the screen. The agent builds candidate fixes as separate variants, measures each on the device, and asks you to check how they look before it keeps one.
### Which coding agents work with Buoy Optimize?
Any agent that can use an MCP server and read a skill file, such as Claude Code or Cursor. `init` writes the MCP config for Claude Code, Cursor and VS Code.
### Can I trust a benchmark from the simulator?
Only for the simulator. Use simulator rounds to rule ideas out quickly, then run the finalists on a real phone before you ship.
### Does Buoy Optimize work with Flutter?
Not yet. The skill and `run_benchmark_batch` are React Native only.
# Ask Buoy
Source: https://buoy.gg/buoy/latest/docs/tools/ask-buoy
Ask Buoy is an in-app assistant for installed Buoy tools. It can inspect app data and run supported actions, such as editing storage or creating a development-only network override. It requires Pro and a model endpoint you configure. The feature is in beta.
Start with read-only access and a test build. Ask it to inspect a request, then check the tool result before enabling writes.
---
## Installation
Install the tool packages for the actions you want Ask Buoy to use, and complete their setup first.
Keep Buoy packages on compatible versions, including their exact license peer requirement. Check your dependency tree with `npm ls @buoy-gg/license` after upgrading; a successful install command alone does not establish compatibility.
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
({ Authorization: `Bearer ${await auth.getToken()}` }),
}}
/>
```
The tool appears in the dial as **ASK BUOY**.
---
## Point it at a model
Use an authenticated gateway that supports the selected protocol. `headers` supplies your app's session token on each request; the gateway must validate it, authorize the user, constrain model access and request size, and preserve streaming responses. Keep the provider credential on the server.
The installation example assumes your app defines `auth.getToken()`. Replace the endpoint and model ID with values your gateway supports. Do not expose a gateway that forwards requests without validating the caller.
For a private local experiment, `apiKey` can call a provider directly, but that key is included in the app bundle. Do not distribute that build to testers.
`protocol: "anthropic"` uses an Anthropic-shaped API; `"openai"` uses the corresponding compatible API shape. Services requiring request signing need a gateway that performs that signing. Choose token limits and provider options for the model you actually use, then test tool calls, streaming, errors, and interrupted turns.
Before enabling writes, send a harmless request such as “List the installed tools.” Check that the endpoint accepts the token, the action list is correct, and no app state changes.
### Teach it your data
Buoy learns your routes, query keys, store names and storage keys by watching the app run — and, for Zustand stores, the field names of the items in every list they currently hold. What it can't guess is what any of that *means*:
```tsx
context: {
notes: [
"user.rewards.points = loyalty points",
"Prices are integer cents everywhere. `/api/*` ids are prefixed — ord_, usr_, itm_.",
],
// Exact shapes for anything the agent may WRITE. Paste your real types —
// nothing parses them, they go to the model as written.
types: {
CartLine: "{ lineId: string; itemId: string; qty: number; unitPrice: number }",
Offer: "{ code: string; status: 'active' | 'expired'; endsAt: string /* ISO */ }",
},
}
```
Review these definitions when your app schema changes. They guide the model but do not replace validation in the tools.
**Why `types` is separate from `notes`.** Ask Buoy prefers an observed shape over anything you declare — runtime truth can't go stale, and the prompt tells it to copy the field names it finds. The digest gives it a head start where it can: for every list a **Zustand** store currently holds, it carries that list's item field names and types, so `lines[]` arrives as `{ lineId: string; itemId: string; qty: number; … }` before the agent reads anything. Values never travel — only names, types and the sketch of a shape. Redux slices and Jotai atoms are reported by name alone, so if that is where your data lives, `types` is the only way the agent learns its shape without reading an instance first.
But *there is nothing to observe when the collection is empty*, which is exactly the moment someone asks for the first cart line. The digest is honest about that rather than papering over it: an empty list is reported as a list whose shape is **unknown**. A note saying `item id = 123` doesn't fill the gap either — it gives the model the id and nothing about the object it goes in, so it invents `quantity` where you have `qty`, and your app renders nothing.
Provide types for objects the agent may create, especially empty collections. Ask Buoy is instructed to prefer observed shapes and flag disagreements; test the resulting writes rather than assuming a declaration guarantees correctness.
**Don't write them by hand.** Everything these notes describe is already in your repo. Paste this into Claude Code, Cursor, Codex — any coding agent that can read the project — and have it do the first pass:
```text
Read this repo and fill in `context.notes` and `context.types` for Buoy's Ask
Buoy — an in-app AI agent that reads and writes this app's live state at
runtime. It already discovers route paths, query keys, store and storage key
NAMES, plus the item field names of lists held in Zustand stores, and it reads
live values before writing. Your job is what those names MEAN, and the SHAPES
it cannot observe — anything in Redux or Jotai, and any collection that is
currently empty.
Look at: route definitions, React Query key factories, Zustand/Redux/Jotai
store shapes, AsyncStorage/MMKV key constants, and the TypeScript types behind
anything a user sees.
`types` is a map of name -> shape text, for every object the agent might
CREATE or WRITE: cart lines, addresses, user records, feature flags, anything
a store or query cache holds a list of. Copy the real TypeScript type. Keep
optional markers and literal unions; strip imports, generics and methods.
Add a trailing `/* comment */` for a unit or format that the type alone does
not convey (cents, ISO date, id prefix). Nothing parses these — they are
handed to the model as written.
`notes` is one short plain-English fact per string, in this order:
1. For each main screen: what it shows and WHERE that data comes from — the
React Query key pattern and endpoint, or the store and slice. This is how
the agent knows that "change the name on the screen I'm looking at" means
the query cache on one screen and a store on another.
2. Which screen loads the FULL set of something, and which screens load only
part of it. Ask Buoy can only read what your app has already fetched, so
when someone asks for "all the X" it has to know where all of them are —
"the full list of items is on /catalog; the home carousel fetches one card
at a time as you swipe". Without this it can still go looking, but with it
it goes straight there.
3. What an ambiguous key, field, or store name MEANS in product terms.
4. Units and formats that apply broadly — cents vs dollars, ISO vs epoch ms,
id prefixes.
5. Enum and status values, quoted exactly as the code spells them, where they
are not already visible in a type above.
6. Which source is authoritative when two of them hold the same thing.
7. Any rule a newcomer gets wrong — a field that looks writable but is
derived, two stores that must stay in sync.
Rules:
- Skip anything obvious from the name alone. `user.email` needs no note.
- No secrets, tokens, internal endpoints, or real customer data. These strings
go to our model endpoint with every message.
- 20-30 notes and the shapes that actually get written. Dense beats
exhaustive — every one of these is sent with every message.
Output only the `context` object, ready to paste.
```
Read what it gives you before shipping it — it is a first draft of the one input that most determines whether the agent gets your data right.
### Write down your team's procedures
Some tasks aren't guessable from the app: *"reproduce the pricing glitch"*, *"put this account in the expired state"*, *"set up the demo cart"*. Write them once as **procedures** and the agent follows them when someone asks:
```tsx
context: {
procedures: [{
id: "expired-subscription",
title: "Put the account into the expired-subscription state",
summary: "when asked to test what an expired or lapsed subscriber sees",
body: `1. Read the zustand store "account" and note subscription.status.
2. setState { subscription: { status: "expired", renewsAt: null } } — a merge, not a replace.
3. Navigate to /account. Done when the renew banner is showing.
Undo puts the real status back.`,
requires: ["zustand", "route-events"], // only listed when these tools are installed
}],
}
```
Only the `id` and one-line `summary` ride in every prompt; the body loads when a request matches, so a long playbook costs nothing until it's needed. A tester then types **"run the expired-subscription check"** and the agent opens the procedure first and follows it — instead of asking what that means. A procedure guides; it grants nothing: every step still goes through the same policy, approval card, undo and checks as any other call.
---
## What your team can do with it
| Who | What they type |
|---|---|
| **QA** | *"Make the next checkout call fail with a 500"* · *"Give me 1500 points"* |
| **Support** | *"Show me what user 8823 sees on the rewards screen"* · *"Is this a bug or did their offer expire?"* |
| **Product** | *"Put the app in demo state — gold tier, three items, promo applied"* |
| **Design** | *"Show me this card with a 60-character name, and again with no image"* |
---
## Nothing it changes is hidden
The default policy allows reads and ordinary writes immediately and asks for destructive actions. Use read-only mode for initial setup, or require approval for every change as shown below. The changes bar records supported reversible changes and identifies changes it cannot undo.
The count is honest in both directions: storage writes and query-cache edits are reversible because Buoy reads the old value *before* it writes (a cache edit the app has since refetched is left alone, and Undo says so); a state write with no captured prior value is labelled **permanent** rather than folded into a number Undo can't deliver; and one-shot actions like navigation aren't counted as changes at all. Typing **"undo that"** works too — the agent has its own undo tool wired to the same ledger as the bar.
**Destructive actions wait for a tap.** Wipes and resets show an approval card describing the *effect* — "Clear all saved app data" — not the raw payload. Tune it either way:
```tsx
({ Authorization: `Bearer ${await auth.getToken()}` }),
policy: {
requireApproval: ["write", "destructive"],
maxSteps: 12,
},
}}
/>
```
`policy.readOnly: true` refuses writes. For scoped access, set `allow` rules for permitted effects or tools; nonmatching calls are refused. `deny` rules take precedence. Keep these configurations separate from the approval example above.
The card takes a **note** — type *"just the second line"* before **Not now** and the agent gets your words verbatim, instead of a round trip of "what would you prefer?". **Allow for this chat** approves *and* stops asking about that action for the rest of the conversation — ten storage writes are one tap, not ten. It waives the approval card only: read-only, deny lists and release-build refusals still hold. **Settings → Permissions** lists what's been waived, each with an **Ask again** button, and a new conversation forgets all of it.
The card's long description is collapsed behind **Details**, so the buttons are always reachable — and a card you never answered comes back after a reload, still answerable. Tapping **Allow** then runs the change itself, through the same gate and the same undo ledger.
**It checks its own work.** "The tool said ok" is not the same as "the app shows it", so after a write the agent reads the app back. The activity row says **Verified** when the requested state is really there; **Done · not yet visible** when the write landed but the outcome hasn't shown yet — an override installed that nothing has fetched through — and the agent is told what would make it show (a refresh, a visit to that screen) before it may claim anything; or **Done · check failed** when the app doesn't hold what was written, in which case it may correct *once*, with a different change, and never repeats the write or reports it as done. Tap the row for the reason. Storage writes, store and cache edits, navigation and override rules are checked today.
**Or look without touching.** On a shared or support device, **Settings → Permissions → Read only** refuses every write and simulation until you turn it off — the header says **Read only** while it's on. It adds to whatever the app's `policy` already restricts and never loosens it, it takes effect on the very next call even mid-turn, and Undo still works, because putting things back is the safe direction.
**Or turn the asking off entirely.** On your own dev device, the header's gear opens **Settings → Permissions → Skip approval prompts**: every action then runs the moment the agent calls it, wipes included. It's the "I'm moving fast" switch — it waives the *approval* gates only, so `readOnly`, `deny` and the release-build refusals still hold, and the changes bar still records everything with Undo. It persists across reloads and restarts until you turn it off, and the settings row stays amber while it's on so you can see that it is.
**SecureStore values are off by default** — they're credentials, and they'd travel to your model endpoint. Key *names* are always readable; opt into values with `policy.secureReads: true`.
**You can keep typing while it works.** Anything you send mid-turn is parked in a list above the composer — in order, tap a row to edit it, ✕ to drop it — and sent one at a time as each turn finishes. The list holds, and says why, after an error, after Stop, and while a question or approval is waiting for you: a parked message is a next task, never an answer. **Stop** stops before the next call; anything already in flight finishes and keeps its receipt, every call it skipped shows as "Not run — stopped", and the composer invites you to say what to do instead. **Try again** keeps the stopped attempt above the new one.
**A question it asks you is always tappable.** When the agent needs a decision — your request could mean two things, or it's offering to do something you didn't quite ask for — the answer comes back as buttons, not as a sentence expecting you to type "yes". If it ever asks in prose anyway, Buoy turns that into a card for it. Typing over the card still works; it just stops being the only way.
It also gets out of your way: when it navigates or taps, the sheet drops to a strip for a couple of seconds so you see the app do it. Minimize it and it keeps going — if it then needs a tap, the approval waits and its chip in the minimized dock gets a **!** badge; tap the chip and the card is there. (Closing the sheet still stops the turn.)
**Big results aren't lost.** A tool result over 24,000 characters is cut for the model — but the whole thing is kept for the conversation, and the agent can read any part of it back by field, by search term or by window. So *"what did the server send for the third item?"* is answerable even when that field sits far past the cut, and *"what did that look like before you changed it?"* comes from what the agent actually saw at the time, not a fresh read of the new value. The kept copy lives in memory for the conversation only and is never written to disk.
**Reading a turn.** Consecutive reads fold into one row — *Looked at Network and Storage · 3 reads* — and every write, failure, refusal and declined call stays its own row. A quiet line under each answer says how long it took and how many actions ran. Long tables say when they're cut and offer **Show all**; code stays as code.
Every finished answer carries **Copy conversation** — and so does the header — which puts the whole conversation, cards included, on the clipboard as plain text. For a bug report, the header's document button asks the agent to **summarize for a ticket**: one card with steps, expected, observed, build, the evidence it actually saw and what's still applied, with anything it inferred kept in its own row — and its own **Copy finding**. Nothing is run or changed to produce it.
**See what it actually did.** The header's gear opens **Settings → Chat → Show agent thinking**. On, every answer grows a collapsed strip — `2 steps · 1 thought · 3.2s · 6210 tokens` — that opens into the model's reasoning and each step it ran, in the order they happened. Tap a step for what it **sent** and what it **returned**.
That last part is the one that finds bugs. A step whose status is `ok` and whose result is `{"ok": false, "error": "no such key"}` looks like a working step until you can see the payload.
**Copy conversation** — the button under every answer — takes the whole conversation as plain text, and includes the working while this is on:
```text
You: show my cart
[thinking]
I should read the bag store first.
[1] zustand.getStoreState — Done
{"storeName":"Poké Mart bag"}
Ask Buoy: Here are the items in your cart.
```
Off by default, because the answer is what the sheet is for.
Not every model reports reasoning. Claude thinks by default; most OpenAI models return none and the strip then shows the steps alone, which it says rather than looking empty.
**The conversation survives a reload.** Reload the app, restart it, or crash it, and the chat is there when you come back — and so is the agent's memory of it, so "undo that" still means something. A turn the app died in comes back marked interrupted rather than blank, messages you had parked come back as unsent rows, and an approval you never answered comes back as a card that says it predates the restart — **Allow** re-reads what it would change and refuses if that has moved on since. In a very long session the agent's memory is trimmed before the screen is; a quiet line marks the gap when that happens — and the exchanges whose changes are still applied (the override you armed, the user you're impersonating) are the *last* to go, so "turn that off" keeps meaning something. This is what makes the tool bearable while you iterate, and it is the only thing that survives a crash. **New conversation** deletes it.
---
## Watch it from your desk
If the device is also connected to [Buoy Desktop](../desktop), Ask Buoy shows up there too — as a **read-only mirror**: the conversation as it streams, what the agent has changed and whether each change can be put back, and what the turn has spent in tokens. It's how you follow a tester's session from your own machine without standing over their shoulder.
Desktop exposes two actions: **Undo everything reversible**, and **reset** (which undoes first and refuses to clear if a revert fails, rather than dropping the only record of what is still applied).
Desktop has no chat composer. Start conversations on the device. Use the broker only on a trusted development network; account admission is not per-user authorization to control another device.
---
## Which build should QA run?
**Use a development build for response overrides and screen-driving actions.** An internal distribution label alone does not make `__DEV__` true. Some actions only work when `__DEV__` is true, and a few would otherwise *report success and do nothing*. Ask Buoy refuses those before running them and says why, and the sheet's first screen tells you which kind of build you're on.
A release build still reads storage, network, state, routes, console and crashes, and still impersonates and navigates — which is the support persona's whole job.
> **Note:** Ask Buoy is Pro in every build. Separately, in a release build Buoy itself only renders for a licensed Pro user at all. See [Buoy Pro](https://buoy.gg/pricing).
---
## Configuration reference
Everything the `askBuoy` prop takes.
| Option | Default | What it does |
|---|---|---|
| `endpoint` | *required* | Where the conversation is POSTed. The only thing Buoy can't invent for you. |
| `model` | *required* | Model id, passed through to the endpoint. |
| `protocol` | `"anthropic"` | The wire shape the endpoint speaks — `"anthropic"` or `"openai"`. |
| `headers` | — | `() => Record` (may be async), resolved **before every request** so short-lived tokens work. This is the intended auth story. |
| `apiKey` | — | A provider key sent straight to the provider. **Dev only** — it is compiled into your bundle in plaintext. |
| `maxTokens` | `4096` | Ceiling for one response. Choose a value supported by your model and large enough for its response and reasoning requirements. |
| `anthropicVersion` | — | The `anthropic-version` header. Ignored when `protocol` is `"openai"`. |
| `requestOverrides` | — | Extra fields merged into every request body, last — `temperature`, a gateway's routing hints, whatever your endpoint demands that this config doesn't model. |
| `policy` | destructive asks | What the agent may do without asking. See [above](#nothing-it-changes-is-hidden). |
| `context` | — | `{ notes, types, procedures }` — what your data *means*, the shapes of what it may write, and your team's playbooks. Keep these aligned with your app schema. |
| `appName` | the app name | Shown in the sheet header and given to the model. |
| `peek` | `true` | When the agent navigates or taps, the sheet drops to a strip for a couple of seconds so the user sees the app do it. Set `false` to keep the sheet fixed. |
| `persistTranscript` | `true` | Keep the conversation across a reload, restart or crash. `false` keeps it in memory for the session and no longer. |
| `onEvent` | — | Every engine event as it happens — tool starts and results, outcome checks (`tool-verified`), blocks, approvals, retries, errors, per-request token usage with prompt-cache counters and the model id the provider actually served, and a `stopReason` on every `done`. Log agent activity to your own systems, meter cost per seat. Called synchronously on the JS thread: keep it cheap. Throws are swallowed so a logging bug can't take the chat down. |
| `tools` | — | Descriptors for **your** custom tools, so the agent can drive them too. See below. |
### Let it drive your own tools
A [custom tool](../custom-tools) with a `sync` adapter is already dispatchable as `custom:` — Buoy Desktop and MCP can drive it today. Ask Buoy is different: it only offers what's in its catalog, so without a descriptor your tool is invisible to the chat while staying fully drivable from MCP.
```tsx
tools: [{
toolId: "custom:feature-flags", // must match the registered id
title: "Feature flags",
summary: "Read and set this app's feature flags.",
actions: [{
action: "setFlag",
summary: "Turn one feature flag on or off.",
params: {
type: "object",
properties: { name: { type: "string" }, on: { type: "boolean" } },
required: ["name", "on"],
},
effect: "write", // read | write | destructive — drives the approval gate
release: "works", // works | noop | empty | throws | unknown — anything but "works" is refused in a release build
}],
}]
```
Your actions then get the same param validation, policy gates and release-build refusals as the built-ins. Write a real `summary` and real param names: a parameter the model isn't shown the name of is a parameter it will guess at.
---
## Security
- **Gateway credentials.** With `headers`, your app obtains a session token and sends it to your endpoint. Keep provider keys on the gateway. Direct `apiKey` configuration embeds a provider credential in the app.
- **Model traffic.** Conversation requests go to your configured endpoint. Buoy account validation and telemetry are separate; see [Telemetry](../telemetry).
- **Its own traffic is invisible to it**, so it can never read back its own auth headers.
- **Credentials are stripped** from tool results by field name *and* by shape (bearer tokens, JWTs, key patterns) before anything is sent.
- **The saved conversation holds no tool results.** It survives a restart (see above) under a `@react_buoy` key, capped and scrubbed for credential shapes — but only what was *said*. The payloads the agent read (storage values, response bodies, user records) are never written; it comes back knowing what it did, not what it saw. (The full copies it can re-read mid-conversation are held in memory, after credential redaction, and vanish with the conversation.) Turn the whole thing off with `persistTranscript: false` if the agent works over regulated data, since anything on disk under a Buoy key is readable by the Storage tool and, through it, by clients with access to your development broker. Account validation does not provide per-user device authorization.
### What we send to the model
1. Your message, plus a system prompt with your app's name, your `context.notes` and `context.types`, and a **values-free** digest of the running app: route paths, store and storage key *names*, the *field names and types* of the items in each Zustand store's lists (a shape sketch, bounded in depth, width and total size — never the records themselves), and a "right now" block re-read before each message with the current route, which query keys are mounted on screen, and the method + URL of the last few requests. Never values, never bodies.
2. The results of tool calls it makes — state, storage values, response bodies — after the redaction above.
3. Nothing in the background: the digest is read when the chat opens and the "right now" block when you send a message; requests happen only while a turn runs.
The agent reads live app data, and some of that comes from services an attacker may influence. Prompt injection against tool-using agents is a real, unsolved class of attack. Ask Buoy limits available actions — visible banner, real undo, destructive actions behind approval, and a system prompt that treats app data as data — but use `readOnly` or a tighter `requireApproval` against production data.
---
## Troubleshooting
- **A raw provider error on the first message** — the endpoint and `protocol` usually disagree. Ask Buoy warns when it can spot this itself.
- **"Endpoint busy — retrying in 4s"** — a rate limit or an overloaded provider. The agent waits and asks again by itself (up to twice, and only when nothing was generated yet, so nothing ever runs twice); you don't need to do anything.
- **"Couldn't reach your AI endpoint"** — the gateway dropped; the answer has a **Retry** button. Chronic cases are usually a corporate proxy buffering SSE.
- **"This conversation is too large for the AI endpoint"** — the model's window is full and there was nothing older to trim. Start a new conversation, or ask a shorter question. (The agent trims older exchanges by itself first, and measures the endpoint's real token count as it goes, so this is rare on a 128k+ window.)
- **It answers all at once instead of streaming** — React Native's built-in `fetch` has no streaming body. On Expo, `expo/fetch` as the global enables it.
- **An action is refused as "does not work in this build"** — that's the release-build truth doing its job, not a bug.
## Related
- [Scenarios](./scenarios) — once the chat gets a setup right, save it as a one-tap button for the team. Determinism beats a fresh LLM run every time.
- [Network overrides](./network) — the durable request faking Ask Buoy drives.
- [Impersonate](./impersonate) — see the app as a specific user.
- [Custom tools](../custom-tools) — register your own, and hand the agent their descriptors so it can drive them too.
## Web support (unreleased)
Register this package’s /web namespace in FloatingDevTools modules to use its shared panels and actions in a browser app. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Camera
Source: https://buoy.gg/buoy/latest/docs/tools/camera
Buoy Desktop supplies a camera feed to supported iOS Simulator apps on macOS. Sign in with a Buoy account before using the Desktop panel. Webcam, image, video, pattern and QR generation are available at Free limits; screen-region capture and non-QR generation require Pro access.
## Use it
Buoy Desktop → **Camera** → click a source. Then start your app however
you like. Sources: **Mac camera**, **Screen region**, **Barcode**, **Image**
(PNG, JPG, HEIC, WebP, GIF, BMP, TIFF), **Video** (MP4, MOV, M4V) and
**Test pattern**.
The app under test does not need the Buoy SDK. Its camera library and capture APIs must still support the simulated camera path.
The camera attaches at process start, so relaunch anything already running — Fast
Refresh won't do it. Switching source afterwards is live. Grant the host camera or screen-recording permissions required by your source, and verify the app's own capture flow.
## Screen region
A resizable box on your desktop is the camera. Put it over a QR code, a licence
or a document to supply frames to your app. Verify its scanner callback and payload handling separately.
Drag to move, pull a handle to resize, **Esc** to put it away. Resizing snaps to
**16:9** — the only shape the camera has, so anything else gets black bars; hold
**Shift** to override. **Click-through** lets clicks land underneath, so you can
scroll the page you're filming.
Needs Screen Recording permission — without it the feed is black. This source
needs [Buoy Pro](https://buoy.gg/pricing).
## Barcodes
QR, PDF417, Aztec, DataMatrix, EAN-8/13, UPC-E, Code 128/39/93, ITF.
Decoding support depends on the app scanner and capture path. The **Barcode** source generates supported formats from text you type; generation and decoding are separate checks.
Generating QR is free. The rest — PDF417 (driving licences), Aztec, DataMatrix,
Code 128 — need [Buoy Pro](https://buoy.gg/pricing).
## Compatibility
Camera libraries can disable Simulator paths at compile time. Check the version you use and test preview, scanning, still capture and recording separately. A working preview or a video used as input does not establish recording-output compatibility.
Use `buoycam patch` to inspect known library guards before applying a patch. A native-source patch requires rebuilding the app. See the [Camera setup page](https://buoy.gg/camera) for the host workflow and diagnostics. No compatibility table on this page substitutes for testing your app and library version.
## From an agent
Buoy's MCP server drives the whole tool, so a coding agent can run a camera test
through the supported MCP actions: find a simulator and its apps (`camera_devices`) and
the Mac's cameras and windows (`camera_inputs`), pick what to show
(`camera_source`), attach it — to one app (`camera_launch`) or to everything the
simulator launches (`camera_zero_setup`) — then check its own work
(`camera_status`, `camera_diagnose`) and clean up (`camera_stop`).
These host tools do not require an SDK connection from the app under test. MCP process/account setup still applies. The camera actions need
[Buoy Pro](https://buoy.gg/pricing); `camera_diagnose` works either way and will
tell you which tier you are on.
## Limits
- macOS + Xcode + Buoy Desktop. Screen sources need macOS 12.3+.
- Simulator only, not devices. Android emulators do webcams natively.
- No recording through `AVCaptureMovieFileOutput`. Recording through other paths, such as the Flutter camera plugin, has separate support.
- Front and back are the same feed.
- No GS1 DataBar or Micro symbologies.
- One injection owner at a time — quit other simulator-camera tools.
# Environment Inspector
Source: https://buoy.gg/buoy/latest/docs/tools/env
Inspect environment values available to the running JavaScript process and validate them against required names, types, and expected values.
Expo's build-time substitution does not make every `EXPO_PUBLIC_` variable enumerable at runtime. A value missing from this tool is not proof that it is missing from your app's bundle. See the limitations below.
## Installation
After installation, the Environment Inspector will be auto-detected and appear in your FloatingDevTools menu.
## Custom Configuration
For more control, use `createEnvTool` with the `envVar` builder to define required variables and validation rules:
```tsx
import { createEnvTool, envVar } from "@buoy-gg/env";
const envTool = createEnvTool({
requiredEnvVars: [
envVar("EXPO_PUBLIC_API_URL").exists(),
envVar("EXPO_PUBLIC_DEBUG_MODE").withType("boolean").build(),
envVar("EXPO_PUBLIC_ENVIRONMENT").withValue("development").build(),
envVar("EXPO_PUBLIC_MAX_RETRIES")
.withType("number")
.withDescription("Maximum API retry attempts")
.build(),
],
});
```
Mount the configured preset through `apps`; calling the factory alone does not change the discovered tool:
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
```
This fragment assumes you have already configured your Buoy account and mounted the menu as described in [Quick Start](../quick-start).
## The `envVar` Builder
Use the builder to declare a requirement:
```tsx
envVar("API_KEY")
.withType("string") // Set expected type
.withValue("sk_test_123") // Or set expected value
.withDescription("API Key") // Add documentation
.build() // Finalize config
// Shorthand for just checking existence
envVar("API_KEY").exists()
```
### Supported Types
```typescript
type EnvVarType = "string" | "number" | "boolean" | "array" | "object" | "url";
```
## `createEnvTool` Options
```typescript
type EnvToolOptions = {
name?: string; // default: "ENV"
description?: string;
colorPreset?: "orange" | "cyan" | "purple" | "pink" | "yellow" | "green"; // default: "green"
id?: string; // default: "env"
requiredEnvVars?: RequiredEnvVar[];
enableSharedModalDimensions?: boolean;
};
```
## Features
- **Automatic Discovery** - Collects runtime-visible `EXPO_PUBLIC_` variables
- **Required Variable Validation** - Define which vars must exist with expected values/types
- **Type Detection** - Auto-detects: string, number, boolean, array, object, url, json
- **Search & Filtering** - Real-time search + filters for "All", "Missing", "Issues"
- **Health Status** - Health percentage (0-100%) with HEALTHY/WARNING/ERROR/CRITICAL states
- **Statistics** - Total count, required count, missing count, wrong value/type counts
- **Copy to Clipboard** - Copy any value with one tap
## Variable Status Types
| Status | Description |
|--------|-------------|
| `required_present` | Required var is set and correct |
| `required_missing` | Required var is not set |
| `required_wrong_value` | Set but doesn't match expected value |
| `required_wrong_type` | Set but wrong type |
| `optional_present` | Optional var that is set |
## Validation Visual Indicators
- **Green** - Variable exists and matches expected value/type
- **Yellow** - Variable exists but value/type differs from expected
- **Red** - Required variable is missing
## Try It Out
Use the interactive builder below to create your environment validation config. Add variables, configure checks (type, value, description), and export the code directly.
---
## What It Can't Do
Discovery reads the runtime `process.env` object and filters for `EXPO_PUBLIC_` names. Declaring a required name adds a validation target; it does not inject a value into the runtime.
[Expo substitutes statically referenced environment values](https://docs.expo.dev/guides/environment-variables/) during bundling. Runtime enumeration cannot reliably recover those substitutions. Check a known value in your actual build before using this inspector as a completeness check.
The tool does not read your `.env` file or change your build configuration. Apply environment changes through your app's normal build or update process.
## FAQ
### How do I check which env vars my Expo app actually loaded?
Install `@buoy-gg/env` and open the Env tool — it shows runtime-visible values. Expo-inlined values may be absent; check the limitations above.
### Can it tell me why an env var is wrong, not just missing?
Yes — declare expected types and values, and failures show the current value, expected value, and context.
## Web support (unreleased)
Supply public runtime values explicitly with setRemoteEnv. The inspector cannot enumerate variables that a bundler replaced at build time. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Events Timeline
Source: https://buoy.gg/buoy/latest/docs/tools/events
Read supported events from installed and configured tools in one chronological timeline. Reproduce a problem, filter to the relevant sources, and open an event for its tool-specific details.
The demo shows a mock checkout session. Exports contain the filtered captured events, not a complete record of everything the app did.
## Installation
Events Timeline subscribes to supported event sources. Complete each source tool's setup first, including stores, providers, and account configuration.
---
## Event Sources
> **Auto-detection** — If you have the tool installed, its events automatically appear in the timeline.
---
## What You Can Do
---
## Search
Tap the magnifying glass in the header to filter the timeline as you type. Matches an event's title, its subtitle (status, duration, host, key), and the full URL of network events — so you can search a host, an action type, a storage key, or a query string value.
> Search stacks with the source badges — filter to Network, then search for the failing endpoint.
---
## LLM Export
Copy your event timeline in formats optimized for AI assistants. Reproduce a bug, export, and paste into Claude or ChatGPT with your question.
**Presets:**
- **LLM** — Markdown with smart formatting, strips noise
- **Bug Report** — Includes timestamps and error details
- **JSON** — Machine-readable for integrations
- **Errors** — Just the failures
- **Minimal** — Quick reference, one line per event
- **Diagram** — Mermaid sequence diagram of the flow
The export follows what's on screen: filter to a source or search the timeline first and you copy that slice, not the whole session.
> **Smart formatting** — Automatically parses nested JSON, shows only changed Redux state, and removes verbose fields like image URLs.
---
## Correlation
Related events are linked together. React Query fetch start → success events show as "1/2" badges so you can trace the full lifecycle.
---
## What's Next
- [Network Monitor](./network) — Inspect request details
- [Redux DevTools](./redux) — State inspection and time-travel
- [Storage Explorer](./storage) — Browse and edit persisted data
---
## FAQ
### How do I see what my React Native app did before a bug?
Open the Events timeline — the interleaved history of requests, state changes, storage writes, and navigation leading up to the bug is right there, exportable as structured text.
### What does "LLM-ready export" mean?
The timeline exports in a compact structured format designed to paste into an AI assistant — so the model sees exactly what the app did, in order, with timestamps.
## Web support (unreleased)
Register this package’s /web namespace in FloatingDevTools modules to use its shared panels and actions in a browser app. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Console
Source: https://buoy.gg/buoy/latest/docs/tools/console
Read captured `console.log`, `console.info`, `console.warn`, and `console.error` calls in your app. Filter by level or search the message, then expand structured arguments for context. Capture begins after the tool installs its hooks and account access permits it.
## Installation
That's it. The Console tool auto-appears in your `FloatingDevTools` menu, and capture starts automatically at app launch — logs emitted before you open the tool are retained:
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
export default function App() {
return (
<>
{/* your app */}
{/* Console tool auto-detected, capture auto-starts */}
>
);
}
```
It patches `console.*` directly, so logs from your own code and your dependencies are captured — no logger integration or Babel plugin required. Fatal/uncaught JS errors are captured too: the tool hooks React Native's global error handler (chaining any existing one), so a crash — even one during the app's very first render — lands in the log as a `[FATAL]`-tagged error entry instead of disappearing.
> **Upgrading?** Older versions required mounting `` manually and passing `consoleToolPreset` via `apps`. Both are automatic now; existing manual wiring is harmless and can be deleted.
---
## What You Can Do
- **See every log, live** — `log`, `info`, `warn`, and `error` stream in as they happen, color-coded by level.
- **Filter by level** — Focus on just errors and warnings when you're chasing a bug.
- **Search** — Filter messages by substring to find the exact log you care about.
- **Read production logs** — Because Buoy runs on-device in any build, you can read console output from a release build with no cable and no Metro connection.
- **Expand structured data** — Objects and arrays are formatted and expandable, just like the browser console.
---
## Read the console from your AI
With the [MCP server](../mcp), an AI agent can read the console tail directly with `get_console` — filtering by minimum level or message substring to pull just the errors it needs while debugging.
---
## What's Next
- [Events Timeline](./events) — Console logs alongside network, state, and route events
- [Network Monitor](./network) — Inspect the requests behind an error
- [AI / MCP Server](../mcp) — Let an agent read the console for you
---
## FAQ
### How do I see console logs in a React Native production build?
Install `@buoy-gg/console` — logs are captured in the app itself and viewable from the floating menu. Production use requires Pro and app-controlled access. Logs stripped from the bundle or emitted before capture starts are unavailable.
### Do I still need the Metro terminal or React Native DevTools?
For breakpoints, yes — keep React Native DevTools. For reading logs anywhere (including devices not attached to your machine), the in-app console replaces terminal-watching.
## Web support (unreleased)
Register this package’s /web namespace in FloatingDevTools modules to use its shared panels and actions in a browser app. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Sentry
Source: https://buoy.gg/buoy/latest/docs/tools/sentry
Inspect JavaScript Sentry envelopes and captured drop reasons before or as the SDK sends them. Use the tool to review payloads, resolved SDK configuration, and estimated quota usage.
## Why this exists
A missing event can be caused by client configuration or processing before ingestion. Buoy helps inspect that part of the path. Its cost view estimates usage from observed envelopes; your Sentry plan and billing records determine actual charges.
## Installation
The tool auto-appears in your `FloatingDevTools` menu and starts watching automatically:
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
export default function App() {
return (
<>
{/* your app */}
{/* Sentry tool auto-detected */}
>
);
}
```
`@sentry/react-native` is an optional peer — without it the tool simply reports "SDK not found". Capture installs when your bundle evaluates, not when you open the tool, so the traffic `Sentry.init` sends during startup is already there the first time you look.
In a monorepo where Metro may resolve the SDK from package source rather than your app's copy, attach explicitly:
```tsx
import * as Sentry from "@sentry/react-native";
import { SentryRoot } from "@buoy-gg/sentry";
```
---
## What You Can Do
- **Inspect captured envelopes** — item types (`event`, `transaction`, `log`, `session`, `client_report`, `attachment`, `replay_event`), byte sizes, span counts, and available post-processing payloads. Capture does not confirm transport success or server ingestion.
- **See what a session costs** — errors, spans, replays, log and attachment volume, projected against a plan quota. Spans are the surprise: Sentry bills each one individually, so a single transaction is many units.
- **Find out where your event went** — a `beforeSend` that returned `null`, a sample-rate roll, dedupe, React Native tracing discarding an empty transaction, or a native rate limit.
- **Grade the config you're actually running** — the options `Sentry.init` resolved, not the ones you typed, flagged for quota risk, privacy, and correctness.
- **Catch personal data before it ships** — payloads are scanned for emails, tokens, card numbers and sensitive fields, reported by JSON path with the value redacted.
---
## How it works
The tool subscribes to the Sentry client's public `beforeEnvelope` hook — the same tee point Sentry's own Spotlight integration uses. It only observes: the envelope is never modified and your transport is untouched. To attribute drops it also wraps your `beforeSend` callbacks, passing their result through unchanged; your callback keeps full authority over what is sent.
The inspector observes the SDK without changing its outgoing envelopes. Your Sentry SDK still sends its normal traffic. If you connect Desktop or MCP, captured data can also be read through that connection.
---
## Read it from your AI
With the [MCP server](../mcp), an agent can call `get_sentry_envelopes` to read the real exception, breadcrumbs and contexts **without waiting for ingestion; this does not prevent the SDK from sending the event**, `get_sentry_cost` for the billing picture, and `get_sentry_drops` when an event is missing.
Sentry's own MCP server reads issues that already reached sentry.io. This one reads the error on the device in front of you — along with the network request, storage, and state that caused it.
---
## What's Next
- [Network Monitor](./network) — the requests behind an error
- [Console](./console) — the logs around it
- [AI / MCP Server](../mcp) — let an agent read all three
---
## FAQ
### Does this send my data anywhere?
Your Sentry SDK sends its usual envelopes. Buoy displays its captured copy locally, and connected Desktop or MCP clients can read that copy. See [Telemetry](../telemetry) for separate Buoy account and telemetry traffic.
### Will it change what Sentry receives?
No. The envelope tee is read-only, and the `beforeSend` wrapper returns your callback's own answer untouched.
### Why does my transaction count as more than one unit?
A transaction can contain several spans. The Cost tab counts observed units to estimate usage; verify current billing rules and quotas against your Sentry plan.
### Can it see native crashes?
Not yet. Native crashes, release-health sessions and Session Replay uploads are sent by the native SDK and never pass through the JavaScript layer this tool observes.
## Web support (unreleased)
Pass the existing browser SDK’s getClient through the host’s sentryGetClient prop. Envelope capture and diagnostic panels use the shared implementation. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Network Monitor
Source: https://buoy.gg/buoy/latest/docs/tools/network
Inspect HTTP requests on your device: URLs, headers, request and response bodies, timing, and errors. Use development-only overrides to check how your app handles failures without changing the server.
## Supported Clients
Capture covers requests that pass through the instrumented global `fetch` or `XMLHttpRequest` APIs. Clients using those APIs, including Axios and HTTP-based GraphQL clients, can appear in the inspector. A native client or a separately imported fetch implementation may bypass these hooks.
## Installation
Set up the core menu and your account using [Quick Start](../quick-start), then install Network if you have not already:
```bash
npm install @buoy-gg/network
```
Restart the development server and app. Open the floating menu and select **Network**. Trigger a new request in your app, then select its row to inspect the response.
In development, interception starts during app initialization. Requests made before the hook is installed or before account access is ready may be missing. An empty list can also mean that your app returned cached data instead of making a new request.
## What You See
A captured request includes its URL, method, status, headers, timing, and available request and response data. Binary or streaming responses may not have a readable body. Inspect the row's details before assuming an empty body means the server returned nothing.
## Status Colors
Use the status code and error text alongside the row color to distinguish successful requests, failures, and requests still in progress.
## Override Responses
To check an error state in a development build:
1. Make a request and open its details.
2. Tap **Override** and choose **Server error 500**.
3. Trigger the same request again. Confirm that Network marks it as overridden and that your app displays the expected error state.
4. Disable the rule or the master override switch. Trigger the request again to confirm normal behavior.
The rule starts with the selected request's endpoint, method, status, and response body. If a rule already covers that request, the button opens it.
| Mode | What happens |
| --- | --- |
| Status preset or Custom | Returns the configured status and body without sending the matched request to the server. Presets include 200, 400, 401, 403, 404, 429, 500, and 503. |
| Offline | Simulates a connection failure without sending the matched request. |
| Timeout | Simulates a timeout failure without sending the matched request. |
| Real response | Delays the request, then sends it to the server and returns its real response. Server-side effects still happen. |
These behaviors apply when a supported request is intercepted and its rule matches. Buoy does not block traffic that bypasses its hooks. Use a test environment when exercising mutations.
### Editing the body
The captured **Response Body** is read-only. Change the response returned to your app in the override rule's **Response body** editor. You can edit values, add or delete fields, or use **Edit all** to paste a replacement body into an empty field.
A rule created from a request matches its endpoint, with the query string replaced by a wildcard. Review the pattern before enabling it: requests with different query parameters can match the same rule.
Patterns use `*` globs against the full URL. For example, `*/v1/users*` matches that path on any host. The first enabled rule matching the URL and method wins. Selecting `POST` matches POST requests; it does not change the request method or outgoing body.
Rules persist across app restarts. If they remain armed and untouched across three launches, Buoy pauses them and offers a control to resume them. The master switch disables overrides while retaining the rules.
Overridden requests appear in an `OVERRIDDEN` group and carry a flask icon. The toolbar shows an active-rule count.
Overrides run only in development, skip `OPTIONS` preflights, and exclude Buoy's own license requests. Custom response statuses must be between 200 and 599. A directly imported `expo/fetch` can bypass the global fetch hook. Binary response handling depends on the transport and response type; verify the result in your app before relying on an override for a download.
Free access allows one active override; adding another replaces it. Pro allows up to 50. You can also manage device rules from Desktop or through the `network_override` MCP tool, subject to the connection's account and plan requirements.
## Stepping Between Requests
Use **Previous / Next** in the detail view to move through the current list. Navigation follows the same search, filters, and pinned rows as that list. New requests update the sequence while you read. In the **Saved** view, navigation follows saved requests and their search results.
Lists are newest-first, so **Previous** moves toward newer requests. Body rendering waits briefly while you step through requests; the URL, status, timing, and headers update with each step.
## Pin & Save
The live list holds up to 500 requests. Clearing it or restarting the app removes live history. Use pins or saves to retain a request you want to revisit.
- **Pin** keeps a snapshot in the `PINNED` section above the live list. Pinned requests remain visible regardless of search and filters, and survive Clear and history eviction. Recovery after restart requires a successful storage write.
- **Save** keeps a snapshot in the separate **Saved** list, with its own search and export.
A request can be both pinned and saved. Use the pin or bookmark button in its detail header. You can also long-press a live row to pin it; long-pressing a row in Saved removes the save.
A request pinned while pending continues to update as its status and response arrive. There is a maximum of 25 pins. The Saved list allows 5 entries on Free and 50 on Pro.
Large bodies can be truncated in stored snapshots, and storage budgeting can remove body data. Check the snapshot before relying on it as a complete copy of the payload.
Desktop changes pins and saves on the connected device. Saving there uses the device's snapshot, with the same storage limits.
## What's Next
- [Storage Inspector](./storage): inspect AsyncStorage and registered MMKV instances
- [Environment Inspector](./env): inspect configured environment values
- [React Query](./react-query): inspect query cache and simulate query states
## FAQ
### How do I debug network requests in React Native without Flipper?
Install Buoy's core and Network packages, configure your account, and open Network in the floating menu. Make a request through a supported client and inspect its row. This setup does not require Flipper, a proxy, or a desktop app.
### Does it capture Axios and GraphQL requests?
It captures requests from Axios and HTTP-based GraphQL clients when they use the instrumented fetch or XHR APIs. Supported GraphQL payloads include operation names and variables. Other transports may bypass capture.
### Can I inspect network traffic in a production build?
Production access requires Pro and an app that deliberately exposes the tool to authorized users. Test capture in your target build. Response overrides remain development-only.
## Network conditions (development preview)
The React Native Network tool has four conditions: Normal, Offline (requests), Slow (+500 ms), and Very slow (+2000 ms). An admitted Free or Pro account can select them in a development build. Open **⋯ → Network throttling** to minimize the Network tool and show a compact floating controller. Tap the signal icon to cycle through No throttling, Slow, Very slow and Offline. Changes apply immediately; the icon and delay show the applied profile. The profile text is a label and can be dragged to move the strip. Close restores Normal and dismisses the strip. Drag the background, delay label or grip to move the strip. Buttons keep their tap actions. In React Native development builds, an open strip returns after reload at its saved position, including when hidden at the edge. Close keeps it closed across reloads. The condition resets to No throttling. Tap the grip to hide and restore. Hiding keeps conditions active.
These affect new HTTP(S) calls through global fetch and React Native XHR. Offline rejects them before dispatch; latency adds a wait before dispatch. Conditions do not disconnect Wi-Fi, change NetInfo, or throttle bandwidth. Imported native transports, images and WebSockets may bypass the hooks.
Pending calls retain their starting profile. Offline takes precedence over authored override rules; latency adds to their delay. During active simulation, a finite XHR timeout starts at `send()` and includes artificial waits. Normal keeps the platform's timeout semantics.
Closing the panel, pausing its list, clearing requests or filtering them keeps conditions active. Select No throttling to clear them. A full JS reload or loss of account access resets them automatically. Native simulator and device validation is still pending for this preview.
## Web support (unreleased)
Browser fetch and XHR use the shared capture, rules, conditions, and request panels. CORS still controls which response data the page can read. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
The capture control reads “Pause network capture” while capture is enabled and “Resume network capture” while it is paused. Existing requests remain visible while capture is paused.
# Storage Explorer
Source: https://buoy.gg/buoy/latest/docs/tools/storage
Inspect and edit AsyncStorage, registered MMKV instances, and registered SecureStore keys. Use the backend filter to choose the data you want to inspect.
## Supported Backends
> **Multi-instance MMKV support** — If you use multiple MMKV instances, register each instance explicitly. Switch between instances and see key counts per instance.
---
## Installation
After setting up [Buoy core](../quick-start), install this package and restart Metro. AsyncStorage can be discovered automatically; register MMKV instances and SecureStore keys below.
### SecureStore setup
Expo SecureStore (iOS Keychain / Android KeyStore) has no key-listing API, so register the keys you want visible — pass the module in, no extra dependency needed:
```typescript
import * as SecureStore from "expo-secure-store";
import { registerSecureStoreKeys } from "@buoy-gg/storage";
registerSecureStoreKeys(SecureStore, [
"auth.accessToken",
{ key: "session", keychainService: "com.myapp.auth" },
// Biometric-protected keys are listed but never auto-read (no surprise Face ID prompts)
{ key: "pin", requireAuthentication: true },
]);
```
### MMKV setup
Register the same instance your app reads and writes. For MMKV v4:
```typescript
import { createMMKV } from "react-native-mmkv";
import { registerMMKVInstance } from "@buoy-gg/storage";
export const storage = createMMKV({ id: "mmkv.default" });
registerMMKVInstance("mmkv.default", storage);
```
If your app already creates this instance, add the registration beside that code instead of creating another instance. Give each registered instance a distinct name.
To verify setup, write a disposable test key through your app, find it in Storage, edit it, and read it back through your app. Delete the test key when finished.
Registered values are re-read every few seconds while the browser is open, so a secure write shows up without reopening the tool. AsyncStorage and MMKV writes are picked up the moment they happen; the keychain gets a poll instead because it has no change notification of any kind.
---
## What You Can Do
---
## Smart Features
**Edit values in place** — Expand a key and hit *Edit value* to write a new one straight to the device. Types are preserved: an MMKV number stays a number, a boolean only accepts `true`/`false`, and a key holding JSON has to stay valid JSON — so you can't silently turn an object into a quoted string. Buffers, read-only MMKV instances, and biometric-protected SecureStore keys say why they can't be edited instead of offering a broken field.
**Edit arrays and objects without typing JSON** — Tap any node in the value tree and its actions appear alongside the key's other buttons: arrays get append, duplicate, reorder and remove; objects get add-key, duplicate and remove; scalars get a text field, with booleans as a two-way toggle. Use the raw text editor when you want to paste a complete value.
**Inline value previews** — Short values show right on the card (`number · 42`, `string · "en"`), and booleans get a color-coded true/false badge. No need to expand to see simple values.
**Pin to top** — Pin the keys you're watching so they stay at the top of the list. Pins persist across sessions.
**Hide from list** — One tap filters noisy keys out of the browser (from the expanded card or bulk selection). Non-destructive — unhide any time from the filters panel.
**JSON formatting** — Values that are valid JSON are automatically pretty-printed for readability.
**Live events** — Watch storage changes happen in real-time as your app reads and writes data.
**Bulk selection** — Select multiple keys to delete or export them all at once.
---
## What's Next
- [Network Monitor](./network) — Inspect supported HTTP requests
- [Environment Inspector](./env) — Validate env vars with type checking
- [React Query](./react-query) — Inspect query cache and simulate states
---
## FAQ
### How do I view AsyncStorage contents in Expo?
Install `@buoy-gg/storage` and open the Storage tool from the floating menu — all AsyncStorage keys are browsable and editable on the device, including in Expo Go.
### Does it support MMKV?
Yes. Register each MMKV instance, then select it with the backend filter. SecureStore requires explicit key registration too.
## Web support (unreleased)
The browser uses localStorage and sessionStorage with the shared editor, event history, undo, and snapshots. Native secure storage is unavailable on web. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Time Machine
Source: https://buoy.gg/buoy/latest/docs/tools/time-machine
Save a named snapshot of registered client-state sources and restore it when repeating a test. Sources can include storage, Redux, Zustand, Jotai, React Query, and the current Expo Router URL.
Check the Sources strip before capture. A snapshot covers the sources available to Buoy; it does not include backend state, component-local state, or in-flight requests.
## Installation
That's it — auto-discovery finds the installed package and the TIME MACHINE tool appears in your floating menu:
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
export default function App() {
return (
<>
>
);
}
```
Each state source is captured through the Buoy tool that already watches it — install the ones you use (`@buoy-gg/storage`, `@buoy-gg/redux`, `@buoy-gg/zustand`, `@buoy-gg/jotai`, `@buoy-gg/react-query`) and they register as snapshot sources automatically. The tool's Sources strip shows you exactly which sources can capture and restore, and why when one can't — including Route, which lights up on any Expo Router app and needs no extra package.
---
## What You Can Do
- **Capture a restore point** — name it, or don't: an unnamed capture is called after the screen you were on and the time (`/checkout · 14:32`), and you can rename it from the row later. Captured client state persists on-device and survives app restarts and JS reloads.
- **Switch a source off** — every tile in the Sources strip is a switch. Tap Zustand off and no capture or restore touches your Zustand stores, from the tool, the restore bar, Buoy Desktop or an agent alike, while storage, Redux, Jotai, React Query and the route keep working. Use it for a store the app cannot survive having replaced wholesale. The choice persists across reloads.
- **Keep the list yours** — swipe any restore point to duplicate or delete it, and a delete can be undone from the bar that replaces it. Renaming lives on the preview, next to what the point actually contains. The row tells you which point you last restored and when.
- **Restore in one tap** — two modes:
- **Live restore** swaps state in place: stores are replaced, the query cache is diffed query-by-query (mounted components keep their subscriptions), storage is written back. The app stays exactly where it is.
- **Restore + reload** restores persisted storage, then reloads the JS bundle — your in-memory stores rebuild themselves from the restored storage. Use this when your stores initialize from persisted storage.
- **Save a repeatable starting state** — configure the supported state sources, capture a snapshot and inspect its restore preview. Restore can remove supported keys absent from the snapshot. An empty baseline clears registered providers that support clearing and requests a reload; it does not reset backend state or reproduce a full reinstall.
- **Go back to the screen, not just the state** — every restore point also records the route it was captured on (`/checkout/payment`, query string and all), and "Return to this screen" sits with the other changes in the restore preview. It's on by default and it's a property of the restore point, so you answer once rather than on every restore. The app navigates as soon as the state lands — or right after the reload, when you restored in reload mode. Needs Expo Router; without it snapshots carry no route and the checkbox doesn't appear.
- **Preview before you restore** — tap any snapshot to see exactly what restoring it will change, item by item: what gets added, removed, overwritten — and what *can't* be applied, with the reason. Tap any item and its before/after diff expands inline underneath it — side by side, straight from the storage tool's diff UX, without losing your place in the list.
- **Untick what you don't want back — and it sticks** — every change in the preview has a checkbox. Uncheck the ones a restore should leave alone (that one auth token, a device id, a cache timestamp) and the restore point remembers it immediately, no save step: every later restore skips exactly those items, including the one-tap RESTORE on the list and restores driven from MCP. Everything else still comes back, including keys that only start differing later. Ticking a source's own checkbox toggles everything under it at once.
- **Loop from a bar over the app** — when a flow needs the same restore ten times in a row, tap **Action bar** in the tool's header (or swipe a restore point and choose **Bar**). The tool minimizes and a small strip stays over your app with one restore point armed: one tap restores it, a long press restores the state without changing screens, and the status line says where a tap will take you. Every restore from the bar first saves a "Before last restore" copy, so an **Undo** link sits in the status line for ten seconds after each one. The picker (chevron) has a **Reload the app after restoring** switch: turn it on when a screen keeps its state in a React context or `useState` that reads storage only when it mounts, such as a cart provider hydrated from AsyncStorage. A live restore puts the storage key back but that screen never re-reads it; a restore that reloads makes it rebuild from the restored storage. `+` asks for a name (leave it empty for the screen and time), captures a new point right there and arms it; the chevron beside the name switches to another point. The strip comes back after a reload, drags anywhere, and hides at the screen edge from its grip. Close it with the × when the loop is done; nothing else changes.
- **See exactly what happened** — every restore reports per-source results: applied counts, skipped items with reasons, and warnings. A partial restore is reported, never hidden.
- **Drive it remotely** — the same snapshots work from Buoy Desktop and from AI agents via MCP (`time_machine_action`): *capture → drive the flow → restore → repeat* is one tool call per step.
---
## How Restore Works (per source)
| Source | Restore mechanism |
|---|---|
| **AsyncStorage** | Diff: keys missing from the snapshot are removed, snapshot entries bulk-written. Buoy's own keys are never touched. |
| **MMKV** | Per registered instance, per-key diff. Read-only instances and `ArrayBuffer` values are skipped (and reported). |
| **SecureStore** | Registered keys written back with their original options. Biometric-protected keys are never read or written. |
| **Redux** | A single full-state jump through Buoy's reducer wrapper, verified after dispatch. Works with the zero-config enhancer, or wrap your root reducer in `withBuoyDevTools()` when using the middleware. |
| **Zustand** | Full `setState(state, true)` replace — with your store's action functions re-grafted from the live store first, so `useStore(s => s.increment)` keeps working. |
| **Jotai** | Every watched, writable atom is set individually. Read-only atoms are captured for inspection and left to recompute; writable derived atoms may be restored through their write function. |
| **React Query** | Per-query diff against the live cache: existing queries get their state set (observers stay attached, with their real `queryFn`s), missing ones are rebuilt, extras removed. Query data is captured in full — snapshots live on-device, so nothing is trimmed for size — while error objects keep only their name and message. Mutations aren't replayable, so the mutation cache is cleared. |
| **Route** | Opt-in per restore: `router.navigate()` to the captured URL — after the state is applied, or on the next boot when the restore reloads. |
**Limits:** this is *client* state — your backend doesn't time-travel, so a restored cart is only as valid as the server allows. Component-local `useState` and in-flight requests aren't captured. The route restores as a URL (pathname + search params) — the back stack it sat on, and params you passed imperatively as objects rather than in the URL, do not. Sources that can't fully restore say so up front, in the tool.
---
## FAQ
### How do I save and restore app state in React Native while testing?
Install `@buoy-gg/time-machine` and capture a restore point — it snapshots device storage (AsyncStorage, MMKV, SecureStore), Redux, Zustand, Jotai, and the React Query cache together, and restores the whole set in one tap. Set your test state up once and jump back to it every iteration.
### Does restoring reload the app?
Only if you want it to. Live restore swaps state in place — stores are replaced, the query cache is diffed query-by-query so mounted components keep their subscriptions, and the app stays exactly where it is. Restore + reload writes storage back and reloads the JS bundle so in-memory stores rebuild themselves.
### How do I restore the same state over and over without opening the tool?
Tap **Action bar** in the Time Machine header, or swipe a restore point and choose **Bar**. The tool minimizes and a strip stays over the app with that point armed. Each tap on its restore button puts that state back; a long press puts the state back without leaving the screen you are on, and the bar keeps a "Before last restore" copy so you can undo a mis-tap within ten seconds. The strip's `+` asks for a name, captures a new point from wherever you are and arms it, so moving the checkpoint forward takes a name and a tap.
### Do restore points survive an app restart?
Yes — they persist on-device through restarts and JS reloads, and each one also records the route it was captured on so you can return to the screen as well as the state.
## Web support (unreleased)
Register this package’s /web namespace in FloatingDevTools modules to use its shared panels and actions in a browser app. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Route Inspector
Source: https://buoy.gg/buoy/latest/docs/tools/routes
Inspect captured navigation events, route parameters, and the stack available from your navigator. Use the timeline to compare where navigation started with the screen that became active.
A route duration is time since the previous navigation event, not a measurement of screen render time. The demo uses a mock session.
## Supported Libraries
> **Auto-detection** — The Route Inspector automatically detects which navigation library you're using and adapts accordingly.
---
## Installation
That's it. The Route Inspector auto-detects your navigation setup and appears in your FloatingDevTools menu.
---
## What You Can Do
---
## Route Types
---
## Event Timeline
Every navigation is tracked with:
- **Path** — Where you navigated to
- **Params** — Route parameters passed
- **Timestamp** — When it happened
- **Duration** — Time since previous navigation
Tap any event to open its **detail page** — the full route template, from/to paths, timing, segments, and params, all copyable, plus a **Go to route** action to jump straight there. This matches how events open in the Events tool.
---
## What It Can't Do
**It knows the routes your navigator declares.** The sitemap is built from your Expo Router file tree or your React Navigation config, so a screen reached only by an imperative push with an object payload appears in the event stream, but its params are not something the sitemap can predict ahead of time.
**Jumping to a route is navigation, not authorisation.** *Go to route* performs the same navigation your code would. It does not bypass a guard — which is exactly what makes it useful for testing that the guard works.
## What's Next
- [Network Monitor](./network) — Inspect supported HTTP requests
- [Storage Explorer](./storage) — Browse and edit AsyncStorage & MMKV
- [React Query DevTools](./react-query) — Inspect query cache and simulate states
---
## FAQ
### How do I inspect the navigation stack in React Native?
Install `@buoy-gg/route-events` and open Routes — the Stack tab shows the live stack with route names and params, updating as you navigate.
### Does it work with Expo Router?
Yes — it's built for Expo Router: the sitemap, stack, and event stream all reflect your file-based routes.
## Web support (unreleased)
Browser History API and hash changes feed the shared route history. Register known paths and the framework router adapter for route selection and navigation. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Highlight Updates
Source: https://buoy.gg/buoy/latest/docs/tools/highlight-updates
See which components render as you interact with a development build. The overlay shows render counts; the detail view shows available causes and hook value changes.
Use the demo to inspect a list update, then compare it with an interaction in your app.
## Render Causes
Inspect the recorded cause alongside the component and interaction before deciding whether a render is unnecessary.
---
## Installation
That's it. Highlight Updates appears in your FloatingDevTools menu.
---
## What You Can Do
---
## Two Modes
Toggle between modes directly from the FloatingDevTools menu:
**Overlay Mode** — Quick visual overlay that shows renders as they happen. Perfect for spotting unnecessary re-renders while you interact with your app.
**Modal Mode** — Full inspector with render history, filtering, and detailed cause breakdowns. Great for deep debugging sessions.
---
## Hook Value Tracking
When a state change causes a render, Highlight Updates shows you the **before and after values** of your hooks. See exactly which `useState` or `useReducer` value changed.
---
## What It Can't Do
**It needs a development build.** Render data comes from React's own DevTools hook (`__REACT_DEVTOOLS_GLOBAL_HOOK__`), which release builds do not install. This tool requires the development hook.
**The overlay costs frame time.** Drawing a box and a counter over every committed component is real work on the UI thread, so the numbers tell you *which* component re-renders and *why*, not what your frame budget looks like with the overlay off. For that measurement use [Bench](./perf-monitor).
## What's Next
- [Image Overlay](./image-overlay) — Overlay design mockups on your running app
- [Environment Inspector](./env) — View and search environment variables
- [Network Monitor](./network) — Inspect supported HTTP requests
---
## FAQ
### How do I find unnecessary re-renders in React Native?
Install `@buoy-gg/highlight-updates` and turn on highlighting — components flash as they render with counts and causes, so over-rendering components and the reason (props, state, parent) are visible immediately.
### How is this different from the React DevTools profiler?
Buoy shows overlays and render details inside the running development build. Use React's profiler for profiling sessions and Buoy for inspecting renders during an interaction. Highlight Updates does not work in production builds.
## Web support (unreleased)
Import @buoy-gg/core/web/register before React DOM to capture roots and renders. The shared inspector measures DOM elements. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Bench
Source: https://buoy.gg/buoy/latest/docs/tools/perf-monitor
Record FPS, CPU, memory, and jank while repeating a test on a device. Compare saved runs or benchmark variants under the same conditions. Available metrics depend on your native dependencies and build mode.
Start with one repeatable interaction. Record it before and after a change, then check the UI as well as the metrics.
## Installation
Bench relies on native peers. Install the package with **Reanimated** and **Worklets** (required), plus the **performance toolkit** and **Nitro modules** that power true native UI-thread metrics:
These ship native code, so rebuild the app with a custom **dev build** (`expo prebuild` then `npx expo run:ios` / `run:android`) — **not Expo Go** — and run `pod install` on iOS. See [Reanimated's setup](https://docs.swmansion.com/react-native-reanimated/) for its Babel plugin. Without `react-native-performance-toolkit`, Bench still runs in JS-only mode; adding it unlocks native UI FPS and CPU.
Once installed, Bench appears in the floating menu. On [Buoy Desktop](../desktop) it also renders as a live HUD you can watch while you use the app.
### Web
Bench also runs in the browser — Expo web, Electron, or any React DOM app — with **no native modules and no dev build**. On web the HUD samples browser APIs instead:
- **FPS** — main-thread frame rate (`requestAnimationFrame`). On web there's a single thread, so JS FPS and UI FPS read the same value.
- **BUSY** — replaces the CPU row: the % of recent time the main thread was blocked by [long tasks](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongTaskTiming) (>50ms). This is the "why does it feel janky" number.
- **JS heap** — `performance.memory` (Chromium-based browsers; the row hides elsewhere).
- **PAGES** — the full HUD ranks your slowest routes by average FPS while each was active (browser-history route tracking — works with React Router, Next, hash routers, or plain `pushState`). Watch it while you click around to see exactly which pages are slow.
React Native Web apps get this automatically. Plain React apps need the standard one-line bundler alias (`react-native` → `react-native-web`) and can render the HUD directly:
```tsx
import { PerfMonitorOverlay, PerfMonitorController } from "@buoy-gg/perf-monitor";
// anywhere in your tree
// toggle it
PerfMonitorController.toggle();
```
---
## What You Can Do
- **Live HUD** — Watch UI FPS, JS FPS, CPU, and memory update in real time as you navigate.
- **Record runs** — Capture a session, save it, and keep a library of recordings.
- **Compare** — Long-press to select two or more saved runs and see them side-by-side in the same bar-chart report batches get (duration, memory, JS FPS, CPU) to prove a change made things faster.
- **Batch benchmarks** — Run the same flow across several variants and get a ranked report with per-metric leaders and at-risk flags.
- **Render capture** — See *why* a run was slow: every recording also captures per-component render counts and durations (React-profiler-style), so reports show the top re-rendering components next to the FPS numbers.
### The warmup case
A batch runs one throwaway case before the first real one, and drops it from the results.
The first run can include sheet dismissal, navigation, and initial mounting. The warmup absorbs that setup work so the recorded cases begin under more comparable conditions.
So a batch of four cases records five runs and takes about a fifth longer than the arithmetic suggests. The report shows four.
### Stall reconstruction
The sampler runs on the JS thread, so it physically can't take samples *while* the JS thread is blocked. Instead of silently skipping those moments (which would make stalls invisible in recordings and on the desktop dashboard's live HUD), the monitor detects the gap when sampling resumes and backfills it with reconstructed `0 JS FPS` samples — so history sparklines, live sync, and saved reports all show the stall. Reconstructed samples are marked `synthetic` in report JSON, counted in the report's stats, and never fabricated across app backgrounding. One consequence worth knowing: recordings made before this behavior existed under-report JS stalls, so a stall-heavy run recorded today will (honestly) score worse than the same behavior recorded on an older version.
---
## Render Capture
With [`@buoy-gg/highlight-updates`](./highlight-updates) installed, every recording — manual or batch — also captures **which components rendered, how many times, and how long they took** (self time, React DevTools profiler convention). Batch reports gain a *Top re-renderers* section per case, single-run reports get a *Render commits* block, and the MCP `run_benchmark_batch` ranking includes the heaviest components per case — so instead of "case B dropped to 41 JS FPS" you get "case B dropped to 41 JS FPS **because `ProductList` rendered 47× costing 312ms**".
A few things to know:
- Requires a **dev build** (the React DevTools hook and profiling timers aren't present in release builds). Without them the run simply has no render data — nothing breaks.
- Capture walks committed fibers on the JS thread, adding a small overhead per commit. It's applied **uniformly to every case** in a batch, so relative comparisons stay fair — but for absolute FPS measurements you can turn it off: the **Capture render commits** toggle in the tool's Settings covers manual recordings, the **Capture renders** toggle in Automate settings covers batches, and `captureRenders: false` works on `run_benchmark_batch`.
- Buoy's own devtools UI (the HUD, floating menu, etc.) is excluded from results automatically, and React Native framework wrappers (`View`, `Text`, `Animated(View)`, Touchable internals, VirtualizedList cells, …) are folded into the run totals instead of cluttering the component list — the rows you see are your components.
- Want to *watch* the renders as they happen? Turn on **Show render highlights while recording** in the tool's Settings (off by default). While a recording is running, the [Render Highlighter](./highlight-updates)'s bounding boxes flash in real time, then the overlay returns to whatever state it was in. Outside a recording this setting draws nothing; use the Highlight Updates tool in the menu for that. The boxes themselves cost real frame time, so leave this off when you care about the numbers.
- Both render settings need `@buoy-gg/highlight-updates` installed in the app. Without it the switches are greyed out and say so.
---
## Optimize with your AI
[Buoy Optimize](../optimize) is a skill that lets your coding agent drive Bench. Tell the agent which screen is slow and it builds each idea for a fix as its own variant, benchmarks every variant on the device with `run_benchmark_batch`, and keeps what's faster. You check that each variant still looks right, which matters most on Skia and other GPU-drawn UI. Say "buoy optimize" in your editor to start.
See [Buoy Optimize](../optimize) for how a round works, what you need and a worked example.
---
## What's Next
- [Buoy Optimize](../optimize) — Let your AI agent benchmark and pick fixes
- [AI / MCP Server](../mcp) — Automate benchmarks with an AI agent
- [Render Highlighter](./highlight-updates) — Find the re-renders hurting performance
- [Events Timeline](./events) — See performance in context with everything else
---
## FAQ
### How do I measure FPS in a React Native app?
Install `@buoy-gg/perf-monitor` and record a run — Bench samples both UI-thread and JS-thread FPS on the device, along with CPU and memory, and saves the run for comparison.
### Can I compare performance before and after a change?
Yes — runs are saved and comparable, and via the Buoy MCP server an AI agent can run benchmark batches of both variants and report which is faster.
## Web support (unreleased)
Browser measurements use frame timing, available JS heap data, and long tasks. Native CPU, RSS, and thermal measurements remain device-specific. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# JS Top
Source: https://buoy.gg/buoy/latest/docs/tools/js-top
Rank the callback origins JS Top can observe by execution time and call count. Compare timers, animation callbacks, and Promise reactions while reproducing a slow interaction. Work outside the wrapped paths appears as unattributed.
The tool uses JavaScript instrumentation and can run in Expo Go. Production access requires Pro.
## Installation
With auto-discovery, installing the package is all you need — the JS TOP tool and its toggleable busy-pill HUD appear in your floating menu automatically. Or register it explicitly:
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
import { jsTopPreset, jsTopModalPreset } from "@buoy-gg/js-top";
```
---
## How it works
You can't sample a blocked JS thread from JavaScript — so JS Top doesn't sample. Instead it wraps every entry point work can take onto the thread (`setTimeout`, `setInterval`, `setImmediate`, `requestAnimationFrame`, `queueMicrotask`, Promise reactions, and legacy-bridge call-ins) and measures each callback precisely, attributing the time to *where the callback was scheduled from*.
Alongside that, a calibrated high-frequency probe measures **estimated thread occupancy** from timer-gap inflation, and React Native's built-in `longtask` observer flags every 50ms+ block. Anything the wrappers can't see shows up honestly as an **unattributed** row — the tool never pretends to a coverage it doesn't have.
- **Inactive when closed.** The engine only runs while the tool is open (or a desktop dashboard is watching).
- **Exclusive-time accounting.** Nested callbacks never double-count; totals always add up.
- **Blocking-task attribution.** Each 50ms+ stall is matched to the callback that overlapped it.
## What You Can Do
- **Rank live JS-thread cost** — Sort by recent ms, calls, average, max, or total.
- **Catch the runaway interval** — A polling loop someone forgot shows up in seconds, named after the function that scheduled it.
- **See thread busy%** — A live meter plus 30s history sparkline, honest even through multi-second stalls.
- **Read blocking tasks** — Every 50ms+ freeze, with the worst-offending origin named.
- **Watch from the desktop** — The Buoy Desktop panel mirrors the device table live, and adds a dev-only per-function Hermes sampler for function-level flame data.
---
## Ask your AI what's eating the thread
With the [MCP server](../mcp), an agent can call `get_js_thread_top` — the device samples for a few seconds and returns the ranked table, so "why is JS FPS low?" becomes a one-tool-call answer.
---
## Coverage notes
- On the New Architecture (bridgeless), touch handlers and React commit work enter the thread through paths pure JS can't wrap — that time appears as **unattributed** (the banner in the tool explains this). Timers, rAF, microtasks, and Promise chains are attributed when they use the wrapped paths.
- Hermes runs `async/await` continuations through an internal path that bypasses `.then` — async function bodies also land in unattributed.
## What's Next
- [Performance Monitor](./perf-monitor) — Benchmarks, FPS/CPU/memory recording, automation
- [Highlight Updates](./highlight-updates) — See which components re-render
- [AI / MCP Server](../mcp) — Let an agent profile the thread for you
---
## FAQ
### How do I find what's blocking the JS thread in React Native?
Install `@buoy-gg/js-top` and open JS TOP — it ranks every task origin (`setInterval ← startPolling`, `Promise.then ← api.ts`, `requestAnimationFrame ← rafSpinLoop`) by the time it consumed, so the runaway timer or callback appears in the ranking when captured.
### Does it need a native module, dev client, or attached debugger?
No. JS Top is pure JavaScript — it wraps the entry points work takes onto the thread instead of sampling, so it runs in Expo Go, dev builds, and release builds with no debugger attached.
### Why is some time reported as "unattributed"?
On the New Architecture, touch handlers and React commit work enter the JS thread through paths pure JavaScript can't wrap. That time is reported honestly as unattributed rather than being blamed on the wrong origin. Timers, rAF, microtasks, and Promise chains are attributed when they use the wrapped paths.
## Web support (unreleased)
Register this package’s /web namespace in FloatingDevTools modules to use its shared panels and actions in a browser app. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# React Query DevTools
Source: https://buoy.gg/buoy/latest/docs/tools/react-query
Inspect the queries in your app's TanStack Query client, including keys, status, observers, and cached data. Edit cache values or simulate loading and errors to test the screen that consumes them.
The demo uses a mock QueryClient. In your app, the tool reads the client supplied by its surrounding provider.
## Installation
Mount `FloatingDevTools` inside the same `QueryClientProvider` as your screens. Installing the package does not make a client outside that context available.
```tsx
```
This placement example uses your existing `queryClient` and app component; import `QueryClientProvider` from `@tanstack/react-query` and `FloatingDevTools` from `@buoy-gg/core`. Keep your existing account configuration.
Open a screen that runs a query, find its key in the tool, and inspect the cached data. Try a simulated loading state, then restore it and confirm that the screen resumes.
---
## Query States
---
## What You Can Do
> **Simulate loading & error states** — Test how your UI handles loading spinners and error boundaries without waiting for real network conditions.
---
## Mutations
Track all your mutations in real-time:
- **Status** — idle, pending, success, or error
- **Variables** — data passed to the mutation
- **Response** — returned data or error message
- **Timing** — when the mutation was submitted
---
## WiFi Toggle
Simulate offline mode with one tap. The WiFi toggle controls React Query's `onlineManager` to test offline behavior for queries that honor that manager. It does not disable the device network or override a query's network mode.
---
## What It Can't Do
**The simulated states are cache-level, not network-level.** Triggering a loading state replaces the query function with one that never resolves; it does not slow or block a real request. That is what makes it instant and repeatable — but if you want to see the actual request fail, use a [Network override](./network) instead.
**Mutations are observed, not replayed.** You can read a mutation's variables, status and response, but there is no re-fire button — replaying a mutation would repeat its side effects on your real backend.
## What's Next
- [Network Monitor](./network) — Inspect supported HTTP requests
- [Storage Explorer](./storage) — Browse and edit AsyncStorage & MMKV
- [Environment Inspector](./env) — Validate env vars with type checking
---
## FAQ
### How do I use React Query devtools in React Native?
Install `@buoy-gg/react-query` and place the menu inside your app's query provider. Open the tool to inspect queries and simulate cache states.
### Does it work with Expo Go?
Yes. It's pure JavaScript — no native modules — so the tool can run in Expo Go. Production access requires Pro and deliberate app authorization.
## Web support (unreleased)
Use the app’s existing QueryClientProvider. The browser host mounts the shared tracker and cache adapter. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Redux DevTools
Source: https://buoy.gg/buoy/latest/docs/tools/redux
Inspect captured Redux actions, their payloads, state, and diffs. Import Buoy before store creation for enhancer-based capture, or use the explicit middleware setup below. State jumps need the enhancer or reducer wrapper.
## Installation
Complete core setup and ensure the package loads before your store. Dispatch a test action, verify its payload and state diff, then check whether Jump is enabled.
> Capture through the Redux DevTools integration point requires that integration to be enabled and Buoy to load before store creation. If either condition is missing, use the explicit middleware path below.
> **Guarantee full capture** — the store-creation hook needs `@buoy-gg/redux` to load before your store module. That's usually automatic; to make it a guarantee, put `import '@buoy-gg/redux';` as the **first import of your app entry**. If Buoy loads too late, it still binds your store automatically at app mount (top-level dispatches only — the tool tells you when it's in that mode).
---
## BUOY vs Chrome Redux DevTools
Buoy provides an in-app action list, state inspection, diffs, and supported state jumps. A browser extension is a separate debugging surface; check its documentation for its current capabilities.
Production use requires Pro and deliberate app access controls. Available capture and jump behavior depends on your store integration, as described above.
## Action List
Every dispatched action is captured with rich metadata:
- **Action Type** — Full action name with automatic slice detection
- **Category Badges** — Instant recognition of pending, fulfilled, rejected states
- **Duration** — How long the action took (with slow action warnings >16ms)
- **Diff Summary** — Quick overview of state changes (+added -removed ~modified)
- **Timestamp** — When the action was dispatched with relative time
---
## Detail View
Tap any action to see three detailed tabs:
### Action Tab
View the complete action payload, meta information, and error details for failed actions. Interactive JSON tree for exploring nested data.
### State Tab
Explore the full state tree after this action with a collapsible data viewer. Navigate deeply nested state with ease.
### Diff Tab
Side-by-side comparison showing exactly what changed — additions (green), removals (red), and modifications (yellow) clearly highlighted. Choose between tree view or split view.
---
## Time-Travel Debugging
Jump to any point in your app's history:
- **Jump to State** — Instantly restore your app to the state after any action
- **Replay Action** — Re-dispatch any action to test how your reducers respond
- **Async Timeline** — Visual timeline showing the full lifecycle of async operations
> **Note:** Jumping to a past state needs a reducer that can serve it, which is a separate piece of wiring from action capture — middleware sits above your reducer and cannot replace what it returns. You get it automatically when `@buoy-gg/redux` is imported before your store module (Buoy becomes the store enhancer); otherwise add the reducer wrapper from [Advanced Configuration](#advanced-configuration). **The JUMP button tells you which you have**: it is disabled and labelled "Time travel not wired" when the store cannot serve a jump, rather than doing nothing when pressed.
---
## RTK Async Thunks
Full support for Redux Toolkit async thunks with intelligent linking:
- **Request ID Tracking** — Automatically links pending → fulfilled/rejected actions
- **Visual Timeline** — See the full async flow in a connected timeline
- **Concurrent Request Handling** — Color-coded badges distinguish parallel requests (#1, #2, etc.)
- **Duration Calculation** — Total time from pending to completion
- **Original Arguments** — See exactly what was passed to the thunk
---
## Performance Monitoring
Catch performance issues before they impact users:
- **Action Timing** — Millisecond-precision duration for every action
- **Slow Action Detection** — Actions taking >16ms (frame budget) are flagged with warnings
- **Average Duration** — Track performance trends across your session
- **State Change Indicators** — Quickly identify actions that actually modified state
---
## Features
### Search & Filter
Find actions instantly by type, or filter to show only actions that changed state.
### Copy to Clipboard
Export action data or payloads for debugging, bug reports, or test fixtures.
### Recording Toggle
Pause action capture when you need to focus, resume when ready.
### Export History
Download your complete action history as JSON for sharing with teammates or creating test data.
---
## Advanced Configuration
Use explicit configuration when you need middleware options or your store was created before Buoy loaded.
### Enable Full Time-Travel
To enable jumping to past states (not just viewing them), wrap your reducer:
```tsx
import { configureStore } from '@reduxjs/toolkit';
import { withBuoyDevTools } from '@buoy-gg/redux';
const store = configureStore({
reducer: withBuoyDevTools(rootReducer),
});
```
### Custom Middleware Options
For fine-grained control over what gets captured:
```tsx
import { createBuoyReduxMiddleware, withBuoyDevTools } from '@buoy-gg/redux';
const customMiddleware = createBuoyReduxMiddleware({
maxActions: 500, // History size (default: 200)
ignoreActions: [ // Actions to skip
'persist/PERSIST',
'persist/REHYDRATE',
],
});
const store = configureStore({
reducer: withBuoyDevTools(rootReducer),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(customMiddleware),
});
```
> **When to use manual middleware:** If you need to ignore specific actions, increase history size, or you simply prefer explicit wiring — the middleware path is also a guaranteed-full-capture alternative to the import-order note above. Everything (action log, desktop sync, MCP `get_redux_state`/`redux_dispatch`) works the same on either path.
> **No conflicts:** If you configure middleware manually, the auto-instrumentation automatically detects this and defers to your configuration. You'll never get duplicate action entries.
---
## API Reference
### Exports
```tsx
import {
// Auto-instrumentation (used internally, rarely needed)
instrumentStore,
isStoreInstrumented,
// Manual middleware (optional, for advanced config)
buoyReduxMiddleware,
createBuoyReduxMiddleware,
// Time-travel (optional)
withBuoyDevTools,
jumpToState,
replayAction,
// Hooks
useReduxActions,
useAutoInstrumentRedux,
// History adapter (for custom integrations)
reduxHistoryAdapter,
createReduxHistoryAdapter,
} from '@buoy-gg/redux';
```
---
## What It Can't Do
**JUMP only reaches the 25 most recent actions.** Every retained action pins its own copy of the state tree, and on an app that replaces large slices wholesale — a store switch, a rehydration — a few dozen of those are enough to exhaust memory. Older actions keep their row, their diff summary and their payload; they just no longer have a tree to restore, so JUMP is disabled on them.
**It reads the store, it doesn't replay it.** Jumping sets state directly. It does not re-run your reducers, re-fire thunks, or reissue the network calls an action originally triggered — so a jump puts the *data* back, not the side effects.
## What's Next
- [React Query DevTools](./react-query) — TanStack Query inspection
- [Network Monitor](./network) — Inspect supported HTTP requests
- [Storage Explorer](./storage) — Browse and edit AsyncStorage & MMKV
---
## FAQ
### How do I use Redux DevTools in React Native without Flipper?
Install `@buoy-gg/redux` — the action stream, state diffs, and time-travel controls run inside the app on the device. Flipper is not required.
### Does time travel work on the device?
Yes — JUMP restores the store to the state after any recorded action, and REPLAY re-dispatches an action, directly from the in-app panel.
REPLAY works on every setup. JUMP needs a reducer that handles the jump, which you get either by importing `@buoy-gg/redux` before your store module (Buoy becomes the store enhancer) or by wrapping your root reducer with `withBuoyDevTools`. If neither applies, the JUMP button is disabled and says so — it never silently does nothing.
JUMP is also disabled on older actions whose raw state has been released. Buoy keeps the before/after state trees of the 25 most recent actions only: every retained action pins its own copy of the tree, and on an app that replaces large slices wholesale (a store switch, a rehydration) a few dozen of those are enough to exhaust memory. Older actions keep their row, their diff summary and their payload — just not a tree to restore.
## Web support (unreleased)
Use the app’s existing Redux provider. The browser host mounts capture and exposes the shared state and action panels. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Zustand DevTools
Source: https://buoy.gg/buoy/latest/docs/tools/zustand
Inspect registered Zustand stores, current values, and recorded changes. Use the diff to find which keys changed, then test a retained state with Jump or restore the initial state with Reset.
## Installation
---
## Setup
Pass your stores directly to `FloatingDevTools` via the `zustandStores` prop — no separate setup call needed.
```tsx
import { FloatingDevTools } from '@buoy-gg/core';
import { useCounterStore } from './stores/counter';
import { useAuthStore } from './stores/auth';
import { useCartStore } from './stores/cart';
const stores = {
counterStore: useCounterStore,
authStore: useAuthStore,
cartStore: useCartStore,
};
export function DevTools() {
return ;
}
```
---
## State Change List
Every state update is captured with rich metadata:
- **Store Name** — Which store changed, color-coded for quick identification
- **Category Badge** — `setState`, `replace`, `persist`, or `initial` — instant recognition of update type
- **Changed Keys** — Top-level keys that changed in this update
- **Diff Summary** — Quick overview of additions, removals, and modifications
- **Timestamp** — When the change occurred with relative time
- **Duration** — How long the update took (middleware mode only, flags >16ms)
---
## Detail View
Tap any state change to see three detailed tabs:
### State Tab
Explore the full state tree after this change with a collapsible JSON viewer. Navigate deeply nested state with ease.
### Diff Tab
Side-by-side comparison showing exactly what changed — additions (green), removals (red), and modifications (yellow) clearly highlighted. Choose between tree view or split view.
### Store Tab
Browse the complete current state of the store, including all keys and their current values.
---
## Advanced: `buoyDevTools()` middleware
For precise partial state capture and timing data, wrap individual stores:
```tsx
import { create } from 'zustand';
import { buoyDevTools } from '@buoy-gg/zustand';
const useCounterStore = create(
buoyDevTools(
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}),
{ name: 'counterStore' }
)
);
```
Works with middleware chaining (persist, immer, etc.):
```tsx
import { persist } from 'zustand/middleware';
const useAuthStore = create(
buoyDevTools(
persist(
(set) => ({ user: null, login: (u) => set({ user: u }) }),
{ name: 'auth-storage' }
),
{ name: 'authStore' }
)
);
```
---
## Performance Monitoring
Catch performance issues before they impact users (middleware mode):
- **Update Timing** — Millisecond-precision duration for every state update
- **Slow Update Detection** — Updates taking >16ms (frame budget) are flagged with warnings
- **State Change Indicators** — Quickly identify updates that actually modified state
---
## Editing a Store
Expand a store's card on the Stores tab and tap **Edit state** — the same full-screen tree editor the Storage tool uses: tap a row to select it, double-tap to type a new value, restructure with the docked actions, then write everything in one save. Works for in-memory and persisted stores alike (a persisted store's `persist` middleware writes the disk copy itself on save — no storage detour).
Saves are **merge-only**. However deep the edit, it is written as `setState({ changedTopLevelKeys }, false)`, which by construction cannot delete a top-level key and cannot touch the action functions your store keeps in state. Two things follow from that:
- **A top-level key can be changed but not removed or renamed.** A zustand merge can't delete, so Remove is disabled on top-level rows, and a raw-JSON edit that drops a key is refused at save with the key named. (For a persisted store, delete the key from its saved copy in the Storage tool and tap "re-read saved value" instead.)
- **Your actions survive, and untouched keys keep the app's value.** The save carries only what you changed — a key the app wrote while you were editing, and you didn't touch, stays the app's.
A store whose state isn't a plain object, or that keeps a function nested inside a data key, says so on the card instead of offering the button. Each save lands in the change log as one ordinary recorded change.
## Features
### Search & Filter
Find state changes by store name or changed keys, or filter to show only updates that modified state.
### Jump to State
Restore any store to a previously captured state — instantly see how your app looked at any point in history.
### Reset Store
Reset any store back to its initial state in one tap — no need to restart the app.
### Rehydration, Told Apart
A store behind zustand's `persist` middleware changes on its own at startup,
when its value comes back off disk. Buoy labels that update **HYDRATE** rather
than drawing it as an ordinary `setState` — so the one change none of your code
made is the one you can pick out of the log, and every later write to the same
store is still marked **PERSISTED**.
### Store Color Coding
Each store gets a consistent color across the UI for easy visual tracking when monitoring multiple stores.
### Persist Awareness
Auto-detects stores using Zustand's `persist` middleware and tags changes accordingly.
### Copy to Clipboard
Export state data or diffs for debugging, bug reports, or test fixtures (Pro).
### Recording Toggle
Pause state capture when you need to focus, resume when ready.
---
## What It Can't Do
**Stores are not auto-discovered.** A Zustand store is a plain function with no registry to enumerate, so Buoy cannot find yours the way it finds a Redux store. Pass them to `FloatingDevTools` via `zustandStores`, or wrap a store with the `buoyDevTools` middleware. If nothing is registered, the tool says so rather than showing an empty list.
**Action names and timing need the middleware.** The `zustandStores` route watches state and can tell you *what* changed. `buoyDevTools` sits inside the setter, so it also knows *which call* changed it. Without it you get the diff, not the label.
## What's Next
- [Redux DevTools](./redux) — Redux action monitor with state diffing and time-travel
- [React Query DevTools](./react-query) — TanStack Query inspection
- [Network Monitor](./network) — Inspect supported HTTP requests
- [Storage Explorer](./storage) — Browse and edit AsyncStorage & MMKV
---
## FAQ
### How do I debug Zustand state in React Native?
Install `@buoy-gg/zustand` and register your stores — they appear in the on-device browser with live state, an update stream, and diffs. No Redux DevTools bridge or web debugger needed.
### Can I see which store update caused a bug?
Yes — the event stream timestamps every update with the store name and changed key, and each event has a before/after diff.
## Web support (unreleased)
Register live stores with watchStores. The shared browser panel edits the same store objects used by the app. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Jotai DevTools
Source: https://buoy.gg/buoy/latest/docs/tools/jotai
Inspect named Jotai atoms, their current values, and recorded changes. Register the atoms you need with the same store your app uses, then follow a value through the event history and diff views.
## Installation
---
## Setup
Call `watchAtoms` once at module scope — pass your Jotai store and a named map of your atoms. No wrappers, no middleware, no modifications to existing atoms.
```tsx
import { getDefaultStore } from 'jotai';
import { watchAtoms } from '@buoy-gg/jotai';
import { countAtom } from './atoms/count';
import { authAtom } from './atoms/auth';
import { cartAtom } from './atoms/cart';
watchAtoms(getDefaultStore(), {
countAtom,
authAtom,
cartAtom,
});
```
That's it. Registered atoms automatically appear in the Jotai tool inside your FloatingDevTools menu.
Change a registered atom through your app, then confirm its new value and event appear. If it is absent, check that `watchAtoms` received the same store as your provider.
---
## Atoms Tab
Browse all registered atoms and their **live current value**:
- **Atom Name** — Color-coded for easy identification across both tabs
- **Value Type** — `number`, `boolean`, `object`, `array · N`, `null`, etc. at a glance
- **Live Value** — Tap any atom to expand and see the full value tree, updated in real-time
- **Change Count** — How many times this atom has changed this session
- **View History** — Jump straight to the filtered event history for a single atom
---
## Events Tab
Every atom change is captured with rich metadata:
- **Atom Name** — Which atom changed, color-coded for quick identification
- **Value Transition** — `prev → next` at a glance (e.g. `0 → 5`, `null → {name, email}`, `[2 items] → [3 items]`)
- **Category Badge** — `INIT` (initial registration) or `WRITE` (subsequent update)
- **No Change** — Flags writes that fired but didn't actually change the value
- **Timestamp** — When the change occurred with relative time
---
## Detail View
Tap any event to see three detailed tabs:
### Change Tab
Atom name, timestamp, category badge, and a diff summary showing which object keys changed.
### Value Tab
The full atom value after this change — collapsible JSON tree for objects, raw value for primitives.
### Diff Tab
Side-by-side comparison of before and after — additions (green), removals (red), modifications (yellow). Choose between tree view or split view.
---
## Using a Custom Store
If your app uses a `` with a custom store, pass it directly:
```tsx
import { createStore, Provider } from 'jotai';
import { watchAtoms } from '@buoy-gg/jotai';
import { countAtom, authAtom } from './atoms';
const myStore = createStore();
watchAtoms(myStore, { countAtom, authAtom });
export function App() {
return (
);
}
```
---
## Features
### Atom Color Coding
Each atom gets a consistent color across the Atoms tab, Events tab, and detail views. Colors are assigned automatically based on atom name and persist for the session.
### Value Transition at a Glance
Every event row shows `prev → next` so you immediately know what changed — no need to tap in for simple updates.
### Atom History
Tap "view history" on any atom in the Atoms tab to see all events scoped to just that atom — useful for tracking a specific piece of state through a flow.
### Search & Filter
Find events by atom name or value content. Add filter patterns to hide noisy atoms from both the Atoms and Events tabs simultaneously.
### Copy to Clipboard
Export the full atoms snapshot or event history as JSON for bug reports, test fixtures, or sharing with teammates (Pro).
### Recording Toggle
Pause atom capture when you need to focus, resume when ready.
---
## What It Can't Do
Only registered atoms are visible. Jotai has stores, but Buoy does not automatically discover every atom your app may use.
Read-only atoms cannot be written. A derived atom can be writable if it defines a write function; derivation alone does not determine whether a write is allowed. Check the tool's reported capability before editing or restoring it.
## What's Next
- [Zustand DevTools](./zustand) — Zustand store monitor with state diffing and jump-to-state
- [Redux DevTools](./redux) — Redux action monitor with state diffing and time-travel
- [React Query DevTools](./react-query) — TanStack Query inspection
- [Network Monitor](./network) — Inspect supported HTTP requests
---
## FAQ
### How do I inspect Jotai atoms in React Native?
Install `@buoy-gg/jotai` and register your atoms — they appear in the on-device browser with live values, write history, and diffs.
## Web support (unreleased)
Register the app’s atoms and store with watchAtoms. The shared panel and snapshot provider use those registrations. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Images
Source: https://buoy.gg/buoy/latest/docs/tools/images
Inspect captured React Native Image and expo-image loads, including timing, dimensions, errors, and cache information. Use the registry to find oversized sources and retry failed loads.
The demo uses a mock storefront. Cache verdicts and byte data depend on the image component and platform; the coverage notes below explain the differences.
## Installation
Add the register import as the **first line** of your app entry file (`index.js` / `index.ts`):
```ts
import "@buoy-gg/images/register";
```
With core account setup complete, the installed tool appears in the floating menu. Keep your existing account initialization and providers; this example shows the menu placement:
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
export default function App() {
return (
<>
>
);
}
```
> **Why first?** RN core's `` instrumentation uses React Native's official component-decorator hook, which must be installed before the Image module first evaluates. expo-image capture has no timing constraint — it works whenever the tool loads. If the import is missing or too late, the tool tells you exactly what to fix instead of sitting silently empty.
---
## What You Can Do
- **Inspect captured image loads** — thumbnail, source URL, load time, decoded dimensions, format, status. Tap for the full detail view.
- **Get cache verdicts per load** — memory, disk, or network. expo-image reports it directly on every load; RN core images are classified via `Image.queryCache` right after loading.
- **Catch oversized sources** — the tool compares decoded pixels against the laid-out size × device pixel ratio, Lighthouse-style: green within 10%, red beyond 50% — with the estimated wasted decoded memory and the exact dimensions you should serve instead.
- **Catch upscaled (blurry) sources too** — a 50px thumbnail stretched into a 300pt hero gets flagged the other way.
- **Track decoded memory** — estimated decoded-bitmap bytes per image and totaled across mounted images, so you catch ballooning before the OOM crash.
- **Diagnose failures** — instrumented `onError` events are captured with the error message; on iOS, RN core also gives you the HTTP status code and response headers.
- **Act on any image** — hard reload (bypass caches + refetch), retry, or flash a red border on the on-screen image to locate it visually.
- **Simulate the bad day** — force an error, an endless loading state, or a blank on any mounted image; swap its source URL in place; or flip app-wide modes: **Offline** (network images fail, bundled assets still load), **Cold** (every load bypasses caches like first launch), and Chrome-style **blank images**. Mass actions apply any of these to everything on screen at once.
- **Prove the savings** — re-encode an oversized source at its displayed size as WebP *on the device*, get the real byte savings, and preview the optimized file in place before you touch your CDN.
- **Clear caches** — expo-image memory and disk caches, one tap from the detail view.
## How it works
React Native ships an official (if `unstable_`-prefixed) hook that lets a devtool wrap every `` in the app — the Images tool uses it to observe sources and attach load/progress/error handlers app-wide, with zero native code. expo-image's component is instrumented the same way through a render-level patch that's immune to import order. Both paths force-attach the load events the libraries already emit natively, so capture works identically in Expo Go, dev clients, and release builds.
Buoy's own UI (like the thumbnails inside the tool) is excluded from capture — the tool never appears in its own registry.
## Coverage notes
- Byte counts (`loaded/total`) come from image `onProgress` events: reliable on iOS RN core and expo-image; RN core on Android (Fresco) doesn't report real byte counts.
- Cache classification for RN core images uses `Image.queryCache`, which reflects the cache *after* the load — combined with whether network progress events fired, the verdict distinguishes a fresh download from a cache hit.
- Decoded-memory numbers are estimates (`width × height × 4` bytes) — the same math the platforms use for RGBA bitmaps.
## Desktop & AI
The same live registry streams to [Buoy Desktop](https://github.com/Buoy-gg/Buoy-Desktop) — the full tool (list, detail, simulations, mass actions) on a big screen — and to your editor's AI agent via the MCP server: `get_images` audits a screen's images in one call, `image_action` drives per-image reloads/overrides (including the on-device savings re-encode), and `set_image_simulation` flips the app-wide modes.
## What's Next
Cache explorer (browse the actual disk cache directories with sizes and ages), per-screen waste reports, and an X-ray overlay mode (badges on every on-screen image).
---
## FAQ
### Why can’t my network inspector see image requests in React Native?
Image fetches happen in native code (NSURLSession / Fresco), never in the JS network stack — so JS-level network devtools can’t observe them. Buoy Images hooks the image components themselves instead, capturing every load with its cache verdict and timing.
### Does it work in Expo Go and release builds?
Yes — capture is pure JavaScript (RN’s official component-decorator hook plus an expo-image render patch), so there’s no native module to install and it works in Expo Go, dev clients, and release builds.
### How do I find images that waste memory?
The registry compares each image’s decoded pixels against its rendered size × device pixel ratio and totals the estimated wasted decoded bytes — sort by the red verdicts, then use the on-device re-encode to prove what a right-sized source would save.
## Web support (unreleased)
Browser capture observes DOM images and supports shared overrides and size analysis. Savings previews use Canvas; cross-origin reads require permission from the image server. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Assets
Source: https://buoy.gg/buoy/latest/docs/tools/assets
Inspect assets registered in the running app, including dimensions, scale variants, and available size data. In development, the Metro graph adds bundle-wide information for duplicate and not-yet-loaded checks.
An asset not loaded during this session may still be used by another screen or flow. Exercise representative flows before removing it.
Buoy's own package assets are excluded from the inventory, size totals, and reports. The exclusion uses package source paths, so app assets with the same filenames remain visible.
## Installation
That's it — no register import, no config, no registration. Auto-discovery finds the installed package and the ASSETS tool appears in your floating menu:
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
export default function App() {
return (
<>
>
);
}
```
> **Why zero-config?** Asset ids in React Native's runtime registry are contiguous, so the tool enumerates everything registered since app start the moment it opens — nothing needs to load early. Live updates stream in as lazily-required assets register later.
---
## What You Can Do
- **See the whole inventory, largest first** — assets visible through the available inventory sources with a thumbnail, dimensions, scale variants, and its size. Filter by kind (images / fonts / video / audio / data) or show only assets not observed in the runtime registry.
- **Get real byte sizes** — in dev, every scale variant is measured from the Metro server, per variant and totaled. In release builds you still get decoded-memory estimates.
- **Find assets not observed in the runtime registry** — compare the bundle graph with React Native asset registrations during this app run. Other loading paths may not be captured.
- **Catch duplicate content** — identical bytes shipped under different names or paths, flagged by content hash. One of the most common (and cheapest to fix) app-size wins.
- **Audit scale coverage** — images whose variants can't serve the current device's pixel ratio (blurry on 3x) are flagged per record.
- **Spot WebP wins** — large PNG/JPEG candidates for conversion; measure the converted files before claiming savings.
- **See every loaded font family** — build-time embedded and runtime loaded (via the expo-font native module when present).
- **Copy a markdown report** — the full inventory with findings, ready for an issue or PR description.
---
## How It Works
Three layers, each degrading gracefully:
1. **Runtime registry (everywhere, incl. release):** enumerates `@react-native/assets-registry` — every asset whose module has been evaluated — and patches `registerAsset` for live updates. Works in Expo Go, dev clients, bare RN, and release builds.
2. **Metro graph (dev):** fetches the dev server's full asset graph for bundle-wide coverage, source paths, and byte measurement — this is what powers runtime registration comparisons and real sizes.
3. **Expo enrichment (when present):** loaded font families and the expo-updates embedded-asset map, read through guarded globals — no extra dependencies for bare RN apps.
**Limits:** `.json` files (including Lottie) compile into the JS bundle as source modules, so they never reach the asset registry — they're visible in dev via the bundle graph only. Native-only resources (app icons, splash screens) live outside the JS bundle entirely and aren't listed.
---
## FAQ
### Can I preview SVG assets?
On native apps, SVG thumbnails and detail previews use `expo-image` if it is installed and its native module is available. It is optional, and Assets does not require `react-native-svg`. When a preview cannot load, the tool shows a placeholder. Desktop and web use browser image rendering. The iOS system SVG decoder has limitations with some path commands.
### Why does the asset count grow after I open the tool?
The tool shows registered assets first, then asks the development server for the larger bundle inventory. A spinner and a "found so far" count show that discovery is still running. After eight seconds, a message explains the wait. You can keep browsing the assets already listed.
Size checks run after discovery. If discovery fails or exceeds 90 seconds, the tool keeps the assets it already found and offers Retry. Without a development server, it shows runtime-registered assets only.
### How do I find out how big my React Native app assets are?
Open the tool in a dev build — every scale variant of assets visible through the available inventory sources is measured from the Metro server and summed per asset, with kind totals in the header. Release builds still get decoded-memory estimates.
### Can it find assets I ship but never use?
In development, it compares the Metro bundle graph with React Native’s runtime asset registry. **Not observed** shows assets missing from that registry during this app run, across screens. Other loading paths may not register there, so this is not proof that an asset is unused.
### How is this different from the Images tool?
Images shows what your app renders at runtime — per-load cache verdicts, timings, failures. Assets shows what your app ships in the bundle. The slow load is an Images problem; the megabytes are an Assets problem.
Use **Duplicates** at the top of the asset list to show every asset that shares a content hash with another asset. The count includes all copies. Selecting it clears the search; select **All** or tap **Duplicates** again to return to the full list.
## Web support (unreleased)
Browser capture observes loaded resources. Add the Vite asset manifest or register a manifest from your bundler to include files before they load. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Impersonate Tool
Source: https://buoy.gg/buoy/latest/docs/tools/impersonate
Test an authorized user's app experience by attaching an impersonation header to intercepted requests. Your backend must authenticate the operator, authorize impersonation, and interpret the header.
Configure user search, select a test user, verify the header on a request to your backend, then stop impersonation and confirm it is removed.
## Installation
Unlike other Buoy tools, the Impersonate tool requires configuration because it needs to integrate with your user search API.
---
## Quick Start
The example uses your existing `api.searchUsers` client and `YourApp` component. Define those in your app, and keep the menu inside the providers needed for cache clearing.
```tsx
import { createImpersonateTool } from '@buoy-gg/impersonate';
import { FloatingDevTools } from '@buoy-gg/core';
const impersonateTool = createImpersonateTool({
// Required: How to search for users
onSearchUsers: async (query) => {
const response = await api.searchUsers({ email: query });
return response.users.map(user => ({
id: user.id,
displayName: user.name,
email: user.email,
avatarUrl: user.avatar,
metadata: { role: user.role },
}));
},
});
function App() {
return (
<>
>
);
}
```
---
## How It Works
When impersonation is active, the tool automatically injects a header into intercepted outgoing `fetch` and `XMLHttpRequest` calls:
```
x-impersonate-user-id: user_123
```
Your backend checks for this header and returns data for the specified user instead of the authenticated admin.
> **Security Note:** Your backend should validate that the authenticated user has admin/impersonation permissions before honoring this header.
---
## Features
### User Search
Search for users by email, name, or ID. Results display in a clean card format showing user details and metadata. When viewing an active impersonation, the card shows a power button to stop directly from the search results.
### Impersonation History
Quick-switch between recently impersonated users. History persists across app restarts (up to 10 users). Each history entry shows when the user was last impersonated (e.g., "5m ago", "2h ago").
### Data Clearing (Auto-Detected)
Automatically clear stale data when switching users:
| Option | Description | Auto-Detected | Default |
|--------|-------------|:-------------:|---------|
| React Query | Clear query cache and cancel pending queries | ✅ | On |
| Redux | Reset Redux store to initial state | ✅ | On |
| AsyncStorage | Clear app data (preserves `@buoy/*` keys) | ❌ | Off |
| MMKV | Clear MMKV storage (preserves `@buoy/*` keys) | ❌ | Off |
Place the menu inside your Query and Redux providers. Redux auto-clearing dispatches `@@RESET`; your reducer must handle it, or you must supply `onClearRedux`. Detecting a store does not guarantee that it can be reset.
### Floating Banner
A floating banner automatically appears when impersonation is active, showing which user is selected. The banner can be toggled on/off in Settings.
---
## Configuration
### Required: User Search
The `onSearchUsers` callback is required. It should return an array of `User` objects:
```tsx
interface User {
id: string; // Sent in the impersonation header
displayName?: string; // Shown in UI (falls back to email, then id)
email?: string; // User's email
avatarUrl?: string; // Avatar image URL
metadata?: Record; // Extra info to display
}
```
### Data Clearing Callbacks
Within the corresponding providers, the tool can:
- Uses `useQueryClient()` to clear React Query cache
- Uses `useStore()` to dispatch a reset action to Redux
For AsyncStorage and MMKV, you can provide callbacks:
```tsx
const impersonateTool = createImpersonateTool({
onSearchUsers: searchUsers,
// Clear AsyncStorage (filter out keys you want to keep)
onClearAsyncStorage: async () => {
const keys = await AsyncStorage.getAllKeys();
const appKeys = keys.filter(k => !k.startsWith('@buoy/'));
await AsyncStorage.multiRemove(appKeys);
},
// Clear MMKV
onClearMMKV: () => {
const keys = storage.getAllKeys();
keys.filter(k => !k.startsWith('@buoy/')).forEach(k => storage.delete(k));
},
});
```
> **Override auto-detection** — If you provide a callback for React Query or Redux, your callback is used instead of auto-detection. This is useful if you need custom reset logic.
---
## Developer Defaults
Set default values for your team. These are used when there are no persisted user settings:
```tsx
const impersonateTool = createImpersonateTool({
onSearchUsers: searchUsers,
defaults: {
headerKey: 'x-admin-impersonate', // Custom header name
showBanner: true, // Show floating banner (default: true)
dataNukeSettings: {
reactQuery: true,
redux: false, // Don't clear Redux by default
asyncStorage: false,
mmkv: false,
},
},
});
```
**Priority order:** User's persisted settings > Developer defaults > Built-in defaults
### Export Configuration
The Settings tab includes an "Export Configuration" section. Click **Copy Config** to copy your current settings as code that can be pasted directly into `createImpersonateTool()`.
---
## Hide Settings Tab
For simple testing scenarios where you don't want users changing settings:
```tsx
const impersonateTool = createImpersonateTool({
onSearchUsers: searchUsers,
showSettingsTab: false, // Only show Search and History tabs
});
```
---
## Floating Banner
The impersonate tool automatically shows a floating banner at the top of the screen when impersonation is active. The banner displays:
- The impersonated user's avatar and name
- A **power toggle button** to pause/resume impersonation
- An **X button** to stop impersonation completely
### Banner Controls
| Control | Action |
|---------|--------|
| Tap user area | Opens the impersonate modal |
| Power button (green) | Pause impersonation — headers stop being injected |
| Power button (red) | Resume impersonation — headers start being injected again |
| X button | Stop impersonation completely |
### Pause vs Stop
- **Pause**: Temporarily stops injecting headers. The session remains active and can be resumed instantly. Useful for quick A/B testing between impersonated and normal views.
- **Stop**: Ends the impersonation session completely. Triggers data clearing based on your settings.
**No setup required** — the banner appears automatically. Users can toggle it off in the Settings tab, or you can set the default via `defaults.showBanner`:
```tsx
const impersonateTool = createImpersonateTool({
onSearchUsers: searchUsers,
defaults: {
showBanner: false, // Disable banner by default
},
});
```
---
## Full Configuration Reference
```tsx
interface ImpersonateToolConfig {
// Tool identity (optional)
id?: string; // Default: 'impersonate'
name?: string; // Default: 'IMPERSONATE'
description?: string; // Shown in tool settings
// Required
onSearchUsers: (query: string) => Promise;
// Data clearing callbacks
// React Query & Redux are auto-detected — only provide if you need custom logic
onClearReactQuery?: () => void | Promise;
onClearRedux?: () => void | Promise;
onClearAsyncStorage?: () => void | Promise; // Required for AsyncStorage
onClearMMKV?: () => void | Promise; // Required for MMKV
// Developer defaults (optional)
defaults?: {
headerKey?: string; // Default: 'x-impersonate-user-id'
showBanner?: boolean; // Default: true
dataNukeSettings?: {
reactQuery?: boolean; // Default: true
redux?: boolean; // Default: true
asyncStorage?: boolean; // Default: false
mmkv?: boolean; // Default: false
};
};
// UI options
showSettingsTab?: boolean; // Default: true
}
```
---
## Backend Integration
Your backend must authenticate the request before this middleware, verify impersonation permission, and check whether the target user is in the operator's allowed scope. This sketch only illustrates the header and admin check; it is not a complete authorization implementation:
```typescript
// Integration sketch: run after your authentication middleware.
function impersonateMiddleware(req, res, next) {
const impersonateUserId = req.headers['x-impersonate-user-id'];
if (impersonateUserId) {
// Verify the authenticated user has permission to impersonate
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Impersonation not allowed' });
}
// Switch context to impersonated user
req.effectiveUserId = impersonateUserId;
} else {
req.effectiveUserId = req.user.id;
}
next();
}
```
---
## What's Next
- [Network Monitor](./network) — See the impersonation headers in your requests
- [Redux DevTools](./redux) — Watch state changes when switching users
- [Storage Explorer](./storage) — Inspect persisted user data
---
## FAQ
### How does impersonation work — does it bypass my auth?
No. Buoy only attaches the headers you configure to outgoing requests. Your backend implements what impersonation means and enforces who may use it — Buoy is the on-device switch.
### Can I test feature flags for different user cohorts?
Yes — if your flag service keys off user identity or headers, switching the impersonated user flips the flags the app receives.
## Web support (unreleased)
Register this package’s /web namespace in FloatingDevTools modules to use its shared panels and actions in a browser app. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Image Overlay
Source: https://buoy.gg/buoy/latest/docs/tools/image-overlay
Overlay design mockups directly on your running app to compare against your implementation. Load a mockup, match its scale and position to the screen, and adjust opacity to compare spacing and alignment.
The demo shows component targeting and alignment on a mock storefront.
## Installation
That's it. Image Overlay appears in your FloatingDevTools menu.
---
## Marking Components as Targets
To make a component discoverable in **Component Mode**, add a `testID` with the `image-target:` prefix:
```tsx
Welcome back {}} />
```
The text after `image-target:` becomes the label shown in the target list. Add as many targets as you like throughout your app:
```tsx
// Header section
// Product card
{product.name}{product.price}
// Bottom tab bar
```
> **Only `image-target:` testIDs are scanned** — your existing `testID` props for testing (e.g., `testID="login-button"`) are not affected and won't appear in the target list.
---
## Loading Images
You can load a design mockup in two ways:
- **Paste from Clipboard** — Copy an image in Figma (or anywhere) and tap "Paste from Clipboard". Requires `expo-clipboard`.
- **Enter a URL** — Paste a direct image URL and tap "Load". Works with any React Native setup.
Clipboard image loading requires `expo-clipboard` and its native setup. Use a direct image URL for the minimal path; load it, reduce opacity, align the mockup, and lock the overlay before interacting with the screen.
---
## Two Modes
### Component Mode
Tap **Component Match** to scan your app and see all tagged targets. Select one, and the overlay pins to that component — measuring its exact position and size. When the component scrolls or repositions, the overlay follows automatically.
### Free Mode
Manually position and resize the overlay anywhere on screen. Drag to move, pinch to scale — useful when you want to compare a full-screen mockup or a section that doesn't map to a single component.
---
## What You Can Do
- **Opacity control** — Blend the mockup over your live UI to spot differences
- **Scale & zoom** — Resize the overlay to match your layout
- **X/Y offset** — Fine-tune positioning for exact alignment
- **Flip** — Mirror the overlay horizontally or vertically
- **Lock** — Prevent accidental repositioning while comparing
- **Outline toggle** — Show overlay boundaries for precise placement
- **Auto-track** — Remeasures the target component on every render cycle so the overlay stays locked even during animations and layout shifts
---
## What's Next
- [Highlight Updates](./highlight-updates) — See exactly why components re-render
- [Environment Inspector](./env) — View and search environment variables
- [Network Monitor](./network) — Inspect supported HTTP requests
---
## FAQ
### How do I compare my React Native UI against a Figma design?
Export the frame as an image, then load it in Image Overlay from clipboard or URL — adjust opacity over the running app and differences jump out.
## Web support (unreleased)
Tag browser targets with data-testid="image-target:Name" and import the browser registration before React DOM. The shared controls support target and free placement. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Camera
Source: https://buoy.gg/buoy/latest/docs/flutter/tools/camera
Buoy Desktop supplies a camera feed to Flutter apps in the iOS Simulator. Choose a source on your Mac, then relaunch the app process so the camera integration loads.
**iOS Simulator only.** Android emulators can already use a webcam natively, and
this is not a physical-device tool.
## Use it
Buoy Desktop → **Camera** → click a source. Then run your app however you like —
`flutter run`, from your IDE, or a build someone handed you. Sources: **Mac
camera**, **Screen region**, **Barcode**, **Image**, **Video**, **Test pattern**.
There is no package for this one. Nothing goes into your `pubspec.yaml`, nothing
wraps your widget tree, and `buoy_core` is not required — it works on any booted
simulator app, including apps that have never heard of Buoy.
The camera attaches at process start, so restart anything already running — hot
reload and hot restart won't do it, because the library is loaded by the process
before Dart runs. Switching source afterwards is live. Camera permission is
pre-granted, so your app never prompts.
## Screen region
A resizable box on your desktop is the camera. Put it over a QR code, a licence
or a document and your app scans what's inside it.
Drag to move, pull a handle to resize, **Esc** to put it away. Resizing snaps to
**16:9** — the only shape the camera has, so anything else gets black bars; hold
**Shift** to override. **Click-through** lets clicks land underneath, so you can
scroll the page you're filming.
Needs Screen Recording permission — without it the feed is black.
## Barcodes
QR, PDF417, Aztec, DataMatrix, EAN-8/13, UPC-E, Code 128/39/93, ITF.
Detection runs on your Mac, not in the simulator, and the results are handed to
the app through the same metadata API a real camera uses — so a scanner reading
`AVCaptureMetadataOutput` gets them without knowing anything happened. The
**Barcode** source generates a code from text you type, which is how you test a
licence scanner without owning a licence.
## What works
The devices are fabricated at the AVFoundation level, below anything Dart can
see, so compatibility depends on how the camera plugin uses AVFoundation. Verified with Flutter's
[`camera`](https://pub.dev/packages/camera) plugin: preview, the frame stream,
photo capture, and video recording.
Plugins that go through `AVCaptureSession`
should work the same way. That is an expectation, not a test result: only the
`camera` plugin has actually been run.
## Desktop & AI
This tool lives in [Buoy Desktop](../../desktop); there is no in-app half to it.
And with the [MCP server](../../mcp), an agent can run a camera test end to end
through its supported actions: find a simulator and its apps (`camera_devices`), see
the Mac's cameras and windows (`camera_inputs`), pick what to show
(`camera_source`), attach it (`camera_launch`, or `camera_zero_setup` for
everything the simulator launches), then check its own work (`camera_status`,
`camera_diagnose`) and clean up (`camera_stop`).
No app-side Buoy package or device connection is required. MCP requires Pro. QR generation is free; Screen region and additional barcode types require Pro. See [Camera](../../tools/camera) for the shared plan and source details.
## Limits
- macOS + Xcode + Buoy Desktop. Screen sources need macOS 12.3+.
- Simulator only, not devices. Android emulators do webcams natively.
- No recording through `AVCaptureMovieFileOutput` — the `camera` plugin's own
video recording works, but that specific output class does not.
- Front and back cameras are the same feed.
- No GS1 DataBar or Micro symbologies.
- One injection owner at a time — quit other simulator-camera tools.
---
## What's Next
- [Images](./images) — Every image load, with cache verdicts and an oversize audit
- [Network Monitor](./network) — The requests your capture screen makes afterwards
- [Buoy Desktop](../../desktop) — Where this tool lives
---
## FAQ
### Do I need a Buoy package for this?
No. It is the one Buoy tool with nothing to install in the app — no
`pubspec.yaml` entry, no `BuoyDevTools` wrapper, not even `buoy_core`. Buoy
Desktop loads a small library into the simulator at launch and publishes frames
to it, so any booted simulator app gets a camera.
### Why does my Flutter app still say there's no camera?
The library is inserted when the process starts, so an app that was already
running when you turned the camera on never got it. Restart the app — hot reload
and hot restart both keep the same process, so neither is enough.
### Can I scan a QR code without printing one?
Yes, two ways. The **Barcode** source generates one from text you type. The
**Screen region** source points the camera at a rectangle of your own desktop,
so you can scan a code on a web page, in a PDF, or in a design file.
### Does this work on Android emulators?
It is not needed there — Android emulators can use a webcam as the camera
natively. This tool exists because the iOS Simulator ships no camera at all.
# Environment Inspector
Source: https://buoy.gg/buoy/latest/docs/flutter/tools/env
The Environment Inspector lets you view and validate environment config in your Flutter app — required-variable checks, type detection, per-variable status badges, and a 0–100% health score.
The film and the demo show the React Native tool, and the demo uses mock data. Use the Flutter setup and feature descriptions below for supported behavior; the demo does not establish Flutter feature parity.
## Installation
## Registration
Flutter has no enumerable `process.env` — `--dart-define` values are compile-time and can't be listed at runtime. So instead of auto-discovery, you hand Buoy the map explicitly:
```dart
import 'package:buoy_env/buoy_env.dart';
registerBuoyEnv(
vars: {
'API_URL': const String.fromEnvironment('API_URL'),
'ENVIRONMENT': const String.fromEnvironment('ENVIRONMENT'),
// ...any runtime config strings
},
requiredEnvVars: [
envVar('API_URL').withType('url').build(),
RequiredEnvVar.value('ENVIRONMENT', 'production'),
envVar('FEATURE_FLAG').exists(), // existence check
],
);
```
## The `envVar` Builder
The same fluent builder API as React Native:
```dart
envVar('API_KEY')
.withType('string') // Set expected type
.withValue('sk_test_123') // Or set expected value
.withDescription('API Key') // Add documentation
.build() // Finalize config
// Shorthand for just checking existence
envVar('API_KEY').exists()
```
### Supported Types
```dart
// 'string' | 'number' | 'boolean' | 'array' | 'object' | 'url'
```
## `registerBuoyEnv` Options
| Option | Type | Meaning |
| --- | --- | --- |
| `vars` | `Map?` | Explicit runtime values to display |
| `requiredEnvVars` | `List?` | Required names, types, and expected values |
Each required entry must be a `RequiredEnvVar`. Use `envVar(name).exists()` for an existence check, `RequiredEnvVar.value(...)` for an expected value, or the builder for type checks.
## Features
- **Required Variable Validation** — Define which vars must exist with expected values/types
- **Type Detection** — Auto-detects: string, number, boolean, array, object, url, json
- **Search & Filtering** — Real-time search + filters for "All", "Missing", "Issues"
- **Health Status** — Health percentage (0–100%) with HEALTHY/WARNING/ERROR/CRITICAL states
- **Statistics** — Total count, required count, missing count, wrong value/type counts
- **Copy to Clipboard** — Copy any value with one tap
## Variable Status Types
| Status | Description |
|--------|-------------|
| `required_present` | Required var is set and correct |
| `required_missing` | Required var is not set |
| `required_wrong_value` | Set but doesn't match expected value |
| `required_wrong_type` | Set but wrong type |
| `optional_present` | Optional var that is set |
## Validation Visual Indicators
- **Green** — Variable exists and matches expected value/type
- **Yellow** — Variable exists but value/type differs from expected
- **Red** — Required variable is missing
## What's Next
- [Network Monitor](./network) — Inspect supported HTTP requests
- [Storage Explorer](./storage) — Browse and edit shared_preferences
- [Console](./console) — Every log call in a DevTools-style panel
---
## FAQ
### How do I check which --dart-define values my Flutter build actually got?
Flutter has no enumerable `process.env`, and compile-time `--dart-define` values can't be listed at runtime — so you hand Buoy the map in `registerBuoyEnv(vars: {...})`. The inspector then shows the value each variable resolved to, with per-variable status badges and a 0–100% health score.
### Can it validate types and required values?
Yes — declare expectations with the `envVar` builder (`withType`, `withValue`, `withDescription`, or `exists` for a plain existence check). Supported types are string, number, boolean, array, object, and url, and anything missing or wrong is flagged.
# Events Timeline
Source: https://buoy.gg/buoy/latest/docs/flutter/tools/events
Inspect captured events from registered source tools in one chronological timeline. Debug complex user flows by watching network requests, storage changes, state updates, and navigation happen in real-time.
The film and the demo show the React Native tool, and the demo uses mock data. Use the Flutter setup and feature descriptions below for supported behavior; the demo does not establish Flutter feature parity.
## Installation
With the umbrella, Events and its bundled source tools are registered for you. For a standalone install, register Events as well as the sources you need:
```dart
import 'package:buoy_events/buoy_events.dart';
registerBuoyEvents();
```
Call registration before using the tool and keep `BuoyDevTools` mounted in debug mode. Complete Network, Storage, Routes, or Riverpod integration on the corresponding tool pages. Trigger one event and check its source badge in the timeline.
---
## Event Sources
| Source | Events |
| --- | --- |
| [Network](./network) | every HTTP request with status and timing |
| [Storage](./storage) | every persisted write with diffs |
| [Routes](./routes) | every navigation with params |
| [Riverpod](./riverpod) | every provider state change |
> **Auto-detection** — A source must be registered and capturing events before it appears in the timeline.
---
## What You Can Do
- **One interleaved timeline** — newest-first, across all installed source tools.
- **Per-source filters** — toggle a source on/off with live subscriber + event counts.
- **Header search** — tap the magnifying glass and filter as you type; matches an event's title, its subtitle (status, duration, host, key), and the full URL of network events. Stacks with the source filters.
- **Real detail views** — a network event opens the same detail page the Network tool shows, including the shared ignore-domain / ignore-URL toggles that hide matches from both lists.
- **Capture toggle + export** — pause capture, and copy as markdown, JSON, plaintext, or a mermaid diagram.
---
## Search
Tap the magnifying glass in the header to filter the timeline as you type. Matches an event's title, its subtitle (status, duration, host, key), and the full URL of network events — so you can search a host, an action type, a storage key, or a query string value.
> Search stacks with the source badges — filter to Network, then search for the failing endpoint.
---
## LLM Export
Copy your event timeline in formats optimized for AI assistants. Reproduce a bug, export, and paste into Claude or ChatGPT with your question — or skip the copy-paste entirely and let an agent read it live with the [MCP server](../../mcp)'s `get_events`.
The export follows what's on screen: filter to a source or search the timeline first and you copy that slice, not the whole session.
---
## Correlation
Related events can share a `correlationId` (included in JSON / LLM exports) so you can trace a full lifecycle across sources when tools emit one.
---
## What's Next
- [Network Monitor](./network) — Inspect request details
- [Riverpod Inspector](./riverpod) — State inspection with diffs
- [Storage Explorer](./storage) — Browse and edit persisted data
---
## FAQ
### How do I see everything my Flutter app did in one timeline?
Install `buoy_events` — it aggregates automatically from the Buoy tools you already have: network requests, storage writes, navigation, and Riverpod state changes, newest-first, with per-source filters and live counts. Source tools must be registered and configured.
### Can I export the timeline?
Yes — pause capture and copy the timeline as markdown, JSON, plaintext, or a mermaid diagram.
# Console
Source: https://buoy.gg/buoy/latest/docs/flutter/tools/console
A Chrome-DevTools-style console for your Flutter app. Buoy Console captures logs captured through the configured hooks — plus `FlutterError` reports and uncaught async errors — and shows them in a familiar, filterable panel: on your phone, on the desktop dashboard, or through your AI agent.
The film and the demo show the React Native tool, and the demo uses mock data. Use the Flutter setup and feature descriptions below for supported behavior; the demo does not establish Flutter feature parity.
## Installation
To capture `print`, wrap your entry point in `BuoyConsole.runZoned` (a `Zone` is the only way to observe `print` in Dart) — so capture starts before your first log fires:
```dart
import 'package:buoy_console/buoy_console.dart';
void main() {
BuoyConsole.runZoned(() {
runApp(const MyApp());
});
}
```
If you never call `BuoyConsole.runZoned`, call `BuoyConsole.install()` once instead — everything except `print` (`debugPrint`, `FlutterError`, uncaught async errors) is still captured.
### Crashes don't disappear
An uncaught error normally takes the desktop connection down with it: the throttled snapshot never fires, and the dashboard just shows an app that stopped answering. Buoy pushes a crash entry out immediately instead, while the connection is still alive, so the app's last words are readable from the desktop dashboard and from your AI agent (`get_triage` leads with them).
Crash entries are tagged so you can tell what actually happened:
| Tag | Source | Meaning |
| --- | --- | --- |
| `[UNCAUGHT]` | `PlatformDispatcher.onError`, guarded zone | Nothing in your code handled this error. |
| `[RENDER ERROR]` | `FlutterError.onError` | A framework/build error. Flutter fires this for errors an `ErrorWidget` then recovers from, so it is reported without claiming your app crashed. |
Duplicate reports of the same error are deduplicated; separate error occurrences remain separate entries.
---
## What You Can Do
- **See every log, live** — logs, warnings, and errors stream in as they happen, color-coded by level.
- **Filter by level** — Focus on just errors and warnings when you're chasing a bug.
- **Search** — Filter messages by substring to find the exact log you care about.
- **Read debug-build logs** — Keep `BuoyDevTools` mounted in debug mode to inspect logs on-device or through Desktop. The widget does not enable this setup in profile or release mode.
- **Expand structured data** — Objects and lists are formatted and expandable, just like the browser console.
---
## Read the console from your AI
With the [MCP server](../../mcp), an AI agent can read the console tail directly with `get_console` — filtering by minimum level or message substring to pull just the errors it needs while debugging.
---
## What's Next
- [Events Timeline](./events) — Console logs alongside network, state, and route events
- [Network Monitor](./network) — Inspect the requests behind an error
- [AI / MCP Server](../../mcp) — Let an agent read the console for you
---
## FAQ
### How do I read print and debugPrint output without a debugger attached?
Wrap your entry point in `BuoyConsole.runZoned` — a `Zone` is the only way to observe `print` in Dart — and logs captured through the configured hooks, plus `FlutterError` reports and uncaught async errors, appears in the on-device panel of a debug build.
### Can I read the logs from a crash?
Yes. An uncaught error normally takes the desktop connection down before the throttled snapshot fires; Buoy pushes a crash entry out immediately while the connection is alive, tagged `[UNCAUGHT]` or `[RENDER ERROR]`, so the app's last words are readable from the dashboard and from an AI agent.
# Network Monitor
Source: https://buoy.gg/buoy/latest/docs/flutter/tools/network
Inspect requests that use the instrumented `dart:io` HttpClient path. Open a captured request to read its status, headers, body, timing, and error details.
The film and the demo show the React Native tool, and the demo uses mock data. Use the Flutter setup and feature descriptions below for supported behavior; the demo does not establish Flutter feature parity.
## Supported Clients
Everything riding `dart:io`'s `HttpClient` is captured automatically:
| Captured | Notes |
| --- | --- |
| `package:http` | default `IOClient` |
| dio | attributed as `dio` in the panel |
| `Image.network` / `NetworkImage` | Flutter's own image loading |
| `cached_network_image` | cache misses / revalidations |
| graphql_flutter / ferry | tag with `X-Request-Client: graphql` for operation names |
> **GraphQL gets special treatment** — Operation names are extracted from queries, mutations, and subscriptions, then displayed with variables using arrow notation: `GetUser › 123`. No more guessing which `/graphql` request is which.
---
## Installation
Using the [`buoy` umbrella](../installation)? It's already included — the Network Monitor self-registers when you wrap your app in `BuoyDevTools`. Standalone, add one call before `runApp`:
```dart
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:buoy_network/buoy_network.dart';
void main() {
if (kDebugMode) registerBuoyNetwork(); // installs the HTTP hook + registers the tool
runApp(const MyApp());
}
```
Capture starts when `registerBuoyNetwork()` installs the hook and account access permits capture. Requests made before registration or through clients created outside the hooked path can be absent. With the umbrella, registration occurs when its widget mounts.
Run the app in debug mode with `BuoyDevTools` mounted. Trigger a fresh HTTP request, open Network, and check its URL and status. If it is missing, check registration timing, account state, and the transport used.
## Network throttling
Choose Network throttling from Network’s menu. The inspector minimizes and a
floating strip appears above the app. Tap the signal bars to cycle through
No throttling, Slow (+500 ms), Very slow (+2000 ms), and Offline. The profile
applies to new requests through the captured HttpClient path. Slow profiles add
a delay; download speed stays unchanged. Offline fails requests before sending.
Drag the strip by its handle or surface. Tap the handle to tuck it against the
right edge; choose Network throttling again to reveal it. Closing the inspector
or pausing capture leaves the profile active. Close on the strip turns throttling
off. Restarting the app restores an open strip at its saved position, but resets
the profile to No throttling.
## What You See
For every request:
---
## Status Colors
---
## Stepping Between Requests
Open a request and the detail view gets a **Previous / Next** footer, so comparing two calls no longer means going back to the list and finding your place again.
It steps through exactly what the list was showing — the same filters, the same search. Narrow the list to failures, open one, and Next walks you through the failures only; the counter reads `REQUEST 4 OF 11`, not "4 of everything captured". Requests arriving while you read re-scope it live.
The list is newest-first, so **Previous** moves toward the newer request — the same direction as scrolling up.
---
## Override Responses
Use an override in a debug build to test how the app responds to a configured status, failure, or delay. After the test, disable the rule and repeat the request to confirm normal behavior.
Open any request and tap **Override** in the header. That takes you to the rule, prefilled from the request you were looking at — its endpoint, its method, its status, its real response body — so you're never starting from a blank field.
Pick an outcome from one grid: `500`, `401`, `404`, `403`, `429`, `503`, `400`, `200`, **Offline**, **Timeout**, **Real response**, or a custom status. Set a delay. Choose whether it fires always, once, N times, or **every other request** — that last one is how you test retry logic, because a rule that's always on or always off can't reach those paths.
**Transport coverage.** Overrides are applied at the same `HttpOverrides` layer as capture, so dio, `package:http` and raw `HttpClient` all see them. A forced failure arrives as a `SocketException`, which dio reports as `DioExceptionType.connectionError` — or `connectionTimeout` for a Timeout rule — exactly as a real network failure would.
**A delay behaves like a slow server, not a slow connection.** The wait is applied while the response is being received, so a 10s delay against a 5s `receiveTimeout` produces a receive timeout — the failure you were trying to reproduce.
**Rules survive a reload**, which is the point: force an endpoint to 500, restart, and watch what your boot path does.
**Automatic pause.** A body your app can't render would otherwise re-break it on every launch, with the controls to undo it locked inside an app that no longer draws. So if overrides sit armed and untouched across three launches, they pause themselves, with one tap to turn them back on.
Overridden requests pin to the top of the list under an **OVERRIDDEN** heading and carry a flask mark next to their status — a 500 you invented has to be distinguishable from a 500 your backend returned.
**Safety.** Overrides only run in debug builds, never touch Buoy's own licence traffic, and skip `OPTIONS` preflights. One master switch turns everything off without losing your rules.
Rules can also be driven from Buoy Desktop and from the `network_override` MCP tool — same rules, same device.
---
## Known Gaps
Documented and on the roadmap: `cupertino_http` / `cronet_http` native clients, gRPC (raw sockets), secondary isolates, and Flutter web.
---
## What's Next
- [Storage Explorer](./storage) — Browse and edit shared_preferences
- [Environment Inspector](./env) — Validate env vars with type checking
- [Riverpod Inspector](./riverpod) — Watch every provider's live value
---
## FAQ
### How do I inspect HTTP requests in a Flutter app without a proxy?
Add `buoy_network` (or the `buoy` umbrella) and call `registerBuoyNetwork()` — everything riding `dart:io`'s `HttpClient` is captured automatically, including `package:http`, dio, `Image.network`/`NetworkImage`, and `cached_network_image`. The panel opens on the device itself, so there is no proxy or desktop tool to attach.
### Does it capture dio and GraphQL requests?
Yes — dio traffic is captured and attributed as `dio`, and GraphQL operation names are extracted from queries, mutations, and subscriptions and shown with their variables (`GetUser › 123`). Tag graphql_flutter or ferry requests with `X-Request-Client: graphql` to get operation names.
### Are requests made during startup captured?
Requests made after hook installation can be captured. Register before the startup requests you need to inspect, and configure account access. Requests made before the umbrella widget mounts may precede its registration.
# Storage Explorer
Source: https://buoy.gg/buoy/latest/docs/flutter/tools/storage
Inspect shared_preferences values and supported registered backends. Edit a disposable test key, read it back through your app, and remove it after checking the integration.
The film and the demo show the React Native tool, and the demo uses mock data. Use the Flutter setup and feature descriptions below for supported behavior; the demo does not establish Flutter feature parity.
## Supported Backends
| Backend | Notes |
| --- | --- |
| `shared_preferences` | detected automatically, live browse + edit |
| Secure / MMKV backends | Require adapters implementing the package backend interfaces and explicit registration; they are not discovered from package installation alone |
---
## Installation
Using the [`buoy` umbrella](../installation)? It's already included. Standalone:
```dart
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:buoy_storage/buoy_storage.dart';
void main() {
if (kDebugMode) registerBuoyStorage();
runApp(const MyApp());
}
```
> **Live monitoring** — `shared_preferences` has no change stream, so Buoy watches writes made through the app *and* re-scans on an interval, so changes visible to the configured backend appear on a later scan. Polling can miss intermediate writes and does not provide an atomic cross-process history.
---
## What You Can Do
---
## What's Next
- [Network Monitor](./network) — Inspect supported HTTP requests
- [Environment Inspector](./env) — Validate env vars with type checking
- [Events Timeline](./events) — Storage writes alongside network and route events
---
## FAQ
### How do I view shared_preferences values in a Flutter app?
Add `buoy_storage` and call `registerBuoyStorage()` — every key-value pair is browsable and editable on the device, with an event stream of every write and a diff of what changed.
### Will it show writes made outside my own code?
`shared_preferences` has no change stream, so Buoy watches writes made through the app *and* re-scans on an interval — changes visible to the configured backend appear on a later scan. Polling can miss intermediate writes and does not provide an atomic cross-process history.
# Route Inspector
Source: https://buoy.gg/buoy/latest/docs/flutter/tools/routes
Inspect the routes and navigation events exposed by your registered go_router instance. Navigate between two screens, then check the from/to paths and parameters in the timeline.
The film and the demo show the React Native tool, and the demo uses mock data. Use the Flutter setup and feature descriptions below for supported behavior; the demo does not establish Flutter feature parity.
## Supported Libraries
Captures navigation from [`go_router`](https://pub.dev/packages/go_router) — the events timeline, route sitemap, and navigation-stack view, using the registered Flutter router.
---
## Installation
Add `BuoyRouteObserver.instance` to your router's `observers` and hand the router to `registerBuoyRoutes` so the sitemap and jump-to-route work:
```dart
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:buoy_routes/buoy_routes.dart';
import 'package:go_router/go_router.dart';
final _router = GoRouter(
observers: [BuoyRouteObserver.instance],
routes: [ /* your existing GoRoute definitions */ ],
);
void main() {
if (kDebugMode) registerBuoyRoutes(router: _router);
runApp(const MyApp());
}
```
---
Use this same router in `MaterialApp.router(routerConfig: _router)`. The snippet assumes your existing route definitions and `MyApp`; mount `BuoyDevTools` through the app builder as shown in [Installation](../installation). Route jumps still run your redirects and authorization checks.
## What You Can Do
---
## Event Timeline
Every navigation is tracked with:
- **Path** — Where you navigated to
- **Params** — Route parameters passed
- **Timestamp** — When it happened
- **Duration** — Time since previous navigation
Tap any event to open its **detail page** — the full route template, from/to paths, timing, segments, and params, all copyable, plus a **Go to route** action to jump straight there.
---
## What's Next
- [Network Monitor](./network) — Inspect supported HTTP requests
- [Storage Explorer](./storage) — Browse and edit shared_preferences
- [Events Timeline](./events) — Navigation alongside network and storage events
---
## FAQ
### How do I debug go_router navigation in Flutter?
Add `BuoyRouteObserver.instance` to your router's `observers` and pass the router to `registerBuoyRoutes` — you get the route sitemap, the live navigation stack, jump-to-any-screen, and a real-time stream of navigation events on the device.
### Which routing packages are supported?
`go_router` — the events timeline, route sitemap, and navigation-stack view, using the registered Flutter router.
# Perf Monitor
Source: https://buoy.gg/buoy/latest/docs/flutter/tools/perf-monitor
Watch performance on a real device. The Perf Monitor is a live on-device HUD showing FPS, jank, CPU, and memory while you use the app — streamed to the [Buoy Desktop](../../desktop) dashboard so you can watch it full-size while you drive the phone.
The film and the demo show the React Native tool, and the demo uses mock data. Use the Flutter setup and feature descriptions below for supported behavior; the demo does not establish Flutter feature parity.
## Installation
The monitor is implemented in Dart. Restart the app after adding the package or registration. Using the [`buoy` umbrella](../installation)? It's already registered. Standalone:
```dart
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:buoy_perf_monitor/buoy_perf_monitor.dart';
void main() {
if (kDebugMode) registerBuoyPerfMonitor();
runApp(const MyApp());
}
```
Open the **PERF** tool from the floating dial to see live metrics and toggle the HUD.
---
## What It Measures
Use a debug build with the widget mounted. These measurements include debug-mode overhead; do not present them as release-build performance. Repeat the same interaction when comparing changes.
- **FPS + jank** — from `SchedulerBinding.addTimingsCallback`: build (Dart UI thread) and raster (GPU thread) frame timings. FPS is activity-gated: an idle Flutter app renders no frames, so the HUD shows `—` at rest — because no active frame rate is available.
- **Memory (RSS)** — from `dart:io`'s `ProcessInfo.currentRss`, sampled continuously even while the UI is still.
- **CPU** — from `/proc/self/stat` on Android (no pure-Dart source exists on iOS yet).
---
## Coming Soon
**Benchmarking and batch reports** — recorded, comparable runs with ranked reports, like the React Native [Bench](../../tools/perf-monitor) tool — plus **Dart Top**, a "what's eating the thread" profiler. [Vote on the roadmap](https://buoy.gg/roadmap).
---
## What's Next
- [Buoy Desktop](../../desktop) — Watch the live HUD full-size on your desktop
- [Events Timeline](./events) — Performance in context with everything else
- [Network Monitor](./network) — Find the slow requests behind the jank
---
## FAQ
### How do I measure FPS and jank in a Flutter app on a real device?
Add `buoy_perf_monitor` and call `registerBuoyPerfMonitor()` — a live on-device HUD reports build (Dart UI thread) and raster (GPU thread) frame timings from `SchedulerBinding.addTimingsCallback`, plus memory (RSS) and, on Android, CPU. It streams to Buoy Desktop so you can watch it full-size while you drive the phone.
### Why does FPS show a dash when the app is idle?
FPS is activity-gated. An idle Flutter app renders no frames, so the HUD shows `—` at rest because there are no new frames to measure.
### Does it need native code?
No — it's pure Dart. No native libraries, no FFI, no platform channels, and no rebuild.
# Riverpod Inspector
Source: https://buoy.gg/buoy/latest/docs/flutter/tools/riverpod
Full Riverpod provider inspection for Flutter. Monitor provider state changes, explore value diffs, and browse live provider values in real-time — directly on your device. It's the same inspector UI as the React Native atom inspector, reading your Riverpod providers.
The demo uses React Native Jotai data. Flutter records providers observed by the scope you configure below; it does not discover every provider declared in the project.
## Installation
---
## Setup
Add the Buoy observer to your `ProviderScope` — no wrappers, no middleware, no modifications to existing providers:
```dart
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:buoy_riverpod/buoy_riverpod.dart';
void main() {
registerBuoyRiverpod(); // or use the `buoy` umbrella widget
runApp(
ProviderScope(
observers: const [buoyRiverpodObserver],
child: const MyApp(),
),
);
}
```
Use your existing `MyApp` with `BuoyDevTools` mounted in debug mode. Change an observed provider and confirm its value and event appear. Use provider APIs compatible with your installed Riverpod version.
> **Name your providers** (`StateProvider(..., name: 'counter')`) so the list reads well — unnamed providers fall back to their runtime type.
---
## Providers Tab
Browse all observed providers and their **live current value**:
- **Provider Name** — Color-coded for easy identification across both tabs
- **Value Type** — `number`, `boolean`, `object`, `array · N`, `null`, etc. at a glance
- **Live Value** — Tap any provider to expand and see the full value tree, updated in real-time
- **Change Count** — How many times this provider has changed this session
- **View History** — Jump straight to the filtered event history for a single provider
---
## Events Tab
Every state change is captured with rich metadata:
- **Provider Name** — Which provider changed, color-coded for quick identification
- **Value Transition** — `prev → next` at a glance (e.g. `0 → 5`, `null → {name, email}`)
- **Timestamp** — When the change occurred with relative time
Tap any event for the detail view: full value trees and a split-screen **diff** — additions in green, removals in red — so you see exactly what changed in complex state.
---
## What's Next
- [Events Timeline](./events) — Provider changes alongside network and route events
- [Network Monitor](./network) — Inspect supported HTTP requests
- [Storage Explorer](./storage) — Browse and edit persisted data
---
## FAQ
### How do I inspect Riverpod provider state on a device?
Add `buoy_riverpod`, call `registerBuoyRiverpod()`, and add `buoyRiverpodObserver` to your `ProviderScope` observers — every provider's live value, its change history, and before/after value diffs are browsable on the device. No wrappers, no middleware, no changes to existing providers.
### Why do some providers show a type instead of a name?
Unnamed providers fall back to their runtime type. Pass a name — `StateProvider(..., name: 'counter')` — so the list reads well.
# Images
Source: https://buoy.gg/buoy/latest/docs/flutter/tools/images
Inspect images rendered through `BuoyImage`, including their load timing, decoded dimensions, displayed size, and available cache information. Plain Image widgets are not captured automatically.
The film and the demo show the React Native tool, and the demo uses mock data. Use the Flutter setup and feature descriptions below for supported behavior; the demo does not establish Flutter feature parity.
## Installation
Flutter has no app-wide `Image` decorator hook, so capture is opt-in at the widget level — use `BuoyImage` in place of `Image` / `CachedNetworkImage`:
```dart
import 'package:flutter/widgets.dart';
import 'package:buoy_images/buoy_images.dart';
BuoyImage(
provider: const NetworkImage('https://example.com/image.png'),
width: 120,
height: 120,
)
```
Replace the example URL with an image your app can load. For a standalone install, call `registerBuoyImages()` before mounting the core widget; the umbrella registers it for you.
It wraps your `ImageProvider`, measures the rendered box for the oversize audit, and owns the props so reload/retry and simulations work.
---
## What You Can Do
- **Inspect captured image loads** — thumbnail, source URL, load time, decoded dimensions, status. Tap for the full detail view.
- **Inspect available cache information** — verdicts depend on the ImageProvider. A provider without disk-cache reporting cannot establish a disk-cache hit.
- **Catch oversized sources** — decoded pixels compared against the laid-out size × device pixel ratio, Lighthouse-style, with the estimated wasted decoded memory and the exact dimensions you should serve instead.
- **Catch upscaled (blurry) sources too** — a tiny thumbnail stretched into a large box gets flagged the other way.
- **Track decoded memory** — estimated decoded-bitmap bytes per image and totaled, so you catch ballooning before the OOM crash.
- **Diagnose failures** — every failure captured with the error message and the HTTP status where available.
- **Reproduce image bugs** — per-image hard reload / retry, plus simulation overrides: force error / loading / blank / URL swap, and app-wide offline / cold-start / blank-images modes.
## How it works
Flutter has no app-wide `Image` decorator hook like React Native. Capture is opt-in: wrap each load in `BuoyImage`, which instruments your `ImageProvider`, watches layout size for the oversize audit, and owns reload / retry / simulation props.
Buoy's own UI is excluded from capture — the tool never appears in its own registry.
## Coverage notes
- Only widgets wrapped in `BuoyImage` appear in the registry — plain `Image.network` / `CachedNetworkImage` without the wrapper stay invisible.
- Cache verdicts depend on the provider (memory / disk / network) as reported through the instrumented load path.
- Decoded-memory numbers are estimates (`width × height × 4` bytes) — the same math platforms use for RGBA bitmaps.
## Desktop & AI
The same live registry streams to [Buoy Desktop](../../desktop) — the full tool (list, detail, simulations, mass actions) on a big screen. And with the [MCP server](../../mcp), an agent can list every load with `get_images`, reload or retry with `image_action`, and flip failure simulations with `set_image_simulation`.
---
## What's Next
- [Network Monitor](./network) — The rest of your HTTP traffic
- [Image Overlay](./image-overlay) — Pin a mockup over the app for pixel-perfect UI
- [AI / MCP Server](../../mcp) — Let an agent audit a screen's images
---
## FAQ
### Why can't a network inspector tell me why a Flutter image is slow?
Image HTTP traffic in Flutter is fetched by `dart:io` image loaders and never surfaces the layout size or cache origin an inspector needs. Buoy's registry records each load's cache verdict (memory, disk, or network), its timing, decoded size versus displayed size, and the exact reason it failed.
### What do I have to change to capture image loads?
Flutter has no app-wide `Image` decorator hook, so capture is opt-in per widget: use `BuoyImage(provider: ...)` in place of `Image` or `CachedNetworkImage`. It wraps your `ImageProvider` and measures the rendered box for the oversize audit.
### How do I find images that waste memory?
Decoded pixels are compared against the laid-out size × device pixel ratio, Lighthouse-style, with the estimated wasted decoded bytes and the dimensions you should serve instead. Tiny sources stretched into a large box are flagged the other way, as upscaled.
# Impersonate Tool
Source: https://buoy.gg/buoy/latest/docs/flutter/tools/impersonate
Configure an authorized user-search API and attach the current impersonation headers to requests sent to your backend. Select a test user, verify the header in Network, then stop impersonation and confirm the header is removed.
Your backend must authenticate the operator and authorize the target user. Adding a header does not grant permission.
The film shows the React Native tool.
## Installation
Unlike other Buoy tools, Impersonate requires configuration because it needs to integrate with your user search API. Using the [`buoy` umbrella](../installation)? The dial entry is already registered — still call `registerBuoyImpersonate(...)` to wire search.
---
## Quick Start
```dart
import 'package:buoy_impersonate/buoy_impersonate.dart';
registerBuoyImpersonate(
onSearchUsers: (query) async => api.searchUsers(query), // List
);
```
The snippets assume your app defines `api.searchUsers` and a Dio instance named `dio`. Add the interceptor only to the client for your authorized backend, not to a shared client that also sends requests to third-party services:
```dart
import 'package:dio/dio.dart';
dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) {
options.headers.addAll(BuoyImpersonate.instance.impersonationHeaders);
handler.next(options);
}));
```
---
## How It Works
When impersonation is active, `impersonationHeaders` contains:
```
x-impersonate-user-id: user_123
```
Your backend checks for this header and returns data for the specified user instead of the authenticated admin. Your backend decides what the headers mean — Buoy just makes toggling them one tap.
> **Security Note:** Your backend should validate that the authenticated user has admin/impersonation permissions before honoring this header.
---
## Features
### User Search
Search for users by email, name, or ID. Results display in a clean card format showing user details and metadata. When viewing an active impersonation, the card shows a control to stop directly from the search results.
### Impersonation History
Quick-switch between recently impersonated users. History persists across app restarts. Each history entry shows when the user was last impersonated.
### Data Clearing
Pass optional clearing callbacks so switching users clears stale client state:
| Callback | Typical use |
|----------|-------------|
| `onClearReactQuery` | Invalidate Riverpod/query-style caches |
| `onClearRedux` | Reset a global store |
| `onClearAsyncStorage` | Clear `shared_preferences` (preserve Buoy keys yourself if needed) |
| `onClearMMKV` | Clear an MMKV / secure backend |
Unlike React Native, Flutter has no runtime auto-detection — “configured” means you passed the callback.
### Floating Banner
A floating banner appears when impersonation is active, to identify the selected user. Toggle it in Settings.
---
## Configuration
### Required: User Search
`onSearchUsers` should return `List`:
```dart
class ImpersonateUser {
final String id; // Sent in the impersonation header
final String? displayName; // Shown in UI (falls back to email, then id)
final String? email;
final String? avatarUrl;
final Map? metadata;
}
```
### Defaults
Customize the header key and related defaults:
```dart
registerBuoyImpersonate(
onSearchUsers: api.searchUsers,
defaults: const ImpersonateDefaults(
headerKey: 'x-admin-impersonate',
),
);
```
Read the live map anywhere with `BuoyImpersonate.instance.impersonationHeaders`.
---
## What's Next
- [Network Monitor](./network) — Inspect headers on captured requests
- [Storage Explorer](./storage) — Inspect the impersonated user's persisted state
- [Events Timeline](./events) — The whole flow in one stream
---
## FAQ
### How do I test a Flutter app as another user without logging out?
Register `buoy_impersonate` with your user-search callback, pick a user in the tool, and attach `BuoyImpersonate.instance.impersonationHeaders` in your HTTP client — a dio interceptor, for example. The state persists across launches and mirrors live to Buoy Desktop.
### Does impersonation bypass my authentication?
No. Dart has no global `fetch` to patch, so you decide where the headers go, and your backend decides what impersonation means and who is allowed to use it. Buoy is the switch, not the authority.
# Image Overlay
Source: https://buoy.gg/buoy/latest/docs/flutter/tools/image-overlay
Overlay design mockups directly on your running app to compare against your implementation. Load a mockup, match its scale and position to the screen, and adjust opacity to compare spacing and alignment.
The film and the demo show the React Native tool, and the demo uses mock data. Use the Flutter setup and feature descriptions below for supported behavior; the demo does not establish Flutter feature parity.
## Installation
Using the [`buoy` umbrella](../installation)? It's already registered. Standalone:
```dart
import 'package:buoy_image_overlay/buoy_image_overlay.dart';
registerBuoyImageOverlay();
```
---
## Marking Components as Targets
To make a widget discoverable in **Component Mode**, wrap it in `BuoyImageTarget`:
```dart
BuoyImageTarget(
label: 'LoginCard',
child: LoginCard(),
);
```
The `label` becomes the name shown in the target list. Add as many targets as you like throughout your app — headers, product cards, tab bars.
---
## Two Modes
### Component Mode
Tap **Component Match** to scan your app and see all tagged targets. Select one, and the overlay pins to that widget — measuring its exact position and size, with a dashed highlight. Optional **Auto Track** re-measures the target as it moves.
### Free Mode
Manually position and resize the overlay anywhere on screen. Drag to move, aspect-lock-resize to scale — useful when you want to compare a full-screen mockup or a section that doesn't map to a single widget.
---
## What You Can Do
- **Opacity control** — Blend the mockup over your live UI to spot differences
- **Scale & zoom** — Resize the overlay to match your layout
- **X/Y offset** — Fine-tune positioning for exact alignment
- **Flip** — Mirror the overlay horizontally or vertically
- **Lock** — Prevent accidental repositioning while comparing
- **Auto-track** — Remeasures the target so the overlay stays locked as it moves
---
## What's Next
- [Images](./images) — Every image load with cache verdicts and oversize audits
- [Environment Inspector](./env) — Validate env vars with type checking
- [Network Monitor](./network) — Inspect supported HTTP requests
---
## FAQ
### How do I compare a Flutter screen against a design mockup?
Register `buoy_image_overlay`, load your exported design into the tool, and overlay it on the running app — adjust opacity, scale, and position until the implementation matches, without leaving the simulator.
### Can the overlay follow a specific widget?
Yes — wrap the widget in `BuoyImageTarget(label: 'LoginCard')` and Component Mode pins the overlay to it, measuring its exact position and size. Auto Track re-measures the target as it moves.
# TV Remote
Source: https://buoy.gg/buoy/latest/docs/tools/tv-remote
Press the remote without picking one up. The TV Remote tool sends real D-pad, select, menu, media
and long presses — and typed text — to a connected TV app from Buoy Desktop, records what you
pressed as a named macro, and replays that macro against one device, or every mapped device at
once.
Record a short navigation sequence, replay it, and inspect each event result alongside the resulting focus and screen state. Receiving a key event does not prove that the app reached the intended screen.
## Installation
Complete [TV Quick Start](../tv/quick-start) first, including core, `@buoy-gg/external-sync`, your account key, and a working Desktop connection. Then add this tool.
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
export default function App() {
return (
<>
{/* your app */}
{/* TV apps run headless — no bubble */}
>
);
}
```
Then open **TV Remote** in [Buoy Desktop](../desktop), pair your device with an injection target,
and press away.
---
## How presses are delivered — and why it matters
**Buoy injects presses from your Mac, not from inside your app.** Buoy Desktop shells out to
`adb shell input keyevent` for Android TV and `idb ui key` for the Apple TV simulator. Both go
through the platform's real input pipeline, so the press travels the same focus engine a physical
remote drives.
The package you install in the app does the other half: it **observes**. It reports the remote
events your app actually received, which is what lets a replay tell "the app handled that press"
apart from "something swallowed it."
Nothing is faked in-process, and that is deliberate:
- On tvOS, an app can post React Native's internal key-event notification (the JS event fires, but
focus never moves) or call `requestTVFocus` (focus jumps, but no event fires and nothing is
traversed). Each is half of a press, and the missing half is the half QA cares about.
- On Android, directional focus navigation for unconsumed D-pad keys happens in `ViewRootImpl`,
*above* the Activity. An in-process `dispatchKeyEvent` would fire JS events **without moving
focus** exactly on the screens that are broken.
The package also renders nothing on screen. On Android TV any focusable view in an overlay becomes
a D-pad stop in your app's focus order — a visible tool would change the navigation it is meant to
be testing.
---
## What works where
| Target | Replay | How |
|---|---|---|
| Android TV emulator | ✅ everything | `adb -s shell input keyevent` |
| Android TV device | ✅ everything | the same, over `adb connect :5555` |
| Apple TV simulator | ✅ D-pad, select, menu, hold, Home, text — ❌ media keys | `idb ui key` / `ui button` / `ui text` |
| Apple TV device | ❌ **record only** | no supported host-side injection exists |
### What you can send
| | Apple TV sim | Android TV | Echoed back by the app? |
|---|---|---|---|
| D-pad, Select, Menu | ✅ | ✅ | yes (Menu: only with menu capture on) |
| **Hold** (long press) | ✅ | ✅ | yes — as `longSelect`, `longLeft`, … |
| **Type text** | ✅ | ✅ | **no** — see below |
| Play/pause, rewind, fast-forward, next, previous | ❌ | ✅ | yes, on Android |
| Home / TV | ✅ | ✅ | no — it backgrounds the app |
| Swipe / pan (touch surface) | ❌ | ❌ | — |
| Voice / Siri | ❌ | ❌ | — |
Replay limits:
- **No media transport keys on an Apple TV simulator.** `idb ui key` speaks the HID *keyboard*
page, which has no usages for play/pause, rewind, fast-forward, next or previous, and
`idb ui button` offers only Apple Pay, Home, Lock, Side button and Siri. Those steps are reported
`skipped-unsupported` on that target — flagged, never silently passed. They work on Android.
- **A retail Apple TV cannot be driven at all.** Apple's only supported way to press buttons on
physical hardware programmatically is an XCUITest runner paired to the device, which is far too
heavy to live inside Buoy Desktop.
- **Swipes and pans can be recorded but never replayed.** There is no touch surface on a simulated
Apple TV (`idb ui swipe` errors out and takes the app with it), and Android TV has no
touchscreen. So `swipeUp`/`pan` events reach the capture stream from a physical remote, but no
macro step can reproduce them.
- **Voice / Siri is out of reach, and would not help.** Siri is a system service: the audio goes to
Apple and your app never sees it, so there is nothing to inject and nothing to assert. What your
app actually observes is the resulting *text* — which is what `Type text` sends.
**Recording works everywhere, including retail hardware**, because capture is pure JS. That gives
you the *record-on-retail* workflow: a tester presses the physical remote on a rack device, the
macro is recorded from the app's own event stream, and replay then runs against emulators and
simulators.
### Requirements on your Mac
- **Android**: `adb` — part of the Android SDK platform-tools.
- **Apple TV simulator**: `idb`, which does **not** ship with Xcode:
```bash
brew tap facebook/fb && brew install idb-companion
pipx install fb-idb
```
Without it the Apple TV lane is disabled and the panel says so.
Buoy looks for both on your `PATH` and in the usual install locations, so a packaged desktop app
finds them even though a GUI app inherits a minimal `PATH`.
---
## Pairing
The panel lists every booted target it finds and suggests one that matches your device's platform.
Pairing is **manual and remembered** per device. Buoy will not probe for a pairing by injecting keys
into devices you didn't ask it to touch.
---
## Typing, and holding
**Type text** sends a string to whatever field currently has focus
(`idb ui text` / `adb shell input text`). This is the practical stand-in for the Siri remote's
dictation, and it saves pecking out a search query on a D-pad keyboard grid one letter at a time.
Typed text uses a different input path: text travels the platform's **keyboard** path, so it reaches the
native text field without ever touching the app's TV event pipe. Verified: an app with capture
armed reports nothing at all for injected text. A text step is therefore **fire-and-wait** — the
tool can prove it was sent, not that it was received. (Menu and Home are fire-and-wait for the same
kind of reason.)
**Hold** turns the next press into a long press — the same keycode with a duration flag
(`--longpress` / `--duration`). React Native reports it under its own event name, so a held Select
comes back as `longSelect` and *is* echo-confirmed like any other press. Holding is meaningful for
the D-pad, Select and Play/Pause; the fork emits no long variant for Menu.
## Macros
A macro is a list of raw presses with the timing you recorded:
```json
{
"version": 1,
"name": "search-for-arrow",
"createdAt": "2026-08-25T14:02:11.000Z",
"steps": [
{ "key": "right", "delayMs": 420 },
{ "key": "select", "delayMs": 800 },
{ "key": "select", "hold": true, "delayMs": 600 },
{ "kind": "text", "text": "arrow", "delayMs": 500 }
]
}
```
A step is either a key press (`key`, optionally `hold`) or typed text (`kind: "text"`). A step with
no `kind` has always meant a key press, so macros recorded before text steps existed still replay.
Record one two ways:
- **From the D-pad** — every button you press in the panel is injected *and* recorded. No capture
gaps, because the desktop knows exactly what it sent.
- **From the real remote** — a human presses the physical remote and the macro is built from the
app's capture stream. This is the retail-device path.
Macros export and import as JSON, so they can live in the repo next to the app under test.
### Replay
Each step is injected, then Buoy waits for the app to echo the event back before pacing the recorded
delay and moving on. Recorded delays are clamped to 150ms–5s and scaled by the speed multiplier, so
a long pause while you were thinking doesn't become a long pause in CI.
Every step reports one of:
| Outcome | Means |
|---|---|
| **ok** | Injected, and the app reported receiving it (with the round-trip time). |
| **no-echo** | Injected, but no matching app event was observed in time. Check native focus, event capture, connection state, and a blocked JS thread. |
| **skipped-unsupported** | This target has no way to send that key (play/pause on a tvOS simulator). |
| **inject-failed** | The injector itself failed — target offline, adb unauthorized, idb missing. |
Menu is expected to produce no echo: on Android the back key goes to `BackHandler` rather than the
TV event pipe, and on tvOS the Menu key is handled natively. Turn on **Capture the Menu key** while
recording on Apple TV to route it to JS instead — the panel warns you that your app won't go back or
background while that is on, and it is always handed back when recording stops.
### Fleet replay
**Run on N devices** replays one macro against every paired device at once, one injection lane each,
and lays the outcomes out as a device × step matrix with a final screenshot per device for eyeball
diffing. Partial fleets are normal: an Apple TV simulator skipping a play/pause step, or one device
being offline, marks that cell and never blocks the rest.
---
## What's Next
- [Buoy Desktop](../desktop) — where the TV Remote panel lives
- [Route Inspector](./routes) — see which screen a replay landed on
- [Highlight Updates](./highlight-updates) — what re-rendered while focus moved
---
## FAQ
### Can I test my app on a real Apple TV with this?
You can **record** on one — capture runs in your app and works on any hardware. You cannot replay
against one in this version; there is no supported host-side way to inject remote presses into a
retail Apple TV.
### Why doesn't the tool show anything on the TV screen?
On purpose. On Android TV a focusable view in an overlay joins your app's D-pad focus order, so an
on-screen tool would alter the navigation it is supposed to be testing.
### My macro reports `no-echo` on a video screen.
That is the tool doing its job. Native players (ExoPlayer, AVPlayer) take focus and eat D-pad and
play/pause input while video is mounted. A fixed-delay replay would have marched on and reported a
pass.
### Can I use the Siri button / voice search?
No. `idb` does expose a `SIRI` button, but the simulator has no Siri, and on real hardware Siri is a
system service — the audio goes to Apple and your app never receives it. There is nothing to inject
and nothing for the app to echo. Use **Type text** instead: the text is the part your app actually
sees.
### Does this need a native module?
No. `@buoy-gg/tv-remote` is JavaScript. Other Buoy tools may have native dependencies. The shelling out happens in
the desktop app.
## Web support (unreleased)
Browser capture observes keyboard events without consuming them. It does not reproduce a TV’s native focus behavior. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.
# Focus Inspector
Source: https://buoy.gg/buoy/latest/docs/tools/focus-inspector
Inspect observed focus transitions, the current focus target, and the scanned focusable elements in a React Native TV app. Flags identify patterns worth testing, such as repeated unsuccessful moves or an element that has not been reached.
Treat flags as diagnostic evidence. Confirm a suspected bug with the same remote sequence and your intended focus behavior.
## Installation
Complete [TV Quick Start](../tv/quick-start) first, including core, `@buoy-gg/external-sync`, your account key, and a working Desktop connection. Then add this tool.
```tsx
import { FloatingDevTools } from "@buoy-gg/core";
export default function App() {
return (
<>
{/* your app */}
{/* TV apps run headless — no bubble */}
>
);
}
```
Then open **Focus** in [Buoy Desktop](../desktop) and drive your app with the remote — or with
the [TV Remote](./tv-remote) tool, which presses it for you.
Requires `react-native-tvos` and the New Architecture. On a phone build the tool reports "not a
TV build" and attaches nothing.
---
## What you see
### Now
The element that currently holds focus: its `testID`, component name, native tag, how much of the
screen it occupies, and how long it has been focused.
Two warnings live here, and they are the ones worth knowing about:
- **Focus is parked on a `TVFocusGuideView`.** A guide with `destinations` is a genuine focus
stop with no highlight. To whoever is holding the remote, focus just disappeared.
- **Focus was lost.** A view blurred and nothing took focus after it — focus left the React
Native tree entirely (a system dialog, a native view, an unmounted subtree). This is reported
only when the tool *watched it happen*; "we have not seen focus yet" is a separate, calmer
message that never claims anything is wrong.
### Observed exits
A D-pad compass showing where each direction has been *seen* to lead from the focused element.
A direction not yet pressed from the current element remains unknown. Observed exits describe transitions that occurred; they do not predict untested paths through guides, overrides, or traps.
### Transitions
Every focus move, newest first: the direction credited to it, where focus came from, where it
went, and how long it had been sitting there. A move to `LOST` is focus leaving the tree.
### Focusables
Everything the focus engine could land on, in reading order — including elements below a
ScrollView's fold and the invisible guide stops. Each row shows whether focus has ever reached
it, the props that make it focusable (`hasTVPreferredFocus`, `nextFocus*`, guide settings), and a
**Focus this** button.
That button is the tool's most useful interaction: put focus somewhere, then press directions and
watch what actually happens from there. It is also the only thing this tool ever writes.
---
## The flags
| Flag | What it means |
|---|---|
| **Dead end** | You pressed one direction three or more times in a row from the same element and focus never moved — and there *is* something focusable further along that way. |
| **Focus trap** | A region focus was never observed escaping in any direction where something focusable exists outside it. Usually a `TVFocusGuideView` and everything inside it. |
| **Invisible stop** | Focus landed on a guide, or on an element the scan has never seen. This is what "the highlight vanished" looks like. |
| **Focus lost** | A view blurred and nothing took focus afterwards. |
| **Suspected unreachable** | Never focused, nothing observed pointing at it, and a visited element sits right next to it. |
Two things the flags will not do.
They will not call anything **unreachable**. Never having reached an element is evidence, not
proof — tvOS's focus engine is non-deterministic enough that certainty is not available without
native support that does not exist. So the strong word is reserved for nothing, and the panel
tells you when you have not traversed enough for absence to mean anything yet.
They will not blame the screen edge. If nothing focusable exists past an element in that
direction, focus refusing to move is the screen ending, not a bug — that gets counted and set
aside, not flagged.
Flags belong to the screen the current inventory describes. Navigate away and they reset with the
rescan, rather than turning every element of the old screen into a mystery.
---
## Platform differences worth knowing
`TVFocusGuideView` and `trapFocus*` are documented for both platforms in [react-native-tvos](https://github.com/react-native-tvos/react-native-tvos#tvfocusguideview). A trapped region may be intentional on Android TV as well as tvOS. Compare the flag with your props, framework version, and observed navigation before classifying it as a defect.
**Focus and blur arrive in different orders.** tvOS delivers the new element's focus first and
the old element's blur second; Android does the opposite. The tool handles both, to distinguish transitions from focus loss.
`Platform.isTVOS` is `undefined` on Android TV, not `false` — a detail that has cost more than
one team an afternoon.
---
## What it costs your app
Two event listeners, always, and the expensive part only on demand.
The focus listeners start with the app rather than when you open the panel, because focus cannot
be *asked about* on either platform — it can only be watched arriving. A tool that started
listening when you opened it would know nothing until focus next moved, and would be blind in the
exact situation you opened it for: focus is stuck, you press a direction, nothing moves, so
nothing is learned. What that costs is two event-emitter subscriptions and a bounded ring-buffer
append per focus change.
The fiber scan — the part with real cost — only runs while Buoy Desktop is actually watching. Use
**Pause recording** in the panel to stop the rest.
The tool renders **nothing** on the device, and never will. On Android TV any focusable view in
an overlay becomes a D-pad stop in your app's own focus order — a visible focus tool would change
the thing it is measuring. Buoy Desktop is the surface.
There is no native code in this package: no podspec, no Gradle, nothing to link. The inventory is
a read-only walk of the React tree, and the same key sequence produces the same focus path with
the tool running and with it removed.
---
## Pairs well with
- **[TV Remote](./tv-remote)** — press the D-pad from your desktop and record the sequence as a
macro. Replay a macro while the Focus Inspector records, and you have a repeatable focus
regression test.
- **[Routes](./routes)** — when focus "vanishes" on navigation, the route timeline usually
says why.
## Web support (unreleased)
Browser capture measures DOM focusable elements and observes real focus and keyboard events. It uses the existing sync adapter for remote inspection. The browser build is available in this checkout and has not been published yet. See the [web setup guide](../web-preview.md) for registration, dependencies, and browser boundaries.