A lightweight URL bridge for macOS automation.
Milan is a HTTP agent designed to execute local scripts and Apple Shortcuts via simple URL calls. It acts as a persistent bridge, allowing you to trigger local automation from any HTTP-capable source (browser, curl, Stream Deck, or other scripts).
It can do both:
- Standalone: It works perfectly as a standalone tool on your Mac.
- Companion: It connects with dy.lan to act as the remote helper for your Mac, allowing you to trigger complex workflows from any device on your local (or Tailscale) network.
- URL Triggers: Turn any local script into an HTTP endpoint instantly.
- Speed: Persistent agent design ensures execution in ~120ms for scripts (and ~1 sec for Shortcuts).
- Simplicity: Single Go binary, no runtime dependencies.
- Privacy: Strict IP allow-listing and no external cloud services.
- Reach: Reach your Mac via Dylan from any network (LAN/VPN).
- Security: Identity verification with the Dylan master at startup.
Milan creates a connection between your server and your client. It establishes a clear separation between logic and execution:
- dy.lan (The Redirector): Your central hub and logic engine running on Docker or Synology. It identifies where a request needs to go and "points" the way.
- mi.lan (The Agent): The local executor on your Mac. It waits for instructions and handles the heavy lifting, like running scripts or Apple Shortcuts.
- Request: A client (like your iPhone) sends a request to the redirector (e.g.,
http://mi.lan/mini/shortcut/Note). - Handshake: Before starting, the agent can ask the redirector "Who am I?" via
http://dy.lan/whoamito ensure the bridge is correctly configured. - Redirection: The hub recognizes the target agent ("mini") and passes the request to the specific Mac's IP (e.g.,
192.168.1.118:8080). - Execution: The agent performs the local action and sends the result back.
iPhone -> Dylan (Synology) -> Milan (Mac) -> Script -> Response
- macOS (tested on Sequoia)
- Go 1.21+ (to build)
- Ruby 3+ (to run
.rbscripts)
Milan resolves all paths relative to its own binary:
milan-dir/
├── milan # binary
├── config.yaml # your config (copy from config.yaml.example)
├── scripts/ # scripts served as HTTP endpoints
│ └── custom/ # private scripts (gitignored)
├── data/ # background job logs (auto-created)
└── milan.log # runtime log
Keep the binary and config.yaml in the same directory. To call milan from
anywhere, create a symlink — Milan uses filepath.EvalSymlinks internally and
resolves the real binary location correctly:
ln -sf /path/to/milan-dir/milan /usr/local/bin/milanDo not move the binary alone without the config and scripts alongside it.
# Download binary from releases, or build from source:
go build -o milan .
# Setup config
cp config.yaml.example config.yaml
# Edit config.yaml: add allowed IPs
# Start Milan
./milan startconfig.yaml (copy from config.yaml.example):
milan:
port: 8080
allowed_ips:
- "192.168.1.*"
scripts_dir: "./scripts"
notes:
- id: my-notes
path: /path/to/notes| Key | Default | Description |
|---|---|---|
port |
8080 |
HTTP port Milan listens on |
allowed_ips |
— | IPs allowed to trigger scripts. Wildcards supported (192.168.1.*). Localhost is always allowed |
scripts_dir |
./scripts |
Directory for scripts, relative to the binary |
notes |
— | List of note sources (see Notes / Wiki) |
Via Dylan (Remote):
http://mi.lan/hellotriggers./scripts/hello.rbon your Mac via Dylanhttp://mi.lan/shortcut/Notetriggers Apple Shortcut "Note"http://mi.lan/shortcut/Note/Hello%20Milantriggers Shortcut "Note" with input "Hello Milan"
Standalone (Local):
http://localhost:8080/hello/Worldrunsscripts/hello.rbwith "World" asARGV[0]locally on your Machttp://localhost:8080sends status information
Scripts can stream output line by line via SSE (Server-Sent Events):
GET /stream/<script>
GET /stream/<script>/<arg>
The response is a text/event-stream. Each line of stdout is sent as a data: event. When the script finishes, Milan sends event: done. On non-zero exit: event: stream_error.
Background mode: If the client disconnects mid-stream, Milan switches to silent mode — the script continues running, collects output into a log file, and records a background job entry when it finishes.
When a stream is abandoned, Milan records the job in data/jobs/status.json:
GET /jobs/all → all job records (JSON)
GET /jobs/pending → unacknowledged jobs
GET /jobs/ack/<id> → mark job as acknowledged
Jobs are identified by <script>_<timestamp> and include script name, exit status, log path, timestamp, and acknowledged flag. History is capped at 100 entries.
Milan can serve Markdown and HTML files from configured directories:
GET /notes → list sources (JSON)
GET /notes/<source> → list files in source (JSON)
GET /notes/<source>/<file> → render file (HTML)
GET /notes/<source>/assets/<path> → serve asset (image or CSS)
Markdown files are rendered via Apex. HTML files are served as-is. Both images/ and css/ subdirectories are served as assets.
Configure sources in config.yaml:
milan:
notes:
- id: my-notes
path: /path/to/notes/directoryVia URL Scheme:
milan://hello/Worldrunsscripts/hello.rb— same as the HTTP call, but without opening Safarimilan://stream/hello/Worlduses the streaming endpoint — required for long-running scripts or GUI appsref://works the same way asmilan://, but is intended for document references rather than script execution
The milan:// and ref:// URL schemes are handled by ticker, which registers them as part of its app bundle. No separate URL handler app is needed.
./milan start # Start with Dylan identity check
./milan start --standalone # Start without Dylan
./milan stop # Stop service
./milan restart --standalone # Restart service
./milan status # Show status and PID
./milan log # Tail the log file
./milan whoami # Check identity with Dylanmilan is reliable across restarts: it detects stale PID files, clears any process holding the port (via lsof), and waits for the HTTP health endpoint to respond before reporting success.
Scripts live in ./scripts/ (or ./scripts/custom/ for private scripts, gitignored) and receive URL path segments as arguments. Supported types:
| Extension | Interpreter |
|---|---|
.rb |
Ruby |
.sh |
sh |
.py |
python3 |
| (none) | direct (needs executable bit) |
Apple Shortcuts are handled by scripts/shortcut.rb via the shortcuts CLI — no special extension needed.
Examples:
# scripts/hello.rb
#!/usr/bin/env ruby
name = ARGV[0] || 'World'
puts "Hello, #{name}!"# scripts/greet.sh
#!/bin/sh
echo "Hello, ${1:-World}!"Rules:
- Script names:
[a-z0-9_-]only - One script per name —
hello.rbandhello.shtogether cause a 500 error - Timeout: 5 seconds (synchronous execution); no timeout for streams
- stdout → HTTP response
- Exit code != 0 → HTTP 422
- IP Allowlist: Only configured IPs can trigger scripts
- Wildcards:
192.168.1.*allows entire subnet - Localhost: Always allowed (127.0.0.1, ::1)
- Script Names: Validated (no path traversal possible)
To connect Dylan to Milan agents, configure config/milan.yaml on Dylan:
milan:
enabled: true
agents:
mini: "http://192.168.1.118:8080" # Mac Mini
book: "http://192.168.1.188:8080" # MacBook
With the 35-milan-connect.rb plugin, requests are routed through Dylan:
http://mi.lan/mini/hello/World -> Mac Mini: GET /hello/World
http://mi.lan/book/shortcut/Note -> MacBook: GET /shortcut/Note
MIT