Documentation
Mobile
Web
TV

All but React Native are in beta.

Desktop App
docs

Custom Tools

Add your own widget to Buoy with BuoyTool. The basic example shows a counter. Use it to check that the tool opens and works. First, finish Installation, with sign-in and the debug-only widget.

Basic Custom Tool

dart
import 'package:buoy_core/buoy_core.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(MaterialApp(
    builder: (context, child) => BuoyDevTools(
      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<CounterDebugger> createState() => _CounterDebuggerState();
}

class _CounterDebuggerState extends State<CounterDebugger> {
  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'),
            ),
          ],
        ),
      ),
    );
  }
}
import 'package:buoy_core/buoy_core.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(MaterialApp(
    builder: (context, child) => BuoyDevTools(
      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<CounterDebugger> createState() => _CounterDebuggerState();
}

class _CounterDebuggerState extends State<CounterDebugger> {
  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 it with flutter run --dart-define-from-file=.env.local. Open Counter. Tap Increment, then tap Reset to go back to zero. The later sections are 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());
}
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

FieldTypeNotes
idStringStable id (dedupes with first-party tools)
nameStringDial label (rendered uppercase)
colorColorAccent on the dial
icon(size, color) → WidgetPrefer a Buoy icon or Material glyph
descriptionString?Shown in settings tool cards
screenBuilderWidgetBuilder?Full-screen tool host
modalBuilderBuoyToolModalBuilder?Draggable/resizable modal (wins over screen)
onPressedvoid 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(),
)
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'),
        ),
      ],
    );
  }
}
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