A background service for executing and managing long-running developer tasks. DevDaemon runs as a local HTTP server, scheduling and controlling jobs via cron or on-demand HTTP requests.
DevDaemon ships as a library (core) that you embed in your own project. A DaemonPlugin SPI lets you wire in company-specific services (feature flags, analytics, roles, VPN checks) while sensible defaults keep it working out of the box.
- Blog post: Rethinking Idle Time: Developer Productivity Beyond the Keyboard
- Conference talk (DPE Summit 2025): MDX Daemon - Turning Idle Time into Developer Productivity
- On-demand job execution — trigger any registered job via
POST /execute/{job-id} - Job cancellation — cancel a running job via
POST /cancel/{job-id} - Cron scheduling — jobs can declare a Quartz cron expression to run periodically
- Plugin architecture — implement
DaemonPluginand register it viaServiceLoaderto override feature flags, analytics, roles, and more - System sleep awareness — missed jobs are automatically rescheduled after the machine wakes
- Lock files — prevents duplicate concurrent runs of the same job
core/ # The DevDaemon library (published to Maven Central)
default-configs/ # Built-in job configuration JSON files
default-jobs/ # Built-in job scripts
src/ # Library source code
sample-dev-daemon/ # A minimal sample project that depends on core
sample-configs/ # Sample job configuration JSON files
sample-jobs/ # Sample job scripts
src/ # Sample application source code
- JDK 17+
- Gradle (wrapper included)
./gradlew build./gradlew :sample-dev-daemon:runThe sample daemon starts on port 2001 by default (configurable via the SAMPLE_DEV_DAEMON_PORT environment variable).
The sample-dev-daemon module serves as a reference implementation - use it as a starting point for your own daemon project.
The sample daemon has two jobs defined (test.json and test-cron.json). Both run the same test_script.sh which runs for 10 seconds simulating a long-running asynchronous task.
test-cron- this job is scheduled on daemon start and runs automatically every 60 seconds, as defined by its JSON configcronScheduletestcan be executed manually with the following command in terminalcurl -X POST "http://localhost:2001/execute/test?source=OnManual"
While the sample daemon test job is running, you can cancel it with curl -X POST "http://localhost:2001/cancel/test?source=OnManual"
The core library is published to Maven Central: https://central.sonatype.com/artifact/xyz.block.devdaemon/core
You can also use sample-dev-daemon as a copy-ready template for your own daemon integration.
Add the core module as a dependency:
dependencies {
implementation("xyz.block.devdaemon:core:<version>")
}Then call startDevDaemon() from your application's entry point:
import com.squareup.dev.daemon.startDevDaemon
fun main() {
startDevDaemon()
}Without a custom plugin, DevDaemon will start with default (no-op) implementations for all services and listen on port 2964 (configurable via DEV_DAEMON_PORT).
Implement the DaemonPlugin interface to customize behavior:
class MyDaemonPlugin : DaemonPlugin {
override fun portEnvVar(): String = "MY_DAEMON_PORT"
override fun defaultPort(): Int = 3000
override fun modules(): List<Module> = listOf(
genericModule {
single<FeatureFlagService> { MyFeatureFlagService() }
single<AnalyticsService> { MyAnalyticsService() }
}
)
}Register it via the standard ServiceLoader mechanism by creating:
src/main/resources/META-INF/services/com.squareup.dev.daemon.DaemonPlugin
with your fully-qualified class name as the file's content.
| Service | Description |
|---|---|
FeatureFlagService |
Provides job definitions and listens for configuration changes |
AnalyticsService |
Reports job execution events |
RolesService |
Determines which roles the current user has |
VpnCheckService |
Checks VPN connectivity before job execution |
JobStatusRegistry |
Tracks and reports job status |
In the sample daemon, jobs are defined as local JSON files in sample-dev-daemon/sample-configs/. In production, it is best to decouple job configurations from the daemon and host them in a remote feature flag provider (e.g., LaunchDarkly) which serves JSON feature flags to a custom FeatureFlagService implementation.
Decoupling the job configurations from the daemon allows adding new job configurations and making changes (e.g. updating cron schedule, enable/disable job, etc) to how a job is executed on-the-fly (via FeatureFlagService.registerFeatureFlagChangeListener), without a new daemon release.
Each file describes a single job:
{
"cronSchedule": null,
"enabled": "true",
"executeIn": "",
"id": "my-job",
"job": "my_script.sh",
"jobDirectory": "path/to/jobs",
"registryRoles": ["dev"]
}| Field | Description |
|---|---|
id |
Unique identifier for the job |
enabled |
Whether the job is active |
job |
Script or executable to run |
jobDirectory |
Directory containing the job script |
executeIn |
Working directory for execution. Use an empty string for default behavior, or a remote Git URL of a cloned repository. |
cronSchedule |
Quartz cron expression for periodic scheduling (null for on-demand only) |
registryRoles |
Roles required to run this job |
Each job configuration points to a script or executable via the job and jobDirectory fields. DevDaemon resolves the full path by combining these two values and runs the script as a child process.
Scripts receive any configured arguments plus a --source= flag indicating how the job was triggered (e.g., OnManual). Standard output and standard error are merged and forwarded to the daemon's logger.
A minimal job script looks like this:
#!/bin/bash
echo "Starting my-job..."
# do work here
echo "Done!"In the sample daemon, job scripts are placed in the sample-dev-daemon/sample-jobs/ directory. In production, it is best to auto-install scripts to a predefined location (e.g., ~/devdaemon/release-jobs/) on developer machines, independent of the DevDaemon installation, and provide this path as the job configuration's jobDirectory.
Place the script in the directory referenced by jobDirectory in the job configuration and ensure it is executable (chmod +x).
At Block, releasing a new daemon version and auto-installing it on developer machines is a lengthy process, while releasing updated job scripts is much quicker. Once the daemon implementation is stable, almost all changes will be in job configurations and scripts. Decoupling the two allows you to iterate on scripts without a new daemon release.
| Method | Endpoint | Description |
|---|---|---|
POST |
/execute/{job-id}?source=OnManual |
Execute a job by its ID and source parameter to indicate trigger |
POST |
/cancel/{job-id}?source=OnManual |
Cancel a running job by its ID and source parameter to indicate trigger |
- Git Hooks
- IDE Plugins (mdx-daemon-plugin)
- Cron
OnPostCheckoutGitHookOnPostMergeGitHookOnPostRebaseGitHookOnSyncCompleteWithSuccessOnSyncStartOnIdeBuildStartOnCommandLineBuildStartOnCronScheduleOnManual
The app is built by running:
./gradlew packageNativeApp --no-configuration-cacheThis performs the following steps:
- Builds a fat JAR — Compiles the Kotlin source and bundles all runtime dependencies into a single
my-daemon-<version>.jar - Runs jpackage — Uses the JDK 17+ built-in
jpackagetool to wrap the fat JAR into a nativeMyDaemon.appbundle, including an embedded JVM runtime so end users don't need Java installed - Patches Info.plist — Adds
LSUIElement = trueto the app'sInfo.plist, which tells macOS to run it as a background process with no Dock icon - Code signs — Signs the
.appwithcodesignusing a Developer ID certificate on CI, or ad-hoc signing locally - Zips for distribution — Archives the signed
.appintomy-daemon.zip
The final artifact is a self-contained macOS app at build/jpackage/MyDaemon.app that can be dropped into any install location and launched — no external JVM required.
This is already set up in :sample-dev-daemon as a reference. Run ./gradlew :sample-dev-daemon:packageNativeApp --no-configuration-cache and find the SampleDevDaemon.app in the sample-dev-daemon/build/jpackage/ directory.
The app is expected to be installed at ~/Applications/MyDaemon.app (or your preferred location). The actual binary lives at Contents/MacOS/MyDaemon inside the app bundle.
The daemon runs as a macOS LaunchAgent (per-user, not system-wide). Create a plist at:
~/Library/LaunchAgents/com.company.mydaemon.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.company.mydaemon</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>lsof -ti tcp:2001 | xargs kill -9; sleep 2; ~/Applications/MyDaemon.app/Contents/MacOS/MyDaemon</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>JAVA_HOME</key>
<string>~/Library/Java/JavaVirtualMachines/azul-17-ARM64/Contents/Home</string>
<key>ANDROID_HOME</key>
<string>~/Library/Android/sdk</string>
<key>ANDROID_SDK_ROOT</key>
<string>~/Library/Android/sdk</string>
<key>TERM</key>
<string>xterm-256color</string>
<key>LAUNCH_DARKLY_SDK_KEY</key>
<string>YOUR_LAUNCHDARKLY_SDK_KEY</string>
<key>MY_DAEMON_PORT</key>
<string>2001</string>
</dict>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/my-daemon-output.log</string>
<key>StandardErrorPath</key>
<string>/tmp/my-daemon-error.log</string>
</dict>
</plist>| Key | Purpose |
|---|---|
Label |
Unique identifier for the agent |
ProgramArguments |
Kills any existing process on the daemon port, then launches the app |
EnvironmentVariables |
Runtime environment — update paths and keys for your setup |
KeepAlive |
macOS automatically restarts the daemon if it exits |
RunAtLoad |
Starts the daemon on login |
StandardOutPath / StandardErrorPath |
Log file locations for debugging |
# Load and start the daemon
launchctl load ~/Library/LaunchAgents/com.company.mydaemon.plist
# Stop and unload the daemon
launchctl unload ~/Library/LaunchAgents/com.company.mydaemon.plist
# Check if running
launchctl list | grep mydaemonAt Block, the daemon macOS app, job scripts, and LaunchAgent plist are auto-installed on developer machines by a separate infrastructure team using internal device management tooling. This means developers don't need to manually build, copy, or configure anything — the daemon is silently deployed, kept up to date, and launched automatically on login.
For this to work at scale at your company, you'll want a similar approach: use your organization's device management or software distribution system (e.g., Munki, Jamf, Chef, Puppet, or an internal equivalent) to package and push the .app bundle, job scripts, and LaunchAgent plist to developer machines. This keeps the rollout hands-free and ensures every machine runs the correct version.
Copyright 2026 Block, Inc.
Licensed under the Apache License, Version 2.0.
See LICENSE for details.