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:
[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:
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:
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:
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:
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:
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.
dependencies {
implementation(libs.snapo.tool.core)
}dependencies {
implementation("com.openai.snapo:tool-core:13.1.0")
}The library comes from Maven Central. Most Android projects already include mavenCentral() in their dependency repositories.
This example returns a JSON message:
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:
[versions]
androidx-startup = "1.2.0"
[libraries]
androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" }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:
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:
<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:
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:
<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:
<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:
# Run from your Android project root
./gradlew :your-tool:initSnapoToolFrontendThis 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:
./gradlew :your-tool:installSnapoToolDependenciesThis 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:
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:
./gradlew :your-tool:devSnapoToolFrontendWith 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:
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:
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:
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:
post("/message") {
val message = request.bodyText()
respondText(message)
}Call it from a frontend event handler while Android is connected:
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:
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:
await navigator.clipboard.writeText("Hello, world!");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:
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.