Control your Mac with Elixir
A production-ready Elixir library for macOS automation via AppleScript and JavaScript for Automation (JXA). Automate Safari, Finder, Mail, Messages, and more—all with the safety and reliability of Elixir.
- 🎯 Type-Safe Automation: Leverage Elixir's type system and pattern matching for robust automation
- 🧪 Test-Friendly: Built-in adapter pattern with Mox support makes testing automation code straightforward
- ⚡ Production-Ready: Automatic retry logic, telemetry integration, and comprehensive error handling
- 📦 Batteries Included: Pre-built modules for Safari, Finder, Mail, Messages, and system control
- 🔒 Permission Management: Built-in helpers for checking and requesting macOS permissions
- 🛠️ Extensible: Clean patterns and guides for adding your own app modules
- Developer Tooling: Automate your Mac-based development workflow
- Testing & QA: Control Safari for browser testing, automate UI interactions
- System Administration: Manage processes, files, and system settings programmatically
- Communication Bots: Send automated emails and messages
- Data Collection: Extract data from running applications
- Productivity Automation: Build custom workflows combining multiple apps
Here's a complete workflow combining multiple features:
alias ExMacOSControl, as: Mac
alias ExMacOSControl.{Safari, Mail, Retry, Permissions}
# Check permissions before starting
case Permissions.check_automation("Safari") do
{:ok, :granted} -> :ok
{:ok, :not_granted} ->
Permissions.show_automation_help("Safari")
raise "Safari automation permission required"
end
# Scrape data from a website with automatic retry
{:ok, price} = Retry.with_retry(fn ->
# Open URL
:ok = Safari.open_url("https://example.com/product")
Process.sleep(2000) # Wait for page load
# Extract price via JavaScript
Safari.execute_javascript(~s|
document.querySelector('.price').textContent
|)
end, max_attempts: 3, backoff: :exponential)
# Send email notification if price dropped
if String.contains?(price, "$99") do
:ok = Mail.send_email(
to: "me@example.com",
subject: "Price Alert!",
body: "The product is now #{price}!"
)
IO.puts("✅ Alert sent!")
end- AppleScript Execution: Timeout support, argument passing, comprehensive error handling
- JavaScript for Automation (JXA): Full JXA support with ObjC bridge access
- Script File Execution: Auto-detect
.applescript,.scpt,.js,.jxafiles - macOS Shortcuts: Run Shortcuts with input parameters (strings, numbers, maps, lists)
- System Events: Process management, UI automation (menu clicks, keystrokes), file operations
- Safari: Open URLs, execute JavaScript, manage tabs
- Finder: Navigate folders, manage selections, set view modes
- Mail: Send emails (with CC/BCC), search mailboxes, unread counts
- Messages: Send iMessages/SMS, retrieve chats, unread counts
- Permissions: Check and manage macOS automation permissions
- Retry Logic: Automatic retry with exponential/linear backoff
- Telemetry: Built-in observability via
:telemetryevents - Script DSL: Optional Elixir DSL for building AppleScript
- Platform Detection: Automatic macOS validation
# Basic AppleScript execution
{:ok, result} = ExMacOSControl.run_applescript(~s(return "Hello, World!"))
# => {:ok, "Hello, World!"}
# With timeout (5 seconds)
{:ok, result} = ExMacOSControl.run_applescript("delay 2\nreturn \"done\"", timeout: 5000)
# => {:ok, "done"}
# With arguments
script = """
on run argv
set name to item 1 of argv
return "Hello, " & name
end run
"""
{:ok, result} = ExMacOSControl.run_applescript(script, args: ["World"])
# => {:ok, "Hello, World"}
# Combined options
{:ok, result} = ExMacOSControl.run_applescript(script, timeout: 5000, args: ["Elixir"])
# => {:ok, "Hello, Elixir"}# Basic JXA execution
{:ok, result} = ExMacOSControl.run_javascript("(function() { return 'Hello from JXA!'; })()")
# => {:ok, "Hello from JXA!"}
# Application automation
{:ok, name} = ExMacOSControl.run_javascript("Application('Finder').name()")
# => {:ok, "Finder"}
# With arguments
script = "function run(argv) { return argv[0]; }"
{:ok, result} = ExMacOSControl.run_javascript(script, args: ["test"])
# => {:ok, "test"}# Execute AppleScript file (auto-detected from .applescript extension)
{:ok, result} = ExMacOSControl.run_script_file("/path/to/script.applescript")
# Execute JavaScript file (auto-detected from .js extension)
{:ok, result} = ExMacOSControl.run_script_file("/path/to/script.js")
# With arguments
{:ok, result} = ExMacOSControl.run_script_file(
"/path/to/script.applescript",
args: ["arg1", "arg2"]
)
# With timeout
{:ok, result} = ExMacOSControl.run_script_file(
"/path/to/script.js",
timeout: 5000
)
# Override language detection for files with non-standard extensions
{:ok, result} = ExMacOSControl.run_script_file(
"/path/to/script.txt",
language: :applescript
)
# All options combined
{:ok, result} = ExMacOSControl.run_script_file(
"/path/to/script.scpt",
language: :applescript,
args: ["test"],
timeout: 10_000
)# Run macOS Shortcuts
:ok = ExMacOSControl.run_shortcut("My Shortcut Name")
# Run Shortcut with string input
{:ok, result} = ExMacOSControl.run_shortcut("Process Text", input: "Hello, World!")
# Run Shortcut with map input (serialized as JSON)
{:ok, result} = ExMacOSControl.run_shortcut("Process Data", input: %{
"name" => "John",
"age" => 30
})
# Run Shortcut with list input
{:ok, result} = ExMacOSControl.run_shortcut("Process Items", input: ["item1", "item2", "item3"])
# List available shortcuts
{:ok, shortcuts} = ExMacOSControl.list_shortcuts()
# => {:ok, ["Shortcut 1", "Shortcut 2", "My Shortcut"]}
# Check if a shortcut exists before running it
case ExMacOSControl.list_shortcuts() do
{:ok, shortcuts} ->
if "My Shortcut" in shortcuts do
ExMacOSControl.run_shortcut("My Shortcut")
end
{:error, reason} ->
{:error, reason}
endControl running applications on macOS:
# List all running apps
{:ok, processes} = ExMacOSControl.SystemEvents.list_processes()
# => {:ok, ["Safari", "Finder", "Terminal", "Mail", ...]}
# Check if an app is running
{:ok, true} = ExMacOSControl.SystemEvents.process_exists?("Safari")
# => {:ok, true}
{:ok, false} = ExMacOSControl.SystemEvents.process_exists?("NonexistentApp")
# => {:ok, false}
# Launch an app
:ok = ExMacOSControl.SystemEvents.launch_application("Calculator")
# => :ok
# Activate (bring to front) an app - same as launch
:ok = ExMacOSControl.SystemEvents.activate_application("Safari")
# => :ok
# Quit an app gracefully
:ok = ExMacOSControl.SystemEvents.quit_application("Calculator")
# => :ok
# Full workflow example
app_name = "Calculator"
# Check if it's running
case ExMacOSControl.SystemEvents.process_exists?(app_name) do
{:ok, false} ->
# Not running, launch it
ExMacOSControl.SystemEvents.launch_application(app_name)
{:ok, true} ->
# Already running, bring to front
ExMacOSControl.SystemEvents.activate_application(app_name)
endNote: This module requires automation permission for System Events. macOS may prompt for permission on first use.
Control application UI elements programmatically (requires Accessibility permission):
# Click menu items
:ok = ExMacOSControl.SystemEvents.click_menu_item("Safari", "File", "New Tab")
# => :ok
:ok = ExMacOSControl.SystemEvents.click_menu_item("TextEdit", "Format", "Make Plain Text")
# => :ok
# Send keystrokes
:ok = ExMacOSControl.SystemEvents.press_key("TextEdit", "a")
# => :ok
# Send keystrokes with modifiers
:ok = ExMacOSControl.SystemEvents.press_key("Safari", "t", using: [:command])
# => :ok
# Multiple modifiers (Command+Shift+Q)
:ok = ExMacOSControl.SystemEvents.press_key("Safari", "q", using: [:command, :shift])
# => :ok
# Get window properties
{:ok, props} = ExMacOSControl.SystemEvents.get_window_properties("Safari")
# => {:ok, %{position: [100, 100], size: [800, 600], title: "Google"}}
# Application with no windows returns nil
{:ok, nil} = ExMacOSControl.SystemEvents.get_window_properties("AppWithNoWindows")
# => {:ok, nil}
# Set window bounds
:ok = ExMacOSControl.SystemEvents.set_window_bounds("Calculator",
position: [100, 100],
size: [400, 500]
)
# => :ok
# Complete UI automation workflow
# 1. Launch app
:ok = ExMacOSControl.SystemEvents.launch_application("TextEdit")
# 2. Create new document (Command+N)
:ok = ExMacOSControl.SystemEvents.press_key("TextEdit", "n", using: [:command])
# 3. Type some text
:ok = ExMacOSControl.SystemEvents.press_key("TextEdit", "H")
:ok = ExMacOSControl.SystemEvents.press_key("TextEdit", "e")
:ok = ExMacOSControl.SystemEvents.press_key("TextEdit", "l")
:ok = ExMacOSControl.SystemEvents.press_key("TextEdit", "l")
:ok = ExMacOSControl.SystemEvents.press_key("TextEdit", "o")
# 4. Get window properties
{:ok, props} = ExMacOSControl.SystemEvents.get_window_properties("TextEdit")
# 5. Resize window
:ok = ExMacOSControl.SystemEvents.set_window_bounds("TextEdit",
position: [0, 0],
size: [1000, 800]
)Important: UI automation requires Accessibility permission. Enable in:
System Settings → Privacy & Security → Accessibility
(Or System Preferences → Security & Privacy → Privacy → Accessibility on older macOS)
Add Terminal (or your Elixir runtime) to the list of allowed applications.
Available Modifiers: :command, :control, :option, :shift
Convenient helpers for file operations using Finder:
# Reveal file in Finder (opens window and selects the file)
:ok = ExMacOSControl.SystemEvents.reveal_in_finder("/Users/me/Documents/report.pdf")
# => :ok
# Get currently selected items in Finder
{:ok, selected} = ExMacOSControl.SystemEvents.get_selected_finder_items()
# => {:ok, ["/Users/me/file1.txt", "/Users/me/file2.txt"]}
# Empty selection returns empty list
{:ok, []} = ExMacOSControl.SystemEvents.get_selected_finder_items()
# => {:ok, []}
# Move file to trash
:ok = ExMacOSControl.SystemEvents.trash_file("/Users/me/old_file.txt")
# => :ok
# Complete workflow example
# 1. Create a test file
File.write!("/tmp/test.txt", "test content")
# 2. Reveal it in Finder
:ok = ExMacOSControl.SystemEvents.reveal_in_finder("/tmp/test.txt")
# 3. Get selected items (the file we just revealed should be selected)
{:ok, selected} = ExMacOSControl.SystemEvents.get_selected_finder_items()
# => {:ok, ["/tmp/test.txt"]}
# 4. Move to trash when done
:ok = ExMacOSControl.SystemEvents.trash_file("/tmp/test.txt")
# Error handling
{:error, error} = ExMacOSControl.SystemEvents.reveal_in_finder("/nonexistent/file")
# => {:error, %ExMacOSControl.Error{type: :not_found, ...}}
{:error, error} = ExMacOSControl.SystemEvents.trash_file("relative/path")
# => {:error, %ExMacOSControl.Error{type: :execution_error, message: "Path must be absolute", ...}}Important Notes:
- File operation paths must be absolute (start with
/) reveal_in_finder/1will open a Finder window and bring Finder to the fronttrash_file/1moves items to Trash (not permanent deletion), but should still be used with caution- File operations require Finder access (usually granted automatically)
Control the macOS Finder application:
# Get selected files in Finder
{:ok, files} = ExMacOSControl.Finder.get_selection()
# => {:ok, ["/Users/me/file.txt", "/Users/me/file2.txt"]}
# Empty selection returns empty list
{:ok, []} = ExMacOSControl.Finder.get_selection()
# => {:ok, []}
# Open Finder at a location
:ok = ExMacOSControl.Finder.open_location("/Users/me/Documents")
# => :ok
# Create new Finder window
:ok = ExMacOSControl.Finder.new_window("/Applications")
# => :ok
# Get current folder path
{:ok, path} = ExMacOSControl.Finder.get_current_folder()
# => {:ok, "/Users/me/Documents"}
# Returns empty string if no Finder windows open
{:ok, ""} = ExMacOSControl.Finder.get_current_folder()
# => {:ok, ""}
# Set view mode
:ok = ExMacOSControl.Finder.set_view(:icon) # Icon view
:ok = ExMacOSControl.Finder.set_view(:list) # List view
:ok = ExMacOSControl.Finder.set_view(:column) # Column view
:ok = ExMacOSControl.Finder.set_view(:gallery) # Gallery view
# Error handling
{:error, error} = ExMacOSControl.Finder.open_location("/nonexistent/path")
# => {:error, %ExMacOSControl.Error{...}}
{:error, error} = ExMacOSControl.Finder.set_view(:invalid)
# => {:error, %ExMacOSControl.Error{type: :execution_error, message: "Invalid view mode", ...}}Note: This module requires automation permission for Finder. macOS may prompt for permission on first use.
Control Safari browser programmatically:
# Open URL in new tab
:ok = ExMacOSControl.Safari.open_url("https://example.com")
# => :ok
# Get current tab URL
{:ok, url} = ExMacOSControl.Safari.get_current_url()
# => {:ok, "https://example.com"}
# Execute JavaScript in current tab
{:ok, title} = ExMacOSControl.Safari.execute_javascript("document.title")
# => {:ok, "Example Domain"}
{:ok, result} = ExMacOSControl.Safari.execute_javascript("2 + 2")
# => {:ok, "4"}
# List all tab URLs across all windows
{:ok, urls} = ExMacOSControl.Safari.list_tabs()
# => {:ok, ["https://example.com", "https://google.com", "https://github.com"]}
# Close a tab by index (1-based)
:ok = ExMacOSControl.Safari.close_tab(2)
# => :ok
# Complete workflow example
# Open a new tab
:ok = ExMacOSControl.Safari.open_url("https://example.com")
# Wait for page to load, then execute JavaScript
Process.sleep(2000)
{:ok, title} = ExMacOSControl.Safari.execute_javascript("document.title")
# List all tabs
{:ok, tabs} = ExMacOSControl.Safari.list_tabs()
IO.inspect(tabs, label: "Open tabs")
# Close the first tab
:ok = ExMacOSControl.Safari.close_tab(1)Note: This module requires automation permission for Safari. Tab indices are 1-based (1 is the first tab).
Control Mail.app programmatically:
# Send an email
:ok = ExMacOSControl.Mail.send_email(
to: "recipient@example.com",
subject: "Automated Report",
body: "Here is your daily report."
)
# Send with CC and BCC
:ok = ExMacOSControl.Mail.send_email(
to: "team@example.com",
subject: "Team Update",
body: "Weekly status update.",
cc: ["manager@example.com"],
bcc: ["archive@example.com"]
)
# Get unread count (inbox)
{:ok, count} = ExMacOSControl.Mail.get_unread_count()
# => {:ok, 42}
# Get unread count (specific mailbox)
{:ok, count} = ExMacOSControl.Mail.get_unread_count("Work")
# => {:ok, 5}
# Search mailbox
{:ok, messages} = ExMacOSControl.Mail.search_mailbox("INBOX", "invoice")
# => {:ok, [%{subject: "Invoice #123", from: "billing@example.com", date: "2025-01-15"}, ...]}
# Complete workflow example
# Check unread count
{:ok, unread} = ExMacOSControl.Mail.get_unread_count()
IO.puts("You have #{unread} unread messages")
# Search for important messages
{:ok, messages} = ExMacOSControl.Mail.search_mailbox("INBOX", "urgent")
# Process search results
Enum.each(messages, fn msg ->
IO.puts("From: #{msg.from}")
IO.puts("Subject: #{msg.subject}")
IO.puts("Date: #{msg.date}")
IO.puts("---")
end)
# Send notification email if urgent messages found
if length(messages) > 0 do
:ok = ExMacOSControl.Mail.send_email(
to: "admin@example.com",
subject: "Urgent Messages Alert",
body: "Found #{length(messages)} urgent messages requiring attention."
)
endImportant Safety Notes:
- Mail automation requires Mail.app to be configured with an email account
send_email/1sends emails immediately - there is no undo- Use with caution in production environments
- Consider adding confirmation prompts before sending emails
- Test with safe recipient addresses first
Control the Messages app programmatically:
# Send a message (iMessage or SMS)
:ok = ExMacOSControl.Messages.send_message("+1234567890", "Hello!")
# Send to a contact name
:ok = ExMacOSControl.Messages.send_message("John Doe", "Meeting at 3pm?")
# Force SMS (not iMessage)
:ok = ExMacOSControl.Messages.send_message(
"+1234567890",
"Hello!",
service: :sms
)
# Force iMessage
:ok = ExMacOSControl.Messages.send_message(
"john@icloud.com",
"Hello!",
service: :imessage
)
# Get recent messages from a chat
{:ok, messages} = ExMacOSControl.Messages.get_recent_messages("+1234567890")
# => {:ok, [
# %{from: "+1234567890", text: "Hello!", timestamp: "Monday, January 15, 2024 at 2:30:00 PM"},
# %{from: "+1234567890", text: "How are you?", timestamp: "Monday, January 15, 2024 at 2:31:00 PM"}
# ]}
# List all chats
{:ok, chats} = ExMacOSControl.Messages.list_chats()
# => {:ok, [
# %{id: "iMessage;+E:+1234567890", name: "+1234567890", unread: 2},
# %{id: "iMessage;-;+E:john@icloud.com", name: "John Doe", unread: 0}
# ]}
# Get total unread count
{:ok, count} = ExMacOSControl.Messages.get_unread_count()
# => {:ok, 5}
# Complete workflow example
# Check for unread messages
{:ok, unread} = ExMacOSControl.Messages.get_unread_count()
if unread > 0 do
# List all chats to see who has unread messages
{:ok, chats} = ExMacOSControl.Messages.list_chats()
# Find chats with unread messages
unread_chats = Enum.filter(chats, fn chat -> chat.unread > 0 end)
# Get recent messages from the first unread chat
if length(unread_chats) > 0 do
first_chat = hd(unread_chats)
{:ok, messages} = ExMacOSControl.Messages.get_recent_messages(first_chat.name)
# Process the messages
Enum.each(messages, fn msg ->
IO.puts("From: #{msg.from}")
IO.puts("Text: #{msg.text}")
IO.puts("Time: #{msg.timestamp}")
IO.puts("---")
end)
end
endRequired Permissions:
- Automation permission for Terminal/your app to control Messages
- Full Disk Access (for reading message history)
Important Safety Notes:
send_message/2andsend_message/3send real messages immediately - there is no undo- Messages are sent via iMessage by default, falling back to SMS if iMessage is not available
- Use the
:serviceoption to force SMS or iMessage - Be extremely careful when using in automated scripts
- Consider adding confirmation prompts before sending messages
- Test with your own phone number first
macOS requires explicit permissions for automation. Use the Permissions module to check and manage these:
# Check accessibility permission
case ExMacOSControl.Permissions.check_accessibility() do
{:ok, :granted} ->
IO.puts("Ready for UI automation!")
{:ok, :not_granted} ->
ExMacOSControl.Permissions.show_accessibility_help()
end
# Check automation permission for specific apps
ExMacOSControl.Permissions.check_automation("Safari")
# => {:ok, :granted} | {:ok, :not_granted}
# Get overview of all permissions
statuses = ExMacOSControl.Permissions.check_all()
# => %{accessibility: :granted, safari_automation: :not_granted, ...}
# Open System Settings to grant permissions
ExMacOSControl.Permissions.open_accessibility_preferences()
ExMacOSControl.Permissions.open_automation_preferences()Required Permissions:
- Accessibility: For UI automation (menu items, keystrokes, windows)
- Automation: For controlling specific apps (Safari, Finder, Mail, etc.)
- Full Disk Access: For some operations (e.g., Messages history)
ExMacOSControl includes automatic retry functionality for handling transient failures like timeouts:
alias ExMacOSControl.Retry
# Basic retry with exponential backoff (default: 3 attempts)
# Retries: immediately, after 200ms, after 400ms
{:ok, result} = Retry.with_retry(fn ->
ExMacOSControl.Finder.get_selection()
end)
# Custom max attempts with linear backoff
# Retries: immediately, after 1s, after 1s, after 1s, after 1s
{:ok, windows} = Retry.with_retry(fn ->
ExMacOSControl.SystemEvents.get_window_properties("Safari")
end, max_attempts: 5, backoff: :linear)
# Combining timeout and retry for reliability
{:ok, result} = Retry.with_retry(fn ->
ExMacOSControl.run_applescript(script, timeout: 10_000)
end, max_attempts: 3, backoff: :exponential)Retry Behavior:
- Only retries timeout errors (errors with
type: :timeout) - Non-timeout errors (syntax, permission, not found) return immediately
- Exponential backoff: 200ms, 400ms, 800ms, 1600ms, etc.
- Linear backoff: constant 1000ms between retries
When to Use:
- ✅ Timeout errors that may succeed on retry
- ✅ Operations depending on application state
- ✅ UI automation affected by system responsiveness
- ❌ Syntax errors (won't be fixed by retrying)
- ❌ Permission errors (user intervention required)
ExMacOSControl emits telemetry events for monitoring and observability:
# In your application.ex
:telemetry.attach_many(
"ex-macos-control-handler",
[
[:ex_macos_control, :applescript, :start],
[:ex_macos_control, :applescript, :stop],
[:ex_macos_control, :applescript, :exception],
[:ex_macos_control, :retry, :start],
[:ex_macos_control, :retry, :stop],
[:ex_macos_control, :retry, :error]
],
&MyApp.handle_telemetry/4,
nil
)
# Example handler to track slow operations
defmodule MyApp do
def handle_telemetry([:ex_macos_control, :applescript, :stop], measurements, metadata, _) do
duration_ms = measurements.duration / 1_000
if duration_ms > 5_000 do
Logger.warning("Slow operation: #{duration_ms}ms - #{metadata.script}")
end
end
def handle_telemetry(_, _, _, _), do: :ok
endAvailable Events:
[:ex_macos_control, :applescript, :start]- Script execution begins[:ex_macos_control, :applescript, :stop]- Script succeeds (includesdurationin microseconds)[:ex_macos_control, :applescript, :exception]- Script fails (includeserrordetails)[:ex_macos_control, :retry, :*]- Retry lifecycle events
See docs/performance.md for comprehensive performance guide including:
- Timeout configuration recommendations
- Common bottlenecks and solutions
- Benchmarking strategies
- When to use retry logic
- Complete telemetry event reference
Add ex_macos_control to your list of dependencies in mix.exs:
def deps do
[
{:ex_macos_control, "~> 0.1.0"}
]
endThen run:
mix deps.get# In iex
iex> ExMacOSControl.run_applescript(~s(return "Hello!"))
{:ok, "Hello!"}If this works, you're ready to automate! 🎉
- macOS 10.15 (Catalina) or later
- Elixir 1.19 or later
- Appropriate macOS permissions (accessibility, automation, etc.)
See the Permissions section for details on required permissions.
Full documentation is available at https://hexdocs.pm/ex_macos_control.
# Install dependencies
mix deps.get
# Install git hooks (recommended)
./scripts/install-hooks.sh
# Run tests
mix testGit Hooks: This project uses pre-commit and pre-push hooks to ensure code quality. See docs/git_hooks.md for details.
This project uses strict code quality standards. All contributions must pass the following checks:
# Run all quality checks (format, credo, dialyzer)
mix quality# Format code
mix format
# Check code formatting
mix format.check
# Run Credo static analysis (strict mode)
mix credo --strict
# Run Dialyzer type checking
mix dialyzer
# Run tests
mix test- Formatting: All code must be formatted with
mix format(120 character line length) - Credo: All code must pass strict Credo checks with zero warnings
- Dialyzer: All code must pass Dialyzer type checking with zero warnings
- Tests: Aim for 100% test coverage on new code (minimum 90%)
- Documentation: All public functions must have
@doc,@spec, and@moduledoc
See CONTRIBUTING.md for detailed development guidelines and standards.
Want to add automation for additional macOS apps? ExMacOSControl provides comprehensive documentation for creating new app automation modules.
See the App Module Creation Guide for:
- Step-by-step instructions for creating modules
- Common patterns and best practices
- Testing strategies (unit and integration)
- Complete examples (Music and Calendar modules)
- Troubleshooting guide
- Ready-to-use boilerplate templates
The guide includes everything you need to extend ExMacOSControl with new functionality while following established patterns and maintaining code quality standards.
- Getting Started Guide - Complete walkthrough for first-time users
- Common Patterns - Real-world automation examples and workflows
- DSL vs Raw AppleScript - Choosing the right approach for your use case
- Advanced Usage - Telemetry, custom adapters, and performance tuning
- Performance Guide - Optimization tips and best practices
- Issues: GitHub Issues for bugs and feature requests
- Discussions: GitHub Discussions for questions and community support
- Documentation: HexDocs for API reference
Contributions are welcome! Please see CONTRIBUTING.md for details on:
- Code of conduct
- Development setup
- Testing requirements
- Pull request process
Whether it's fixing bugs, adding new app modules, improving documentation, or sharing use cases—all contributions are appreciated!
This project is licensed under the MIT License - see the LICENSE file for details.