Reference

The CLI

One binary runs the whole loop: scaffold a project, generate bindings, build the core for each platform, serve it live, and check your machine. Every command teaches through its own help.

Overview#

text
undra [-C <dir>] <command>

  init      create a new project: a core crate and an app shell per platform
  bindgen   generate Swift, Kotlin and TypeScript bindings from the core's schema
  schema    compare the public API of two schemas (breaking or additive), or export the core's schema
  build     build the core for iOS, Android, the web or this machine
  dev       serve the core over a WebSocket to running apps, rebuilding on change
  drift     compare recordings of one flow made on different platforms: where the hosts diverged
  doctor    check toolchains and SDKs against what the project needs, with the fix for each gap
  adopt     add an Undra core to an existing app, without touching its project files
  upgrade   move a project to this undra's version: every pin, the bindings, the migration notes

An Undra project is a directory with a undra.toml. Commands find it from the current directory upwards, or you can point at it with the global -C <dir> (long form --project-dir). undra <command> --help prints what the command does, what it needs, what it writes, and examples.

Exit status is 0 on success, 1 for a failed command or a failed undra doctor, and 2 for a bad command line.

undra init#

bash
undra init todo                              # all three platforms
undra init todo --platforms ios,web          # only those shells
undra init todo --id dev.acme.todo           # application id / bundle identifier
undra init todo --undra-path ~/src/undra       # use a local checkout of Undra
undra init todo --ios-deployment-target 15.0   # an iOS app that supports iOS 15 and 16 (ObservableObject stores)

