Read AGENTS.md first — it's the 5-minute orientation
covering repo topology, how to drive a running app from your session
(Dala.Test, MCP fallbacks), and the pre-empt-failure rules that will save
you from re-deriving things the team has already learned the hard way.
This file goes deeper on Claude Code-specific workflow detail.
Keep AGENTS.md up to date when you change repo conventions, add a new piece of CLI surface area, deprecate a workflow, or hit a new gotcha. Out-of-date guidance there causes wrong decisions everywhere downstream — fix it in the same commit, not in a follow-up.
See guides/agentic_coding.md for the full
agent round-trip workflow: connecting to the running Erlang node, when
to use Dala.Test vs MCP platform tools, and how to avoid the instinct
to reach for xcrun simctl screenshots.
Before committing changes, run all three in this order:
mix test # full suite must pass (call out any pre-existing flake explicitly)
mix format # apply Elixir formatting
mix credo --strict # address new issues; pre-existing ones are tracked separatelyFor native-code changes (iOS .m, Android .kt / .c), Elixir tests don't
exercise the change. Deploy with mix dala.deploy --native and verify
manually with a screenshot or Dala.Test interaction before committing.
Dala has two modes of use:
- Dala UI apps — Elixir-driven SwiftUI/Android apps. The BEAM renders the UI.
- Native sidecar — The BEAM runs invisibly inside any native Xcode/Android Studio app as a debug-only test and agent harness. The native app has zero Elixir dependency. In production builds the BEAM is stripped entirely.
The sidecar mode is the long-term bet. It gives native developers (who write zero Elixir) a way to let agents introspect and drive their apps during development and CI — without changing how they build or ship.
The BEAM + NIF wraps the native app completely. From the OS's perspective, there is one process: the native app. The BEAM runs on a background thread. The NIF, being in-process, has privileges no external tool has:
- Direct access to the iOS/Android UI object graph (no accessibility bridge latency)
- Ability to intercept and synthesize touch events before they reach the app
- Access to non-UI state: model objects, view controller hierarchy, memory
- Faster and more reliable than Appium, XCUITest, or
xcrun simctl— no IPC round-trip
The end goal is that the BEAM is the whole world to this app: it can observe every touch, inject synthetic touches, read every visible label, and report full UI state — all over Erlang distribution to a remote test runner or agent.
- XCUITest runs out-of-process and requires a separate test host target — it cannot read in-memory model state, only rendered accessibility output
- Appium adds an HTTP layer and has significant latency
- The BEAM runs in-process with sub-millisecond IPC via Erlang distribution
- Tests can be written in any language that speaks Erlang distribution (Elixir, Erlang, or via the distribution protocol directly)
- Hot code push means test logic can be updated without restarting the app or rebuilding
Phase 1 — Attachment and reporting (complete)
ui_tree/0— walksUIApplication.sharedwindows via UIAccessibility APIs, returns[{type, label, value, {x,y,w,h}}, ...]tuples. Works on any app with zero modification.ui_debug/0— raw accessibility dump for debugging
Phase 2 — Synthetic interaction (complete)
tap/1— tap by accessibility labeltap_xy/2— tap at screen coordinates (with responder-chain walk to focus text fields)type_text/1— type into the focused text fielddelete_backward/0,key_press/1,clear_text/0— keyboard controllong_press_xy/3,swipe_xy/4— gesture synthesis
Phase 3 — Full cocoon / event interception (future)
Intercept the touch event stream before it reaches the app's responder chain. The BEAM decides whether to pass events through, suppress them, or inject new ones. At this point the BEAM is the authoritative input source and the app is fully contained.
For native-only developers (no Elixir, just Xcode or Android Studio), dala is added and removed as a debug sidecar via a single command. The production app is never affected.
mix dala.inject MyApp.xcodeproj # add sidecar to Xcode project (one time)
mix dala.eject MyApp.xcodeproj # remove it cleanly — git diff shows nothing- Adds
dala_nif.m,dala_beam.mas Debug-only compile sources - Links
libbeam.aand supporting static libs as Debug-only - Copies ERTS runtime directory as a Debug-only bundle resource
- Adds
#if DEBUG dala_start_beam() #endifto AppDelegate/SceneDelegate
// build.gradle (app) — added by inject
debugImplementation 'io.dala:sidecar:VERSION'// Application.onCreate() — added by inject
if (BuildConfig.DEBUG) dalaSidecar.start(this)(The Application.onCreate line can be eliminated with a ContentProvider auto-init,
making Android injection truly zero-touch.)
eject is a clean inverse. git diff after eject shows nothing meaningful. This is
important for the trust model — a developer can verify dala leaves no footprint.
Status: inject/eject are planned; pre-built libbeam.a fat binary (simulator +
device + Android) is the prerequisite.
The MCP server is an abstraction layer between the agent and the BEAM. The agent never sees Erlang nodes, distribution, or NIF calls directly — it talks to typed tools that happen to be backed by the BEAM internally.
The abstraction is the point. A developer using dala with an agent should not be able to accidentally write Elixir, because no tool exists to do so. The agent has everything it needs to verify and drive the native app, and nothing that lets it reach into the BEAM layer.
dala_dev — Mix tasks: deploy, connect, push, doctor, new, inject, eject
dala_mcp — MCP server: native-mode tools for agent-driven development
dala_mcp depends on dala_dev for device discovery and tunnel setup.
dala_dev has no knowledge of MCP. Clean dependency direction.
The MCP server has one mode. There is no dala_MODE=elixir. If a developer
wants full BEAM access they open IEx directly — that is a human workflow,
not an agent workflow. Giving the agent an Elixir-level tool would just be
a worse IEx with predefined functions.
| Tool | Backed by |
|---|---|
dala_deploy |
mix dala.deploy --native |
dala_build |
xcodebuild / gradlew assembleDebug + simctl install |
dala_ui_tree |
dala_nif:ui_tree/0 via RPC |
dala_tap |
dala_nif:tap_xy/2 via RPC (finds by label internally) |
dala_type_text |
dala_nif:type_text/1 via RPC |
dala_swipe |
dala_nif:swipe_xy/4 via RPC |
dala_screenshot |
xcrun simctl io / adb screencap |
dala_logs |
simulator console / adb logcat |
dala_assert_visible |
ui_tree + label/value match |
dala_wait_for |
poll ui_tree with timeout + backoff |
1. edit Swift/Kotlin files (appear in Xcode/AS immediately)
2. dala_build → xcodebuild + simctl install
3. dala_ui_tree → confirm screen state
4. dala_tap / dala_type_text → drive interactions
5. repeat
The developer sees all code changes in their IDE in real time and can intervene at any point. They never need to touch the terminal or know Erlang exists.
mix dala.new and mix dala.inject both emit .mcp.json in the project root:
{
"mcpServers": {
"dala": {
"command": "mix",
"args": ["dala_mcp.server"]
}
}
}Every developer and every agent session gets the same tools automatically. No per-session setup required.
SwiftUI lazily populates its accessibility tree only when an accessibility service is
active. Run this once per simulator session before calling ui_tree:
UDID=<booted-simulator-udid>
xcrun simctl spawn $UDID defaults write com.apple.Accessibility VoiceOverTouchEnabled -bool YES
xcrun simctl spawn $UDID notifyutil -p com.apple.accessibility.voiceover.status.changedWait ~500ms for propagation. Survives app restarts within the same simulator session.
TODO: mix dala.connect should run this automatically for iOS simulator targets.
The preferred tool is mix dala.connect (from dala_dev package):
cd ~/code/dala_demo
mix dala.connect # discover all devices, tunnel, restart, connect IEx
mix dala.connect --no-iex # same but print node names instead of starting IEx
mix dala.devices # list connected devices and their statusNode names are platform-specific:
- iOS simulator:
dala_demo_ios@127.0.0.1 - Android emulator:
dala_demo_android@127.0.0.1
iOS simulator shares the Mac's network stack — the iOS BEAM registers directly in the Mac's EPMD on port 4369. No forwarding needed.
Android is a separate network namespace. dala_dev sets up adb tunnels automatically:
adb reverse tcp:4369 tcp:4369 # EPMD: device → Mac (Android BEAM registers in Mac EPMD)
adb forward tcp:9100 tcp:9100 # dist: Mac → device
Devices are assigned dist ports by index to avoid conflicts:
- Device 0 (Android): port 9100
- Device 1 (iOS sim): port 9101
iOS dist port is passed via SIMCTL_CHILD_DALA_DIST_PORT env var; dala_beam.m reads
DALA_DIST_PORT at startup. Android dist port is passed as an intent extra (dala_dist_port);
MainActivity.java does NOT yet read this — multi-Android support is pending.
Both iOS and Android end up registered in the same Mac EPMD. mix dala.connect sets
up all tunnels automatically.
# Edit Elixir code, then:
mix dala.deploy # compile + push BEAMs + restart apps
mix dala.connect # tunnel + wait for nodes + drop into IEx
# In IEx (after dala.connect):
mix compile && nl(dalaDemo.CounterScreen) # hot-push one module without restart
Node.list() # verify both devices connected
:rpc.call(:"dala_demo_android@127.0.0.1", dalaDemo.CounterScreen, :some_fn, [])# Screen pid is logged at app start: "[dala] step 5 => {ok,<0.92.0>}"
pid = :rpc.call(:"dala_demo_android@127.0.0.1", :erlang, :list_to_pid, [~c"<0.92.0>"])
socket = :rpc.call(:"dala_demo_android@127.0.0.1", Dala.Screen, :get_socket, [pid])
socket.assigns # live assigns# After editing a screen (from the terminal):
mix dala.push # compile + push all changed modules to all connected devices
mix dala.push --all # force-push every module
# Or from inside IEx (after dala.connect), one module at a time:
nl(dalaDemo.CounterScreen)
# Returns: {:ok, [{:"dala_demo@127.0.0.1", :loaded, dalaDemo.CounterScreen}]}Android cannot start distribution at BEAM launch (races with hwui thread pool, causes
SIGABRT via FORTIFY pthread_mutex_lock on destroyed mutex). Instead, Dala.Dist.ensure_started/1
defers Node.start/2 by 3 seconds after app startup. This is handled in the dala library —
app code just calls Dala.Dist.ensure_started(node: :"my_app_android@127.0.0.1", cookie: :my_secret).
ERTS helper binaries (erl_child_setup, inet_gethost, epmd) cannot be exec'd from the
app data directory (SELinux app_data_file blocks execute_no_trans). They are packaged in
the APK as lib*.so in jniLibs/arm64-v8a/ (gets apk_data_file label, which allows exec).
dala_beam.c symlinks BINDIR/<name> → <nativeLibraryDir>/lib<name>.so before erl_start.
The standard loop for AI-assisted feature development or debugging. Use all three layers in order — BEAM state first, then visual verification only when needed.
mix dala.push # compile + push changed BEAMs to all connected nodes
# or for a native rebuild (e.g. after NIF or Swift/Kotlin change):
mix dala.deploy --nativeConnect (or use an already-open IEx session from mix dala.connect):
mix dala.connect --no-iex # sets up tunnels, prints node names, exitsThen from a separate IEx session or script:
node = :"dala_demo_ios@127.0.0.1"
Dala.Test.screen(node) # which screen is showing?
Dala.Test.assigns(node) # live assigns — count, selected items, etc.
Dala.Test.tap(node, :some_button) # drive a tap programmatically
Dala.Test.find(node, "Submit") # locate a widget by visible textThis is the fastest path. BEAM state is exact and doesn't require image decoding.
When you need to confirm rendering, layout, or animations — use the platform MCP servers. These are available as tools in the agent environment.
iOS Simulator (mcp__ios-simulator__*):
| Tool | When to use |
|---|---|
screenshot |
Capture the current simulator frame |
ui_tap |
Tap at x,y coordinates |
ui_type |
Type text into focused input |
ui_swipe |
Swipe gesture |
ui_view |
Inspect the accessibility tree |
ui_describe_point |
What element is at this coordinate? |
ui_describe_all |
Full accessibility dump |
record_video / stop_recording |
Record an interaction sequence |
Android (mcp__adb__*):
| Tool | When to use |
|---|---|
dump_image |
Screenshot from the connected device/emulator |
inspect_ui |
XML accessibility dump of the current view |
adb_shell |
Run arbitrary shell commands on the device |
adb_logcat |
Tail logcat (Elixir logs appear under the Elixir tag) |
1. Edit Elixir code
2. mix dala.push
3. Dala.Test.screen(node) ← confirm navigation / state
4. mcp__ios-simulator__screenshot ← visual sanity check
5. Dala.Test.tap(node, :button) ← drive interaction
6. Dala.Test.assigns(node) ← confirm state updated correctly
7. repeat
Use Dala.Test for assertions (exact, fast, no image parsing). Use MCP screenshot/UI
tools for layout checks, animation spot-checks, or when a bug is only visible
in the rendered output.
After connecting via mix dala.connect, use Dala.Test to inspect and drive the
running app without touching the native UI. Prefer this over screenshot-based
inspection — it gives exact state, not a visual approximation.
node = :"dala_demo_ios@127.0.0.1" # or dala_demo_android@127.0.0.1
# What screen is showing and what state is it in?
Dala.Test.screen(node) #=> dalaDemo.NavScreen
Dala.Test.assigns(node) #=> %{depth: 0, safe_area: %{top: 62.0, ...}}
# Find a node by visible text
Dala.Test.find(node, "Device APIs")
#=> [{[0, 0, 9], %{"type" => "button", ...}}]
# Trigger a tap by the tag atom used in on_tap: {self(), tag}
Dala.Test.tap(node, :open_device)
# Full snapshot for debugging
Dala.Test.inspect(node)
# %{screen: dalaDemo.NavScreen, assigns: ..., nav_history: [...], tree: ...}Tag atoms come from on_tap: {self(), :tag_atom} in the render tree. Check the
screen's render/1 to find them. After a tap, call Dala.Test.screen/1 again to
confirm the navigation happened.
mix test # from ~/code/dalaThe test/onboarding/ suite verifies the full first-run flow end-to-end: archive
install, project generation, mix dala.install, mix dala.doctor, and failure modes.
These tests are excluded from mix test by default (they take minutes and require
Hex/network access). Run them explicitly:
# Fast subset — no simulator needed (~3 min, suitable for PR gating)
MIX_ENV=test mix test --only generator
# Failure-mode checks — no simulator needed (~2 min)
MIX_ENV=test mix test --only pre_device
# Everything above in one pass
MIX_ENV=test mix test --only onboarding
# Full suite including post-device tests (requires a booted iOS simulator)
MIX_ENV=test mix test --only failure_modesRun one file at a time with --max-cases 1 to avoid workspace ID collisions between
concurrent tests:
MIX_ENV=test mix test test/onboarding/generator_test.exs --only generator --max-cases 1
MIX_ENV=test mix test test/onboarding/failure_modes_test.exs --only pre_device --max-cases 1What they test:
| Tag | File | Covers |
|---|---|---|
:generator |
generator_test.exs |
Archive install, mix dala.new, mix dala.install, mix dala.doctor |
:pre_device |
failure_modes_test.exs |
Failure modes that don't need a running simulator |
:post_device |
failure_modes_test.exs |
Failures requiring a live iOS simulator |
Preserved workspaces: When a test fails, its workspace is kept at
/tmp/dala_onboarding/run_<PID>/<test_id>/. Inspect logs/ for per-step output.
Workspaces from passing tests are deleted automatically.
Known limitations (published dala_dev 0.1.7):
DALA_OTP_BASE_URLis not respected — OTP download URL cannot be overridden for failure injection. Network failure tests verify OTP reporting format instead.check_elixirreadsSystem.version()(the running BEAM) — PATH-based fake Elixir versions have no effect. The Elixir version test verifies the check produces clear output.check_javaignores exit code ({out, _}pattern) — a fake java always shows ✓. The java test verifies the check is present and reports useful version info.xcrunandjavashare/usr/binwithdirname/basenameused by mise/asdf elixir launcher scripts. Filtering/usr/binfrom PATH crashes the subprocess. Tests for these tools verify the success path format instead of injecting a missing-tool failure.
See common_fixes.md for a running log of diagnosed bugs and their
fixes — consult it first when hitting silent crashes or unexpected BEAM behavior.
See user_issues.md for a record of real issues encountered by
beta users, their root causes, and fixes applied. Read this before working on setup,
deployment, or tooling problems — the same issues recur, especially for Nix users.
User alias "Nova" = macOS + Nix-managed toolchain throughout.
-
lib/dala/screen.ex— GenServer wrapper, lifecycle callbacks -
lib/dala/socket.ex— assigns + internal dala state -
lib/dala/renderer.ex— walks component tree, issues NIF calls -
lib/dala/connectivity/dist.ex— platform-aware distribution startup -
native/dala_nif/src/lib.rs— Rust NIF implementation (via Rustler) -
ios/dala_nif.m— iOS NIF implementation (SwiftUI bridge + test harness) -
android/jni/dala_nif.c— Android NIF implementation (JNI bridge) -
ios/dala_beam.m— iOS BEAM launcher -
android/jni/dala_beam.c— Android BEAM launcher -
lib/dala/permissions.ex— Permission management -
lib/dala/platform/native.ex— Rustler NIF declarations -
lib/dala/platform/native_logger.ex— Native logger (NSLog/logcat) -
lib/dala/hardware/bluetooth.ex— BLE support -
lib/dala/connectivity/wifi.ex— WiFi network info -
lib/dala/storage/storage.ex— App-local file storage -
lib/dala/storage/blob.ex— Binary blob handling -
lib/dala/hardware/wakelock.ex— Screen wakelock -
lib/dala/ui/native_view.ex— NativeView component behaviour -
lib/dala/ui/feedback/alert.ex— Alerts, action sheets, toasts -
lib/dala/ui/embedded/webview.ex— WebView JS bridge -
lib/dala/ui/sensor/motion.ex— Accelerometer/gyroscope -
lib/dala/event/event.ex— Unified event system -
lib/dala/plugin.ex— Plugin behaviour and DSL -
lib/dala/ml.ex— ML public API -
lib/dala/list.ex— List rendering -
lib/dala/pubsub.ex— Local PubSub
spark- https://hexdocs.pm/spark/Spark.htmlspark repo- https://github.com/ash-project/sparkrustler-https://hexdocs.pm/rustler/readme.htmlrustler repo- https://github.com/rusterlium/rustler