A minimal, working example of an android and ios app built on the P2P
Trust Stack: it takes a username and a passphrase, creates a self-certifying VTA
(did:scid:vh), and registers it with a utility server over the trust stack.
It also allows logging in to multiple accounts, logging out, setting an agent
name on the utility server and looking up other agent names and retrieving
their dids and displaying various status.
It is a deliberate subset of the reference agent-app — same API, same flow,
allows the creation of VTA's,
The whole app is ~740 lines of Node (www/nodejs/index.js) and ~800 lines of
web UI (index.html + main.js + styles.css).
┌───────────────────────────────────────────────┐
│ WebView UI (www/) │
│ create VTA · log in · log out │
│ set agent name · look up agent name │
│ show info / status │
│ username · passphrase · progress · result │
└───────────────┬───────────────────────────────┘
│ @capawesome/capacitor-nodejs (event bridge)
┌───────────────▼───────────────────────────────┐
│ Embedded Node app (www/nodejs/) │
│ composition root — mirrors agent-app │
│ createNodeHost → createAgentStack → start │
│ discovery → connectUtility → pushIdentity │
│ ┌──────────────────────────────────────────┐ │
│ │ @p2p-trust-stack/app-api (npm) │ │
│ │ @p2p-trust-stack/core (npm) │ │
│ └──────────────────────────────────────────┘ │
└───────────────────────────────────────────────┘
The UI never touches crypto, SQLite or the network: it sends one request and renders the events the Node app posts back.
npm install # Capacitor 8 + the Node.js plugin
npm run setup # vendor the stack, shim argon2, patch the plugin
npm run android:init # one-time: npx cap add android
npm run android:build # cap sync + gradle assembleDebug
npm run android:run # install, launch, stream logs
npm run ios:init # one-time: npx cap add ios
npm run ios:build # xcodebuild for the connected device
npm run ios:run # install, launch, pull the runtime logThe committed www/nodejs/bootstrap.env already points at a working relay, so
you can build straight away. To use a different one, run
npm run init:bootstrap first — see Relay configuration.
npm run check (also run by setup) statically verifies that the WebView and the
Node app agree on the bridge protocol, and loads the WebView script against a DOM
stub to catch anything that throws on startup.
Before installing on Android, keep the screen on — the plugin re-copies the whole Node project out of the APK on every install, and a backgrounded app can be frozen mid-copy.
adb shell svc power stayon usbis the easy fix (see the gotchas). On iOS the same class of problem bites differently: backgrounding the app suspends the Node runtime mid-operation (see iOS).
Then enter a username and a passphrase in the app and tap Create VTA & register.
The embedded Node app depends on the published
@p2p-trust-stack/coreand@p2p-trust-stack/app-api(pinned to0.1.0inwww/nodejs/package.json), sonpm run setupinstalls them from the npm registry — no checkout of the stack is needed to build this repo.
The app has a small menu — five screens, each a thin wrapper over one part of the
TrustStack API:
| Menu | API calls | What happens |
|---|---|---|
| Create VTA | host.createAgent → createAgentStack({ adopt }) → stack.start() → connectUtility() → connectivity.pushIdentity() |
Creates the identity and its encrypted keystore, activates it, then registers it with the utility server |
| Log in | host.unlockAgent → stack.adopt() → stack.start() → connectUtility() |
Unlocks an existing VTA with its passphrase and reconnects it (no re-registration) |
| Set name | names.register({ name, namespace }) |
Registers vta:<namespace>:<name> — a two-phase commit over TSP |
| Look up name | names.resolve(nameUri) |
Resolves a name to its DID, peer ID and addresses |
| Show info | identity.current(), connectivity.status(), names.list() |
The active VTA's VID/DID/peer, connectivity and registered names |
Plus a Log out in the session bar, which stops the agent (identity.deactivate())
so you can activate a different VTA. The embedded runtime itself never restarts —
it is single-instance for the life of the app process.
The Log in screen lists the VTAs already on the device (host.listAgents()) as
tappable chips, so you do not have to remember the name.
This mirrors agent-app/web/server.ts closely; the additions are the
mobile-specific environment setup and the handshake described below.
The WebView sends one event per action and the Node app replies with status
progress lines plus one terminal event:
createVta → created login → loggedIn logout → loggedOut
setName → nameSet lookupName → nameLookup getInfo → info
hello → hello (any) → error
Both sides name these as plain strings, so a typo on either side is a silent
no-op at runtime. npm run check compares the two sides statically and fails
the build on a mismatch.
agent-app-mobile/
├── capacitor.config.json webDir: www, nodeDir: nodejs
├── www/ the served web app (source — no build step)
│ ├── index.html · styles.css
│ ├── main.js bridge client: one request, four events
│ └── nodejs/ the embedded Node app
│ ├── bootstrap.env ← the relay it registers with (committed)
│ ├── package.json depends on the published @p2p-trust-stack/*
│ └── index.js the composition root
├── patches/
│ ├── capacitor-nodejs-cxx20.patch the required upstream fix (documented)
│ └── argon2-shim/ pure-JS @node-rs/argon2 replacement
├── scripts/
│ ├── setup.mjs install + shim + patch + verify (both runtimes)
│ ├── init-bootstrap.mjs write www/nodejs/bootstrap.env
│ ├── check-bridge.mjs WebView ↔ Node protocol consistency
│ ├── check-ui.mjs load the WebView script against a DOM stub
│ ├── run-android.mjs install, launch, tail logs
│ └── run-ios.mjs build, install, launch, pull the runtime log
nodeDir is resolved relative to webDir, which is why the Node app lives
at www/nodejs and not at the repo root.
Which bootstrap/relay server the app registers with is set by
www/nodejs/bootstrap.env — a committed file that is bundled into the app:
BOOTSTRAP_PEER_ID=12D3KooWKF2Tmh21NNSH5gWazfbmFEtTZS9XT1uXk8njFAy5jUda
BOOTSTRAP_HOST=relay.truststackers.com
BOOTSTRAP_TCP_PORT=4001
BOOTSTRAP_WS_PORT=4002
BOOTSTRAP_ENABLE_RELAY=true
There is deliberately no BOOTSTRAP_SEED_HEX in it — see
A client carries no seed.
Change it with:
# Point at a different relay, given its peer ID (public — no secret needed):
npm run init:bootstrap -- --host relay.example.com --peer-id 12D3KooW...
# Or let it DERIVE the peer ID from the relay's seed, or its 24-word BIP-39 phrase.
# The seed is used in memory for that one derivation and is never written:
npm run init:bootstrap -- --host 203.0.113.5 --seed-hex <64 hex chars>
npm run init:bootstrap -- --host relay.example.com --words-file ./phrase.txt
npm run android:build # rebuild for it to take effectinit-bootstrap prints the multiaddrs it will dial, so a wrong host or peer ID
is visible immediately rather than as a silent LAN-only fallback. It uses only
@p2p-trust-stack/core — no other checkout of any repo is involved.
Two constraints force this shape, and both are worth knowing before you write your own app:
- The core reads
<cwd>/.env.base-agent.start()does not pass abootstrapEnv, so there is no way to inject the config through the public API. On a phoneprocess.cwd()is/at boot, and the real config lives in app-private storage created at first launch — so there is no build-time directory that becomes the runtime cwd. The values have to be bundled with the app;index.jscopies them to<app data>/runtime/.envandchdirs there. - Capacitor strips dotfiles from the APK. Its Android
ignoreAssetsPatternincludes.*, so a file named.envin the web assets is silently dropped.bootstrap.envis not a dotfile, so it survives; only the runtime copy is named.env.
This is a CLIENT. It dials a peer ID — public information — and never needs the key
behind it. The core's loadBootstrapClientEnv() enforces that: a client .env carrying
BOOTSTRAP_SEED_HEX is refused rather than ignored, because a seed in a client file
is a leaked private key and silently tolerating it is how it stays there.
Earlier versions of the core had only the SERVER loader, which requires the key, so this
file used to carry a zeroed placeholder — and www/nodejs/index.js used to add one back
at boot for a core that predated loadBootstrapClientEnv. Both are gone: the pinned core
exports the client loader, npm run init:bootstrap writes no seed under any input, and
www/nodejs/index.js copies the file to the runtime .env verbatim, adding nothing.
Verbosity is set in the same bundled file, www/nodejs/bootstrap.env:
# ── Logging (optional) ──
# silent | error | warn | info | debug (default: warn)
P2PTS_LOG_LEVEL=info
# 1 turns on the [CEREMONY] OOBI→RFI→RFA trace lines (independent of the level).
CEREMONY_TRACE=1
Rebuild for a change to take effect (npm run android:build / npm run ios:build), then
read the result with npm run android:logs or npm run ios:logs.
Why a file and not a command-line variable: P2PTS_LOG_LEVEL=info node … is POSIX-only
syntax that PowerShell and cmd cannot parse, and this app has no npm script at all —
the Capacitor plugin launches the Node runtime. A bundled config file is the only place
the setting can live on a phone.
Two things www/nodejs/index.js does to make that work:
- Applies the block before the stack is imported, by translating the two keys into
process.env— the logger and the ceremony trace both read those per call, so this works on every core version. (The four Node components useconfig/logging-env.tsinstead; it reaches the same result through a config slot a core this old does not have.) - Routes the stack's own lines through
log()viasetLogSink(). Without it, an info-level run would look like it produced nothing on iOS: the plugin forwards stdout to logcat on Android, but a Home-screen iOS launch discards stdout — which is exactly whylog()mirrors every line intoDocuments/runtime/agent.log. Stack lines now land in both destinations, prefixed[stack:info],[stack:warn], and so on.
Everything else is stock Capacitor. These three are not optional, and each is
handled by npm run setup.
@p2p-trust-stack/core uses the built-in node:sqlite (added in Node 22.5).
The plugin ships nodejs-mobile 18.20.4, which has no node:sqlite at all, so
a stock setup fails. @capawesome/capacitor-nodejs downloads its runtime at
Gradle build time and honours two project variables, so setup points them at a
revived Node 24 build:
// android/variables.gradle
nodejsMobileAndroidUrl = 'https://github.com/gmaclennan/nodejs-mobile/releases/download/v24.18.0-0/nodejs-mobile-android-24.18.0-0.zip'
nodejsMobileAndroidSha256 = 'ceb86b0b8130006195a60cd37393ebe0fd665b644ce8d5674dfba1da65d3be28'The zip's layout (bin/<abi>/libnode.so + include/node/…) matches what the
plugin's CMake expects, so it is a drop-in. Use the full flavour — -lite
drops ICU, inspector and sqlite.
The plugin caches the download under a fixed filename and only re-extracts when the directory is missing, so after changing the URL you must delete
~/.gradle/caches/capawesome-capacitor-nodejs.setupdoes this for you: it readsNODE_MAJOR_VERSIONout of the cached headers and evicts the cache when it is not 24.
On iOS the runtime is a prebuilt NodeMobile.xcframework plus Node headers
rather than something Gradle fetches, so setup unpacks it into the plugin's
ios/libnode/ from a pinned URL and checksum — the same Node 24 build:
https://github.com/gmaclennan/nodejs-mobile/releases/download/v24.18.0-0/nodejs-mobile-ios-24.18.0-0.zip
sha256 849526f5861a235e97d4ecc7a3272f1d6bb63d324716dbe1cc2bb5019992257a
The plugin's postinstall would fetch nodejs-mobile 18.20.4 and does honour
CAPACITOR_NODEJS_IOS_URL/_SHA256 — but only at npm install time, which
makes the override easy to lose. Doing it in setup keeps the pinned values in
the repo, next to the Android ones. It also checks the version rather than
the framework's existence, for the same reason as the Gradle cache: the
postinstall skips whenever a framework is present, so on a fresh clone (where
npm install runs before setup) an existence check would quietly keep Node 18.
Node 24's V8 headers hard-require C++20; the plugin's CMakeLists.txt sets no
standard, so the NDK clang default (C++17) fails with ~20 errors
(v8config.h: error: "C++20 or later required."). setup inserts:
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)Recorded in patches/capacitor-nodejs-cxx20.patch. This affects any Node ≥ 22
runtime, not just ours — worth reporting upstream.
On iOS the same header requirement surfaces in a different place. SwiftPM
compiles the plugin's sources itself, and its Package.swift declares
cxxLanguageStandard: .gnucxx17, so setup rewrites that one token to
.gnucxx20. No source patch is needed there — unlike Android, nothing sets the
standard out of band, so the manifest is the only thing that has to change.
core uses the native @node-rs/argon2 addon to derive the AES key that
encrypts a VTA's private keys. It cannot load inside this embedding:
dlopen failed: cannot locate symbol "napi_get_undefined"
libnode.so does export the N-API symbols — the problem is link scope: the
plugin loads Node via System.loadLibrary("node") (RTLD_LOCAL), so the addon
dlopen'd from inside Node cannot see them. This was verified against both the
Node 18.20.4 and Node 24.18.0 runtimes, so it is a property of the embedding, not
the runtime version.
setup therefore installs patches/argon2-shim over
node_modules/@node-rs/argon2. The shim is ~75 lines: the core calls exactly one
function with fixed parameters (Argon2id, 19 MiB, t=2, p=1, 32-byte output) from
an async context, and those parameters are stored in the keystore row — so the
derivation stays byte-compatible. It uses @noble/hashes, which core already
depends on, and the async variant so the KDF doesn't block the UI bridge.
This also means the app works on iOS, where native addons are not available at all.
Six things about running the stack on a phone are not obvious. All are handled in
www/, and all are worth knowing before you write your own app.
Importing @p2p-trust-stack/app-api has a module-level side effect: it builds
defaultRegistry = new AgentRegistry(), whose constructor calls
resolveP2ptsHome() and then mkdirSync(). With no home passed, that resolves to
the platform default — on Android, /data/.p2pts, which an app cannot create:
Error: EACCES: permission denied, mkdir '/data/.p2pts'
at ensureP2ptsHome (core/dist/platform/p2pts-home.js:216:5)
at new AgentRegistry (app-api/dist/runtime/agent-registry.js:215:18)
at app-api/dist/runtime/agent-registry.js:239:32 <- module scope
So the stack is imported dynamically, after process.env.P2PTS_HOME is
pointed at a writable directory. A static import crashes the runtime before any
of your code runs.
See Relay configuration. In short: the core reads
<cwd>/.env, and on a phone there is no build-time directory that becomes the
runtime cwd — so the values ship inside the app and are copied into place at boot.
The Node project directory is overwritten on app updates. Everything mutable —
P2PTS_HOME, the runtime .env, the databases — lives under
app.datadir() (/data/user/0/<pkg>/files).
Node starts once per app launch; there is no stop(). The app therefore treats
each action as a one-shot guarded operation, and "log out" stops the agent
(identity.deactivate()), not the runtime. process.exit() is also disallowed by
the App Store, so the entry point never calls it.
The Node app announces itself at boot, but that message travels Capacitor's event
channel and is dropped if the WebView has not attached its message listener
yet — Capacitor logs No listeners found for event ready. The WebView therefore
also asks, once isReady() reports the runtime is up:
Nodejs.addListener('message', handleMessage);
Nodejs.addListener('ready', handshake); // plugin's ready event
void handshake(); // …and in case it is already uphandshake() sends hello, which the Node app answers with its version and the
agent list. Both paths are idempotent, so whichever fires first wins.
The plugin copies the Node project out of the APK into app storage, and it does so
whenever the APK's lastUpdateTime changes — i.e. on every install, not just
the first. That is ~56 MB / ~6,000 files. If the app is backgrounded mid-copy,
Android's cached-app freezer suspends the process and the copy stalls
indefinitely until the app is foregrounded again (it looks like a hang, with no
log output at all). Keep the screen on during an install:
adb shell svc power stayon usbIf adb devices reports the phone as unauthorized and no "Allow USB debugging?"
dialog ever appears on it, adb reconnect is usually enough — it re-runs the
handshake, no need to revoke authorizations on the device.
iOS suspends a backgrounded app, which freezes the Node runtime wherever it was. The WebView keeps its last painted frame, and the operation neither finishes nor fails — it just stops. This is the same class of problem as Android's "keep the screen on" note, but here it applies to using the app, not only to installing it: start creating a VTA, switch away, and the create is frozen until you come back.
It is also why the relay reports unreachable after a while. The libp2p socket
does not survive suspension, so the circuit has to be re-established on resume —
the core's relay-health layer tracks exactly that, and says so:
Cannot set name: the relay connection to the utility server could not be re-established. Your change has been kept locally and will be sent automatically once the connection is back.
Nothing is lost and it recovers on its own, but the in-flight operation has to be
repeated. Anything that must survive backgrounding needs a beginBackgroundTask
assertion, which the plugin does not provide.
There is no logcat equivalent. A Home-screen launch discards stdout, and
xcrun devicectl device process launch --console — the only supported way to
attach — fails with CoreDeviceError 10002 on this Xcode. So log() in
www/nodejs/index.js mirrors every line into Documents/runtime/agent.log,
which npm run ios:logs pulls back off the device:
npm run ios:logs
# or by hand:
xcrun devicectl device copy from --device <udid> \
--domain-type appDataContainer --domain-identifier <bundle-id> \
--source Documents/runtime/agent.log --destination ./agent.logThe file is truncated on each launch, so it always describes the current run — which is what made the iOS bring-up debuggable at all.
ios/ is generated and gitignored, so run-ios.mjs passes signing on the
command line rather than editing the project. It reads the team from the "Apple
Development" certificates in your keychain, preferring a paid one (free teams
issue 7-day profiles), and AGENT_APP_IOS_TEAM overrides it.
A bundle identifier is unique across all teams, not just within one. If your
personal team has already claimed the ID in capacitor.config.json, a paid team
cannot register it:
Failed Registering Bundle Identifier: The app identifier "…" cannot be registered to your development team because it is not available.
Override it per build instead of editing the repo:
AGENT_APP_IOS_BUNDLE_ID=com.example.agentapp npm run ios:runSeparately, a team with a pending Apple Developer Program License Agreement cannot issue profiles at all, and reports it as:
PLA Update available: You currently don't have access to this membership resource.
That is fixed at developer.apple.com, not in Xcode.
android/ is generated, and it comes out of Capacitor 8.5.3 pinned to Gradle
8.14.3 + AGP 8.13.0. Newer versions exist (Gradle 9.8.1, AGP 9.4.0), and a
current Android Studio is itself built against AGP 9.4.0 — but AGP 9 is off
Capacitor 8's supported path (Capacitor 9, which would move to it, is still
alpha), and Capacitor pins AGP 8.13.0 in three separate build.gradle files.
Jumping to AGP 9 therefore means patching all three and migrating the generated
Groovy DSL to AGP 9's new DSL. For a public example repo that is the wrong trade:
it would stop building for anyone on Capacitor's supported path.
What that means in practice:
-
It builds. Verified on Android Studio 2026.2 with its bundled JDK 25:
./gradlew assembleDebugsucceeds. -
But JDK 25 + Gradle 8.14.3 is an untested combination. Gradle only lists support for running on JDK 25 from 9.1.0 onward, and AGP 9.4.0 requires Gradle ≥ 9.6.0. It works here; it is simply outside the matrix, so a future Gradle or AGP bump may turn it into a hard failure rather than a warning.
-
If you want every version inside its supported range without touching Capacitor's pins, point the Gradle daemon at an older JDK rather than upgrading Gradle — Gradle 8.14 supports JDK 8–24, and AGP 8.13 requires JDK 17+:
# android/gradle.properties org.gradle.java.home=/path/to/jdk-21
In Android Studio the same setting is Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JDK.
Verified on a Galaxy S23 Ultra (Android 16 / API 36, arm64-v8a), on an
Android emulator, and on an iPad Pro (M4, iPadOS 26.6.1), with Capacitor
8.5.3, @capawesome/capacitor-nodejs 0.2.0 and the Node 24.18.0 runtime.
Android — P2PTS_HOME = /data/user/0/com.truststackers.agentappmobile/files/p2pts:
host: P2PTS_HOME = /data/user/0/com.truststackers.agentappmobile/files/p2pts
create: creating the VTA "bob" (did:scid:vh, Ed25519/X25519)
vta: VTA ready · VID DP2JZTCZ8curFoGh06JzXdfNOpTD6y9yzgQaA-6YSidM
agent: Listening on port 24000.
agent: Registering with LAN addrs: /ip4/192.168.1.222/tcp/24000/p2p/12D3KooW…
agent: Connected to bootstrap server. Discovered 4 peers. (relay available)
agent: Circuit-relay reservation active — reachable via relay from other networks.
identity: bob · did:scid:vh:1:z9R8uva3yuBTQS2x2AaBXJchwxougi856t6ybJJsWrWKe
discovery: attempt 1: 4 peer(s) on the mediator — utility server found
utility: connected to the utility server
register: identity pushed to the utility server
iOS — the same flow, plus the capability probe that matters there:
runtime · node v24.18.0 · ios/arm64 · WebAssembly yes · node:sqlite yes
create: creating the VTA "joe" (did:scid:vh, Ed25519/X25519)
vta: VTA ready · VID DKvpzUGwyeGr4vZe7cM5Ld4ViSHAfQP_DAiHWcQFxijT
agent: Listening on port 24000.
agent: Connected to bootstrap server. Discovered 13 peers. (relay available)
agent: Circuit-relay reservation active — reachable via relay from other networks.
discovery: attempt 1: 13 peer(s) on the mediator — utility server found
utility: connected to the utility server
register: identity pushed to the utility server
Create, name registration, name lookup, log out and log back in are all exercised on both platforms.
WebAssembly yesis worth stating on iOS. Apple forbids JIT, so the runtime runs a jitless V8 that exposes noWebAssemblyglobal of its own; nodejs-mobile installs a pure-JS polyfill (deps/polywasm) at launch to restore it. That is what lets libp2p-noise load at all — it hashes through@chainsafe/as-sha256, a WebAssembly module — so without it the node would start and then never complete a handshake. Everything runs interpreted, which makes iOS noticeably slower than Android for the same work.
The stack dependencies are pinned to @p2p-trust-stack/core@0.1.0 and
@p2p-trust-stack/app-api@0.1.0 from the npm registry. libp2p is the package
that talks to the relay, so that path was re-verified on the new set twice:
on the host (relay connected, 12 peers, utility server found, name list read
back) and again on the iPad (13 peers, circuit-relay reservation active,
identity pushed to the utility server).
Not included, by design:
- The rest of the stack — relationships, messaging, credentials, communities,
profiles, delegations. All are reachable through the same
TrustStackobject this app already holds; each is one more domain method and one more screen.
Requires a utility server registered on the same bootstrap/relay. If none is present the app still creates the VTA and connects, then reports clearly that there was nothing to register with (it retries discovery for 30 s first). Name registration needs that utility-server connection too — it is a two-phase commit over TSP.
@capawesome/capacitor-nodejs: add C++20 to the CMake config, and.gnucxx20toPackage.swift. Without both, no Node ≥ 22 runtime can be used on either platform.@p2p-trust-stack/app-api: thedefaultRegistrymodule-level singleton makes the package un-importable on a platform where the defaultP2PTS_HOMEis not writable. Deferring that construction (or readingP2PTS_HOMElazily) would remove the need for the dynamic-import dance.base-agent.start()cannot be given abootstrapEnvthrough the public API even thoughconnectToBootstrapaccepts one, which forces the.env-plus-chdirworkaround.- The plugin re-copies the whole Node project on every install (keyed on the
APK's
lastUpdateTime), which is slow and can stall when the app is backgrounded. Keying on the project's own content hash would fix it. This is Android-only — on iOS the plugin runs the project straight out of the read-only app bundle. - The plugin's iOS side offers no way to survive backgrounding. A
beginBackgroundTaskassertion aroundstart()— or a documented hook for one — would let an in-flight operation finish rather than freezing until the app is foregrounded again.
Dual-licensed:
- GPL-3.0-only — see
LICENSE; or - a commercial license — see
COMMERCIAL-LICENSE.md.
NOTICE declares the LicenseRef-TrustStackers-Commercial
reference, lists third-party attribution, and records that the embedded Node
runtime (nodejs-mobile) is downloaded at build time rather than redistributed.
App Store builds require the commercial license. Apple's App Store terms impose additional restrictions on recipients that GPL-3.0 forbids, so the GPL branch cannot be conveyed through the App Store. Android builds are unaffected and ship freely under GPL-3.0.
This is a reference implementation, provided "AS IS", pre-1.0 and without
support — see SECURITY.md.
TRADEMARK.md covers the marks and the application
identifiers; any fork must rebrand. All contributions require the
CLA — see CONTRIBUTING.md.