Creates a project directory with undra.toml, core/ (a working to-do store), generated/ (bindings, already generated), one minimal, real app per platform in ios/, android/ and web/, and a CI workflow (.github/workflows/undra.yml: a job for the core and one per app, installing undra at the version pinned at the top of the file). Nothing is downloaded: the templates are part of the binary. Without --undra-path the project depends on the release of this undra, all from github.com/shreypdev/undra: the crates by tag, the Swift package at the repository's root, the Kotlin modules from JitPack and @undra/runtime as the release's asset (the lines). UNDRA_DIST_GIT_URL, UNDRA_DIST_RELEASE_URL and UNDRA_DIST_MAVEN_REPO replace those three addresses, for a rehearsal of a release or a mirror (an https://, http:// or file:// URL; anything else is error[undra::C0009]); undra bindgen, adopt and upgrade read them too. A project that uses a checkout gets no workflow, since CI cannot see the checkout.

FlagMeaning
<name>project name: letters, digits, - and _
--platforms <list>ios, android, web, comma separated; default all three (alias --targets)
--id <id>application id and iOS bundle identifier; default com.example.<name>
--undra-path <dir>use the crates and runtimes of an Undra checkout (or set UNDRA_PATH)
--dir <dir>create the project inside this directory instead of the current one

undra bindgen#

bash
undra bindgen                          # build the core, extract the schema, write <project>/generated
undra bindgen --out ../shared/bindings
undra bindgen --schema schema.json     # no build: generate from a schema file
undra bindgen --check                  # exit 1 if the generated files are stale (for CI)
undra bindgen --docs                   # include the core's doc comments

Reads the core's schema and writes the bindings the app shells import: a Swift package (<out>/swift/), a Gradle module (<out>/kotlin/) and an npm package (<out>/ts/). By default the schema comes from the core itself: the core is built as a host library, loaded, and asked for its schema. With --schema it is read from a file instead and nothing is built. Files an earlier run wrote and this one does not are removed; files you added next to them are never touched. Beside the trees it writes the lint exclusion files, unless [bindings] lint_exclusions = "none" (below). Every generated file says it is generated (the generator, the schema hash, "Do not edit"), and each tree gets a .gitattributes that marks it linguist-generated, so GitHub collapses it in a pull request: review the schema, not the bindings.

FlagMeaning
--schema <file>generate from this schema JSON instead of building the core
--out <dir>write below this directory (default [paths] generated in undra.toml)
--releaseextract the schema from a release build of the core
--platforms <list>only these platforms' bindings (ios is Swift, android is Kotlin, web is TypeScript)
--checkwrite nothing; exit with an error if the generated files differ from what would be written
--docskeep the core's doc comments in the bindings
--crate-name <name>the core crate's name, for a schema file that has none
--swift-observation <mode>observation (@Observable, iOS 17 and later) or observable-object (iOS 15 and later); overrides [bindings] swift_observation, which follows [ios] deployment_target
--ios-deployment-target <version>generate the Swift for this iOS floor instead of [ios] deployment_target (see supporting iOS 15 and 16)

The schema a library exports is the whole schema, doc comments included. --docs keeps them in the generated code, so every type, field, variant, method and port carries the text of its /// comment; without it the generated code has no doc comments. The schema hash covers neither, so both outputs have the same hash and the same wire.

undra schema#

bash
undra schema export -o schema.json               # write the core's API as a file; commit it
undra schema export --check                      # fail when schema.json is not the core's schema (CI)
undra schema diff --against origin/main          # what changed since the base, each line breaking or additive
undra schema diff --against origin/main --exit-code   # ... and exit 1 when a line is breaking (CI)
undra schema diff old.json new.json              # two schema files

The schema is the API: the generated Swift, Kotlin and TypeScript are an artifact of it. schema export builds the core as a host library, loads it and writes its schema as JSON (without doc comments unless --docs, so fixing a comment is not an API change). schema diff compares two schemas and prints one line per change an app can notice, each marked breaking (code written against the old bindings can stop compiling) or additive; the rules are in Reviewing generated code. The order is fixed, so the same two schemas print the same text.

FlagMeaning
diff <old> <new>two schema JSON files, old then new (any form undra bindgen --schema reads)
diff --against <ref> [file]the file as committed at a git ref (git show; nothing is built) against the file in the working tree; the file is schema.json in the project directory unless named
diff --exit-codeexit with status 1 when any line is breaking, or when the schema file disagrees with the bindings beside it; without it the exit status is 0 whatever the diff says
export -o <file>write the schema to a file (default: standard output)
export --docs, --releasekeep the doc comments; extract from a release build
export --checkwrite nothing; exit with an error when the file (-o, or schema.json in the project directory) is not what the export would write

diff --against builds nothing, so it checks the file instead: the generated bindings carry the hash of the schema they were made from, and when schema.json disagrees with them, in the working tree or at the ref, a warning names both hashes. Errors are the usual ones: C0009 for a ref that does not have the file (it says to commit one with schema export) or a file outside a repository, C0002 for a file that is not a schema, C0003 without git, and C0007 from export --check for a file that is not the core's schema.

undra build#

bash
undra build                                   # every platform in undra.toml, debug
undra build --release                         # optimized: what you ship
undra build --platform web --release
undra build --platform android,host

Builds the core for each platform and puts the result where the app shells look for it, below build/, named after the core's namespace ([core] namespace, by default the core's package name; hello_core below):

TargetOutput
iosbuild/ios/HelloCore.xcframework, device and simulator slices, each one prelinked libhello_core.a
androidbuild/android/jniLibs/<abi>/libhello_core.so, arm64-v8a and x86_64, 16 KB page aligned
webbuild/web/hello_core.wasm, release profile, then wasm-opt -Oz if installed
hostbuild/host/libhello_core.{dylib,so}, for the JVM tests and undra bindgen
rnthe ios and android outputs, plus build/ios/HelloCore.podspec for a React Native app's Podfile (docs/REACT_NATIVE.md); the iOS half only on macOS
FlagMeaning
--platform <list>host, ios, android, web, rn, comma separated; default every platform in undra.toml
--releaseoptimized build (LTO, one codegen unit); the web build is always optimized
--configuration <name>an Xcode build configuration, as the generated build phase passes it ($CONFIGURATION): a name that contains Release builds release, any other debug; after an iOS build it writes the stamp Xcode uses to tell which configuration the XCFramework is for

