Snap-O

Build a tool

Build a debugging tool for your Android app and use it from Snap-O on your Mac.

What you’ll build

A tool that displays Hello, world! from your Android app in Snap-O’s Tool pane. You’ll need an Android app and an Android library module for your tool. Manual frontend setup also requires Node.js and npm. Merge the snippets below into your existing Gradle files and version catalog.

Your tool has two parts: an Android server and a web frontend. The frontend calls your server over HTTP. Snap-O connects them over ADB, and the Gradle plugin bundles the frontend into your APK.

For a working starting point, use the Example tool.

Set up the tool

Apply the Tool Packager Gradle Plugin in your tool’s Android library module. It builds the frontend and generates the metadata Snap-O needs to find your tool.

Add dependencies to your version catalog:

gradle/libs.versions.toml
[versions]
snapo = "13.1.0"

[plugins]
snapo-tool-packager = { id = "com.openai.snapo.tool-packager", version.ref = "snapo" }

[libraries]
snapo-tool-core = { module = "com.openai.snapo:tool-core", version.ref = "snapo" }

Make sure pluginManagement.repositories in settings.gradle.kts includes mavenCentral().

Configure your tool

Apply the Tool Packager Gradle Plugin to your tool’s Android library module and set its identity:

Your tool module's build.gradle.kts
plugins {
    alias(libs.plugins.snapo.tool.packager)
}

snapoTool {
    id = "your-tool"
    displayName = "Your tool"
    icon = "@drawable/tool_icon"
}

Use a stable, unique id starting with a lowercase letter and containing only lowercase letters, digits, dots, or hyphens. Set icon to an existing drawable or mipmap resource. Use a single-color icon with a transparent background.

The plugin expects a web project in your module’s frontend/ directory. You’ll create it in step 3.

Custom builds and Node setup

Gradle downloads Node and npm automatically. To use a different frontend directory, set it in your tool module:

Your tool module's build.gradle.kts
snapoTool {
    frontendDirectory = layout.projectDirectory.dir("web")
}

To package files built elsewhere, set frontendAssets to their directory instead. It must contain index.html and use relative asset URLs. This skips the default npm build:

Package prebuilt files
snapoTool {
    frontendAssets = layout.projectDirectory.dir("prebuilt-frontend")
}

Use node { version.set("24.21.0") } to select another Node version, or node { download.set(false) } to use Node and npm on Gradle’s PATH.

If your build uses FAIL_ON_PROJECT_REPOS or PREFER_SETTINGS, add the Node download repository alongside your existing repositories:

settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        ivy {
            name = "Node.js"
            url = uri("https://nodejs.org/dist/")
            patternLayout { artifact("v[revision]/[artifact](-v[revision]-[classifier]).[ext]") }
            metadataSources { artifact() }
            content { includeModule("org.nodejs", "node") }
        }
    }
}

Then disable the plugin’s automatic repository in each tool module:

Your tool module's build.gradle.kts
node { distBaseUrl.set(null as String?) }

Serve HTTP on Android

Add tool-core to your tool library. Its ToolServer handles the connection and HTTP; you provide the routes.

Your Android module's build.gradle.kts
dependencies {
    implementation(libs.snapo.tool.core)
}

The library comes from Maven Central. Most Android projects already include mavenCentral() in their dependency repositories.

This example returns a JSON message:

Handle an example request
import com.openai.snapo.tool.ToolServer

fun createToolServer(toolId: String) = ToolServer(toolId) {
    get("/example") {
        respondJson("""{"message":"Hello, world!"}""")
    }
}

respondJson accepts a JSON string. Use your app's serializer to encode objects.

See Actions and request bodies for more HTTP examples.

Start the server with your app

You can use AndroidX Startup to start the server when your app launches. Add the initializer and manifest entry below to your tool library.

Add the AndroidX Startup dependency to your tool library:

gradle/libs.versions.toml
[versions]
androidx-startup = "1.2.0"

[libraries]
androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" }
Your tool module's build.gradle.kts
dependencies {
    implementation(libs.androidx.startup)
}

Create an initializer using createToolServer from the example above. Put both in your module's namespace; com.example.tool below is an example:

ExampleToolInitializer.kt
package com.example.tool

import android.content.Context
import androidx.startup.Initializer
import com.openai.snapo.tool.ToolServer

class ExampleToolInitializer : Initializer<ToolServer> {
    override fun create(context: Context): ToolServer =
        createToolServer(SnapOTool.ID).apply { startIfAllowed(context) }

    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

The Gradle plugin generates SnapOTool.ID in your module’s namespace. startIfAllowed starts the server in debuggable apps and logs socket startup failures.

Register the initializer under AndroidX Startup's shared provider in your tool library's manifest. Set android:name on the metadata entry to your initializer's full class name:

Your tool library's AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application>
        <provider
            android:name="androidx.startup.InitializationProvider"
            android:authorities="${applicationId}.androidx-startup"
            android:exported="false"
            tools:node="merge">
            <meta-data
                android:name="com.example.tool.ExampleToolInitializer"
                android:value="androidx.startup" />
        </provider>
    </application>
</manifest>

Add the tool library to your app's debug build, replacing :your-tool with your library module's path:

app/build.gradle.kts
dependencies {
    debugImplementation(project(":your-tool"))
}

Keep the tool in debug builds.

Custom startup and release builds

You can call startIfAllowed(context) from your own initialization code instead of AndroidX Startup. Keep the server for your tool’s lifetime and call close() when that lifetime ends.

To inspect a release build, include the tool library in that variant and add this metadata inside the app’s <application> element. Replace your-tool with your tool ID:

App manifest release opt-in
<meta-data
    android:name="snapo.your-tool.allow_release"
    android:value="true" />

Set the app's tool order

Add this optional setting inside your app's <application> element:

App manifest tool order
<meta-data
    android:name="snapo.tool_order"
    android:value="network,tweaks" />

Listed tools appear first, in the order you specify. Other tools follow alphabetically by tool ID. For example, this setting produces Network, Tweaks, Analytics, then Logs when all four tools are available.

Use tool IDs, not display names. Spaces around IDs are allowed; duplicates and invalid IDs are ignored. Unavailable tools are skipped. Without this setting, tools appear alphabetically by ID. Snap-O still remembers your selected tool.

The app owns this setting. Individual tool libraries do not need changes.

Build the web frontend

Create your UI

The plugin expects your web project in frontend/, beside your tool module’s build.gradle.kts. Create it with Gradle:

Create the frontend
# Run from your Android project root
./gradlew :your-tool:initSnapoToolFrontend

This creates a Preact and TypeScript starter with the host SDK and installs its dependencies. Gradle manages Node/npm automatically. It uses frontendDirectory if configured and stops if the directory already contains files.

Commit the generated source files, package.json, and package-lock.json. Subsequent Android builds and devSnapoToolFrontend runs prepare the SDK and use npm ci to install the locked dependencies. A missing or mismatched lockfile fails the build. For an existing web project, see frontend configuration.

After editing dependencies in package.json, run:

Update frontend dependencies
./gradlew :your-tool:installSnapoToolDependencies

This uses managed npm install to update the lockfile. Review and commit the dependency changes, then build normally. Use the same task to finish a failed initialization after resolving the error; existing frontend sources stay intact.

The Android build packages the frontend automatically. Include scripts, images, and fonts in the bundle; the packaged page cannot load remote scripts or call unrelated servers.

Preact is optional. We recommend Preact because its lightweight runtime helps keep the frontend bundled in your APK small. You can use plain JavaScript or another framework that builds bundled web assets with an index.html.

Connect to the Snap-O Mac app

The host SDK tells your frontend when Android is connected. Call Android routes with /api/ in front: /api/example reaches your server’s /example route. The generated src/main.tsx contains this example. For an existing frontend, adapt the code below and keep your CSS imports:

frontend/src/main.tsx
import { render } from "preact";
import { useEffect, useState } from "preact/hooks";
import { host } from "@snap-o/tool-host";

function App() {
  const [message, setMessage] = useState("Disconnected");

  useEffect(() => host.onError(error => {
    setMessage(`Could not connect to Snap-O: ${error.message}`);
  }), []);

  useEffect(() => host.onConnection(async connection => {
    setMessage(connection ? "Connecting…" : "Disconnected");
    if (!connection) return;
    try {
      const response = await fetch("/api/example");
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const data = await response.json();
      setMessage(data.message);
    } catch (error) {
      setMessage(`Request failed: ${String(error)}`);
    }
  }), []);

  return <output>{message}</output>;
}

render(<App />, document.getElementById("app")!);

If startup fails, open the tool in Snap-O and reload it.

host.onConnection runs when the Android connection changes. host.onError reports SDK initialization failures. Both effects unsubscribe when the component unmounts.

Use browser storage only for disposable UI preferences. Stored data can survive app reinstalls; don’t persist credentials, captured traffic, or personal data.

Try your tool in Snap-O

Build and run your Android app with the tool library included, using your usual workflow. Its build includes the frontend automatically.

In Snap-O, select your device, app, and tool. The Tool pane should display Hello, world!.

If the tool is missing, check that you installed a debug build containing the tool library. If the page shows a request error, check Logcat for server startup failures and confirm the /example route matches the example.

Frontend hot reload

Use a development server to edit the UI without rebuilding the Android app for each frontend change. From your project root, run the tool module's devSnapoToolFrontend task:

Start the frontend development server
./gradlew :your-tool:devSnapoToolFrontend

With your tool selected in Snap-O, choose Develop → Use Development Server and enter the local URL printed by Vite. Keep the Android app running. Add this server option to your Vite config so frontend changes reload correctly:

vite.config.ts
server: {
  host: "127.0.0.1",
  port: 5173,
  strictPort: true,
  hmr: { host: "127.0.0.1", clientPort: 5173 },
}

Use the same port for port and hmr.clientPort. Requests to /api/... still go to Android.

The devSnapoToolFrontend task installs dependencies and runs the frontend's npm dev script with the managed Node runtime. You can also run npm run dev from the frontend directory with a local Node installation. In a Debug build of Snap-O, choose Develop → Show Web Inspector to inspect the page in a separate window.

Choose Develop → Use Default to return to the version bundled in the APK. Rebuild and reinstall through your usual Android workflow to update that version.

For a complete tool implementation, see the Example project.

Add more features

Once the example works, add the features your tool needs. Frontend snippets below use host from @snap-o/tool-host. The SDK initializes automatically.

Live streaming with SSE

Use server-sent events (SSE) to send updates without polling. This route sends a message every second. Add it inside your ToolServer block:

Android route
sse("/events") {
    while (true) {
        send(data = "Hello from Android", event = "message")
        kotlinx.coroutines.delay(1_000)
    }
}

In App, replace the request effect with a stream subscription:

Inside App
useEffect(() => host.onConnection(connection => {
  setMessage(connection ? "Connecting…" : "Disconnected");
  if (!connection) return;
  const events = new EventSource("/api/events");
  events.onmessage = event => setMessage(event.data);
  return () => events.close();
}), []);

Replace the timer with your app’s event source. The cleanup closes the stream when the connection changes or the component unmounts.

Actions and request bodies

Use post, put, patch, or delete for actions. This route receives text and returns it as a response:

Android route
post("/message") {
    val message = request.bodyText()
    respondText(message)
}

Call it from a frontend event handler while Android is connected:

Send an action
const connection = host.connection;
if (!connection) throw new Error("Android is disconnected");

const response = await fetch("/api/message", {
  method: "POST",
  headers: { "Content-Type": "text/plain" },
  body: "Hello from the frontend",
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const message = await response.text();

For JSON bodies, use JSON.stringify and Content-Type: application/json, then parse the body with your Android serializer. Use respondNoContent() when an action has no response body.

Routes can include parameters, such as /items/{id}. Read them with pathParameters.getValue("id"); read query values with request.queryParameters["filter"].

Toolbar buttons and search

Use host.setToolbar to add controls to Snap-O’s native toolbar. Connect the callbacks to your UI’s state and actions:

Configure the toolbar
await host.setToolbar({
  actions: [
    { id: "clear", icon: "clear", label: "Clear", onClick: clear },
  ],
  search: { label: "Search", value: query, onChange: setQuery },
});

Here, clear, query, and setQuery come from your component. Each call replaces the toolbar. The main area fits three controls, including search; use endActions for trailing buttons. Set enabled: false to disable a button, and call host.setToolbar({}) when removing the UI.

Copy text and save files

Call these APIs from your frontend’s button handlers:

Copy text
await navigator.clipboard.writeText("Hello, world!");
Open a save dialog
const saved = await host.saveFile({
  name: "message.txt",
  data: new Blob(["Hello, world!"], { type: "text/plain" }),
});

saveFile returns false if the user cancels the dialog.

Pick a color

Open Snap-O’s native color picker from a button handler. Colors use hexadecimal RGBA values:

Open the color picker
const picker = await host.openColorPicker({
  value: "#3366FFFF",
  onChange: color => console.log(color),
});

Replace console.log with your color update handler. Use picker.setValue(color) to update the picker or picker.close() to dismiss it.