Not a manual step. In a project made by undra init the app builds run undra build for you, each only when the core changed: Gradle's undraBuild task (preBuild depends on it; a release variant builds a release core), Xcode's Build the Undra core Run Script phase (before Compile Sources, with input and output file lists) and the undra() Vite plugin from @undra/runtime/vite (on start, and under vite dev on every change of core/src, followed by a reload). They find undra on PATH, or in ~/.undra/bin and the other usual places, and say how to install it when it is missing (error[undra::C0003]).

Under Bazel, undra_core runs this build inside an action, one target per platform, with the same profiles: the Bazel guide.

--release turns on LTO and one codegen unit. The web build is always optimized. On iOS and Android the release build is also tuned for size (the release-mobile profile of the generated crate: opt-level = "s", and a panic still unwinds so the core can contain it), and the host build keeps opt-level = 3. opt_level in [ios] or [android] of undra.toml chooses the smallest library ("z", calls about 1.5x slower) or the fastest ("3") instead; the production page has the numbers. Sizes are printed at the end next to the design's budgets. A debug Android core is tens of megabytes per ABI, so package with --release. The library is your core plus the Undra C ABI, built from a small crate generated under target/undra/; you never write that crate.

No -force_load. Each iOS slice is one prelinked object whose only global symbol is the core's entry, hello_core_undra_api, which the generated bindings call. It links like any other static library, in debug and release, and another core can sit next to it.

undra dev#

bash
undra dev                          # listen on 127.0.0.1:7443
undra dev --android                # also adb reverse the port to every attached Android device
undra dev --addr 0.0.0.0:7443      # reachable from a phone on your network
undra dev --no-watch               # build once and serve
undra dev --no-keep-state          # every rebuilt core starts fresh
undra dev --record session.json     # also write the session as a recording

Runs the core on this machine and serves it over a WebSocket. A simulator, a phone or a browser tab connects with the remote transport and uses this core instead of a built-in one: edit Rust, save, and the core is rebuilt and restarted. The apps reconnect by themselves, with backoff, whenever the connection drops (a rebuild, a phone that slept, a restarted adb); the core's state is carried across a rebuild (it is snapshotted in this process's memory, never on disk, and restored into the new core), so an app comes back to the screen it was on and its dev bar says "Reloaded, state kept"; a changed schema, or --no-keep-state, starts the new core fresh and says so. Logs, including the development records (a line per transaction commit, port call and panic) and a line for each client that connects, reconnects or leaves, print here. Clocks, randomness and logging are answered by this machine, because a remote client cannot answer a synchronous port. The banner also prints the address of the devtools page (behind a per-run token): the stores and their live values, a timeline of every change-set with its diff, a scrubber that restores the core to an earlier step (the app follows, and its dev bar says time travel: step 3), the port-call and query-cache logs, and the counters.

FlagMeaning
--addr <addr>address to listen on; port 0 picks a free one (default 127.0.0.1:7443)
--no-watchbuild once and serve; do not rebuild on change
--log-level <n>log records below this level (0 trace to 5 fatal) are not printed; default 1
--androidrun adb reverse for the server's port on every attached Android device or emulator (only the one in ANDROID_SERIAL, when set)
--no-keep-statestart every rebuilt core from fresh state instead of carrying the running core's state over
--record <file>write the session as an undra.recording (calls, replies, change-sets, port traffic, with times), rewritten twice a second; a rebuilt core starts NAME-2.json. See Testing your app
--record-secretskeep SecureStore values in the recording (by default the calls of that port are recorded with empty arguments and replies)
--devtools <auto|on|off>serve the devtools page at /devtools: auto (default) on a loopback address only, on always, off never

How each platform connects (the banner prints these with the real port):

PlatformHow
WebUndraHelloCore.load({ mode: "remote", url: "ws://127.0.0.1:7443" }), or ?undra=ws://127.0.0.1:7443 in the page URL
iOSset UNDRA_DEV_URL=ws://<your Mac>:7443; the generated app reads it in debug builds
Androidan emulator uses ws://10.0.2.2:7443; a USB device runs adb reverse tcp:7443 tcp:7443 (or --android) and uses ws://127.0.0.1:7443. The generated app takes it from the undra_dev_url launch extra or ./gradlew -PundraDevUrl=..., in debug builds only

Every platform runtime exposes what the connection is doing (connecting, connected, reconnecting(attempt), closed(reason)) and observes its stores again after a reconnect. A call made while the connection is down fails at once with UndraCallError.Unavailable instead of waiting, and a command that fails that way is not handed to onError, because the connection state already reports the drop. docs/DEV_LOOP.md has the details and a troubleshooting table.

No authentication. The server has none. Keep the loopback default unless a device has to reach it.

undra drift#

bash
undra drift ios.json android.json web.json          # where the three hosts diverged; the first file is the reference
undra drift ios.json android.json --exit-code       # exit 1 on any divergence (CI)
undra drift --schema schema.json ios.json web.json  # name and decode with this schema (outside the project)
undra drift --ignore Todos.add.title a.json b.json  # leave a parameter out of the comparison

Compares recordings of one user flow made on different platforms (undra dev --record, one server per platform) and prints where the hosts diverged at the boundary, one line per divergence: calls (a host call missing, extra, or made with other arguments; the sequence is diffed, so one skipped tap does not shift every later line), ports (a port method called another number of times, with other arguments, or answered differently by the platform's adapters), events (a host-pushed event missing or extra), observes (a signal observed on one side only) and state (a signal whose final value differs). The core is deterministic, so with the same inputs every platform gets the same change-sets: the drift that matters is in the inputs. Handles are matched by constructor and order (Todos#0); call ids, timer ids and t are never compared. Always ignored, and named in the header: the replies of Clock.* and Rng.*, the arguments of Timer.*, and, with a schema to find it, the Idempotency-Key header of Http.request. The exit status is 0 whatever is found unless --exit-code. Testing your app has the recording steps.

FlagMeaning
REFERENCE OTHER...the reference recording, then every recording to compare with it; each must carry the reference's schema hash (C0009 otherwise)
--schema <file>the schema that names the ids and decodes the bytes; default schema.json in the project directory, else the project's core is built and asked; outside a project, ids and bytes are compared and the header says so
--ignore <path>leave this out (repeatable): Todos.add.title a parameter, Http.request.req.headers a field inside one, Todos.add the whole arguments, Todos.todos a signal's final value
--exit-codeexit with status 1 when any divergence was found; the output is the same

undra doctor#

bash
undra doctor
undra doctor --platform ios
undra doctor --fix       # the commands that close every gap, as one block to paste (nothing is run)
undra doctor --json      # the report for tools

Checks every prerequisite that undra init, build, dev and the device benchmarks use, and prints one line per finding. Each finding says what was found (ok, missing, or the wrong version), the value it observed, the exact fix command and the heading of docs/ONBOARDING.md that explains it. It covers rustup, stable Rust at the minimum version and the Rust targets of the platforms (wasm32, the iOS device and simulator targets, the Android ABIs of undra.toml); full Xcode against the command line tools and a simulator runtime; the Android SDK, platform-tools, NDK r27, ANDROID_HOME and ANDROID_NDK_HOME, JDK 17, adb and whether a device or emulator is attached, and the Gradle wrapper; Node 20+ and npm; wasm-opt (optional: it makes the wasm core 10-20% smaller); free disk space (a warning under 10 GB); and whether undra is on PATH, since the build integrations run it by name. Checks that matter only to people working on Undra (the Kotlin compiler, the undra emulator) are marked for contributors.

Inside a project only the platforms named in undra.toml are checked; elsewhere it checks all of them. It exits with status 1 when something the project needs is missing. --fix prints the fix commands as one block with a comment per finding and runs nothing; --json prints each finding with its id, state, severity, observed value, fix commands and documentation link.

undra upgrade#

bash
undra upgrade                  # move the project to this undra, regenerate the bindings, print the notes
undra upgrade --dry-run        # show the lines that would change; write nothing
undra upgrade --no-bindgen     # move the pins only
undra upgrade --docs           # keep the core's doc comments in the regenerated bindings

Reads every place a project pins its Undra version (the core's undra dependency in Cargo.toml, which a rev or branch pin leaves pinned by the release's tag, [undra] version in undra.toml, @undra/runtime (and @undra/react-native, @undra/testkit) in package.json, the runtime's Kotlin modules (com.github.shreypdev.undra:runtime, :android-adapters and the optional ones) in the Gradle scripts, the Undra Swift package in the Xcode project, and UNDRA_VERSION in the CI workflow), moves them all to the version of this undra in the shapes undra init writes (one release, all from the Undra repository on GitHub: the release's tag, its npm asset's URL; the JitPack repository is added to settings.gradle.kts when a project's Kotlin pins move to it), regenerates the bindings and prints the migration notes of every release the project crosses: what you have to do, what behaves differently, what is new. Only the version text changes, so comments and formatting stay, and the files are written all or none; a version held in a Gradle variable is left and named. Your app's own project files are not edited. A project that depends on a checkout of the Undra repository is on whatever that checkout is: the command says so and changes nothing. A project newer than this undra is refused (error[undra::C0014]): update the CLI first.

undra adopt#

bash
undra adopt ../MyApp                          # detect platforms, write ../MyApp/undra/
undra adopt . --platform ios                  # only the iOS steps
undra adopt ../MyApp --undra-path ~/src/undra   # use a local checkout of Undra

Adds an Undra core to an app you already have, the conservative way. It writes one new undra/ directory (a core crate, a undra.toml and a UNDRA_ADOPT.md) and a list of the exact steps to wire it into the app, with the paths already filled in. The app's own project files are never modified; you make the few edits yourself.

It detects iOS from *.xcodeproj or Package.swift, Android from settings.gradle(.kts) plus AndroidManifest.xml, and web from a package.json (Vite, webpack, Next and so on). Flags: --platform <list>, --name <name> (the core's name; default the directory's name) and --undra-path <dir>.

undra.toml#

undra init writes every setting that matters, with comments.

undra.toml
[project]
name = "todo"
id = "com.example.todo"        # Android applicationId, iOS bundle id, Kotlin package base
platforms = ["ios", "android", "web"]

[core]
path = "core"                  # the crate bindgen, build and dev work on
# namespace = "todo_core"      # names the core's symbol, libraries and entry; default: its package name

[paths]
generated = "generated"        # written by `undra bindgen`; commit it or check it in CI
build = "build"                # written by `undra build`; do not commit

[undra]
version = "1.0.0"              # the release every dependency names; path = "…" points at a checkout instead

[bindings]
# swift_module = "TodoCore"
# kotlin_package = "com.example.todo.core"
# ts_scope = "app"
# ts_js_number = false         # i64/u64 as `number` instead of `bigint`
# swift_typed_throws = true    # port requirements: `throws(E)`; false emits plain `throws`
# swift_observation = "observable-object"   # Swift stores: "observation" (@Observable, iOS 17+) or "observable-object" (iOS 15+)
# lint_exclusions = "none"     # "beside" (the default) writes the linters' exclusion files beside the trees; "none" writes none

[ios]
deployment_target = "17.0"     # 15.0 or later; below 17.0 the Swift stores are ObservableObjects
# opt_level = "s"              # release builds: "s" small (the default), "z" smallest (calls about 1.5x slower), "3" fastest

[android]
abis = ["arm64-v8a", "x86_64"]
min_sdk = 26
# opt_level = "s"              # the same choice for the Android libraries

Commit generated/ or regenerate it in CI, and gate on undra bindgen --check so a stale binding cannot ship.

lint_exclusions in [bindings] decides whether undra bindgen writes the files that keep a repository's linters out of the generated trees (kotlin/.editorconfig, swift/.swiftlint.yml, ts/.eslintrc.json and ts/eslint.config.undra.mjs): "beside", the default, writes them; "none" writes none, for a repository that configures its linters at the root. The @file:Suppress line at the top of every generated Kotlin file is not one of these files and stays either way. Linters over the whole tree lists the files and the lines to add to a root configuration.

Errors#

CLI errors print as error[undra::C00NN] with what happened, why, and what to do. Macro errors in your Rust use the same teaching shape with E00NN codes; the catalogue is on the architecture page.

Edit this page on GitHub