# JavaScript API

`@glyphteck/veyl` is a JavaScript SDK over the same shared product owners used by web and iOS. Its high-level `open()` runtime and CLI currently target Node.js. Provider-neutral auth/passkey and account/vault owners are also available at `@glyphteck/veyl/auth` and `@glyphteck/veyl/account` for graphical and future platform adapters. The CLI does not contain separate account, chat, wallet, passkey, or support implementations. The documentation MCP exposes static package resources and never imports the runtime.

## Open an account runtime

```js
import {
  API_VERSION,
  VERSION,
  open,
} from '@glyphteck/veyl';

const veyl = await open({
  profile: 'runner',
  network: 'REGTEST',
  homeDir: '/secure/veyl-home',
  webUrl: 'https://veyl.glyphteck.com',
});
```

`open(options)` returns one local, account-scoped SDK runtime. `API_VERSION` versions this deliberately narrow public JavaScript surface, while `VERSION` is the shared product version. The runtime constructor, CLI command descriptors, internal cloud, machine-credential, passkey-transport, and raw account-session modules are not package-root exports.

The auth and account compositions consume explicit runtime ports and have no direct Node, Firebase-construction, Spark-construction, filesystem, machine-key, namespace-file, browser-process, or React dependency. The package's current high-level `open()` implementation comes from `src/runtime/node.js`, which supplies the Node ports. Web and iOS use `openAuth()` and `openAccount()` with their existing cloud, passkey-ceremony, vault crypto, Spark, cache, network, and diagnostics ports; React remains only a snapshot/lifecycle adapter.

Most product methods ensure login and vault unlock when saved account and vault keys are available. Long-running callers should explicitly login/unlock once and call `close()` when finished.

`open({ onOperation(event) })` can observe local firebase operations, default chat/presence WebSocket frame counts, and SDK wallet-claim calls. events contain operation names, lifecycle status, counts, latency, and cache/pending-write flags; they omit payloads, document paths, account/chat IDs, and capabilities. this hook observes the default adapters, not custom cloud/live ports or server internals, and its counts are not billing units. wallet-claim observations count the SDK invocation, not Spark's internal requests. see the repository's `costs/benchmarks.md` for controlled dev scenarios and provider reconciliation.

### Local access and connectivity

The shared account owner separates `localReady`, `online`, and `connection` in its snapshot. `localReady` means the exact locally signed-in account has enough encrypted bootstrap data for vault unlock; it is not server authorization. Unlock opens encrypted cached chats and wallet history without waiting for cloud services. Cached balance is display-only and must be labeled last known. `online` becomes true after cloud reads and online proof succeed; reconnect attaches services to the same unlocked session. Text sends wait up to 30 seconds for connectivity, then remain encrypted on-device as failed attempts for manual retry; reactions expire without a retry row. Avatar changes wait only for the current session, as described below. These are domain-owned pending operations, not a general cloud mutation queue. Live wallet readiness remains mandatory for payments.

Custom platform hosts may provide `bootstrapStorage.read(uid)`, `write(uid, snapshot)`, and `remove(uid)` to `openAccount`, and call `setInternetAvailable(false | true | null)` with platform reachability. Encrypt bootstrap data under the account/environment-bound install key; never put decrypted private content there. Standard web, iOS and Node adapters supply encrypted storage. Logout or observed revocation removes it.

Node authentication is currently process-local: a fresh SDK/CLI process still needs the network to authenticate, even with saved credentials. Cached access supports an already-authenticated long-running SDK process; saved username/profile metadata cannot establish an offline session. Web and iOS can restore their platform-persisted signed-in auth slots.

`connection.unavailable` is the shared offline-display signal, separate from account readiness in `connection.status`. Ordinary startup, local service initialization, and brief cache refreshes do not mean offline. Explicit device/cloud blocking and network errors report unavailability immediately; silent required server reads use the existing 15-second deadline. The cloud transport's `availability` port reports `false` when explicitly blocked and `null` when unknown, never treating an allowed test phase as proof of a server connection.

## Graphical auth owner

```js
import { openAuth } from '@glyphteck/veyl/auth';

const auth = openAuth({
  cloud,
  origin: () => window.location.origin,
  ceremony: {
    create: (options) => navigator.credentials.create({
      publicKey: decodePublicKeyOptions(options),
    }),
    get: (options) => navigator.credentials.get({
      publicKey: decodePublicKeyOptions(options),
    }),
  },
});

await auth.register({ label });
await auth.login({ uid });
const unsubscribeAuth = auth.subscribe((user) => console.log(user));

const unsubscribeLinks = auth.links.watch(user.uid, renderLinks, showError);
const link = await auth.links.create();
await auth.links.add({ token, label });
await auth.activateToken(sessionToken);

const inventory = await auth.passkeys.list(user.uid, {
  currentPasskeyId,
});
await auth.passkeys.delete(inventory.rows[0].id);

unsubscribeLinks();
unsubscribeAuth();
await auth.logout();
```

`openAuth()` owns the shared register, login, token, one-use link, passkey inventory, verified deletion, session observation, and sign-out choreography. `activateToken()` is the generic in-memory account-session activation boundary for a custom token returned by a token-only login, registration, or passkey-link flow; it is not nested under passkey links because those flows share the same isolated auth-slot operation. Never persist that token. The `cloud` input is a transport rather than a Firebase dependency. A platform supplies only the local credential ceremony: browser WebAuthn on web, `react-native-passkeys` on iOS, or a browser-assisted port in the Node runtime. browser adapters decode binary options and return the native credential object; ios returns its library's credential json. the shared owner extracts an explicit allowlist of authentication proof fields before any transport call. never serialize a raw browser credential with `toJSON()` or `JSON.stringify()`: extension results can contain encryption secrets. Errors are normalized at this boundary so every client receives the same recoverable registration and login outcomes. The platform still owns when to prompt, how to present the prompt, navigation, local credential storage, and UI feedback.

## Account profiles

`@glyphteck/veyl/account-profiles` owns one environment-scoped public remembered-account registry and exactly one active runtime. Platform storage supplies atomic `read`, synchronous-reducer `update`, and payload-free `subscribe` ports; `resolveAuthenticatedProfile` is the platform authority that maps those public rows to matching live isolated auth sessions. A remembered row alone is never authenticated. A client reserves a random profile id before authentication, exchanges the memory-only custom token inside that named auth slot, and commits the authenticated Firebase user's uid through `reservation.activate(fields, activation)`. If the uid already owns a durable profile id, the client adopts that authenticated session into the canonical named slot before discarding the provisional slot; neither the token nor the Firebase user enters registry storage. The owner awaits the previous runtime's `lock()` and `close()` before opening the selected profile. Every successful selection advances `lastUsedAt`; the owner exposes that descending recency order for UI and the resolver uses it for live-auth fallback. `switchTo(profileId)` independently verifies the requested profile through the resolver before invalidating the current runtime. `signOut()` retains the public row as an exact-account login shortcut; `remove(profileId)` forgets it. Both resume the most recently selected remaining live authenticated profile when available, while `resumeAuthenticated()` handles platform auth loss explicitly. External storage signals trigger an authoritative reread, and an externally removed active row closes locally without repeating the originating platform cleanup. The client exposes Login only when the resolver finds no live authenticated profile.

## Graphical account owner

`openLocalCache(cacheSeed, { uid, scope })` opens an encrypted store for either `account`, `MAINNET`, or `REGTEST`. account/chat state is independent of the wallet network; the session exposes it as `localCache` and exposes the selected wallet store as `walletCache`.

```js
import { openAccount } from '@glyphteck/veyl/account';

const account = openAccount({
  cloud,
  defaultNetwork: 'MAINNET',
  network: 'MAINNET',
  setNetwork,
  vaultCrypto,
  bootWallet,
  bootChat,
  chat: {
    appState,
    media,
    chatCrypto,
    chatWarming,
    preloadMessageMedia,
  },
  wallet: {
    appState,
  },
  openLocalCache,
});

const unsubscribe = account.subscribe(() => {
  console.log(account.getSnapshot());
});

// During onboarding, after authentication:
await account.profile.setUsername('alice');
await account.profile.setAvatar(preparedWebpBytes);

await account.createVault(password);
const session = await account.unlock(password);
await account.verifyVaultPasswordForChange(currentPassword);
await account.changeVaultPassword({ currentPassword, newPassword });
const unsubscribeChat = account.chat.subscribe(() => {
  console.log(account.chat.getSnapshot().chats);
});
const unsubscribeWallet = account.wallet.subscribe(() => {
  console.log(account.wallet.getSnapshot().value.balance);
});
const unsubscribeTransactions = account.wallet.transactions.subscribe(async () => {
  console.log(await account.wallet.transactions.getSnapshot().getTxListPage({ offset: 0, limit: 50 }));
});
const unsubscribeBitcoin = account.bitcoin.subscribe(() => {
  console.log(account.bitcoin.getSnapshot());
});
await session.walletReady;
const publicRequest = await account.payment.createRequest({ amountSats: 1250 });
console.log(publicRequest.link);
await account.profile.clearAvatar();

await account.support.submit('message', {
  platform: 'web',
  route: '/settings',
  appVersion,
});
await account.support.report(targetUid, { note: 'context' });

await account.user.getSnapshot().updateSettings({ moneyFormat: 'btc' });
await account.push.add(preparedPushLease);
await account.push.drop({ did: preparedPushLease.did });

const unsubscribePeers = account.peers.subscribe(() => {
  console.log(account.peers.getSnapshot());
});
const search = account.peers.openSearch('profiles');
search.search('@alice');
search.close();
unsubscribePeers();
unsubscribeBitcoin();
unsubscribeTransactions();
unsubscribeWallet();
unsubscribeChat();
account.lock();
unsubscribe();
await account.close();
```

`openAccount()` owns authenticated user observation, username and avatar publication, vault observation and creation, vault unlock/lock, encrypted settings/network selection, presence, late wallet readiness, public Bitcoin data, support/report commands, chat and peer/profile composition, account switching, and secret-bearing session teardown. Focused-chat presence is separately owned by the chat session's encrypted ephemeral live transport. The account snapshot exposes `user`, `vault`, `vaultReady`, `vaultError`, `session`, `wallet`, `walletError`, `network`, and `lockState`; `vaultReady` becomes true only after the backend authorizes and confirms the current vault snapshot, while a cached snapshot may warm `vault` without authorizing a guarded route. Stable domain owners such as `bitcoin` and `support` live directly on the returned account owner. Graphical password-change flows call `verifyVaultPasswordForChange(currentPassword)` before revealing the new-password step, then call the atomic `changeVaultPassword({ currentPassword, newPassword })` command. Both are account-bound local decryptions; the first immediately clears its temporary seed, while the second preserves and verifies the existing Vault Signature identity before replacing the authoritative ciphertext.

`account.profile` owns the server mutation after a platform has prepared avatar bytes or collected a username. Browser canvas work and native image manipulation remain platform-local; both then call the same `setAvatar`, `clearAvatar`, or `setUsername` command. Avatar selection/removal previews immediately through the shared user snapshot, waits while offline, and publishes once authenticated profile reads recover. Only the latest selection is retained, in memory for this session; lock/close discards unsent work and rejects its promise with `cancelled`. The promise resolves after server acknowledgment and the confirmed bytes/version enter the ordinary avatar cache. No pending avatar is persisted or compared through extra server reads. Username changes still require connectivity.

`account.bitcoin` restores the last observed public USD price from `bitcoinPriceStorage` (`read()` / `write({ price, updatedAt })`). The platform port is shared across accounts within the same installation and realm. The snapshot exposes `priceUpdatedAt` and `priceFromCache`; a null observation time means the configured $80,000 default, never a fetched rate. Cache hydration restores price only, not fee estimates or server/wallet readiness. The existing cloud listener refreshes the saved rate without additional requests. Confirmed self/peer avatar bytes similarly hydrate from their exact cached version independently of cloud readiness.

`account.peers` is the shared peer-directory owner used by Node, web, and iOS. `openAccount()` supplies its chat, wallet, blocked-user, and encrypted-cache sources directly, including missing-profile chat cleanup. Graphical adapters only subscribe to the same snapshot and profile selectors already defined by core. `openSearch('profiles')` creates profile-only search; web may also use `openSearch('mainmenu')` for its combined local-action and remote-profile menu. Active searches track peer and blocked-user changes and release those subscriptions when cleared or closed.

`account.chat` is the one account-scoped protocol-4 chat owner used by Node, web, and iOS. It owns notes/direct/group creation, stable logical chat IDs, membership epochs, encrypted list/inbox convergence, epoch-spanning history, delivery, messages, receipts, signed encrypted chat settings, member changes, leaving, and deletion. It clears every route, cache, timer, and key-bearing state on lock/account switch. Graphical providers only expose this owner through React; platform media preparation and native behavior enter through explicit ports.

`account.wallet` is the stable account-scoped wallet composition used by Node, web, and iOS. It owns the one live `core/wallet/session.js` engine, its transfer store, cached pre-Spark display state, transaction aggregation, wallet-derived peer recency, late Spark attachment, and lock teardown. `getSnapshot()` preserves the shared `{ value, txValue }` contract; `transactions` exposes aggregate/search/chart data and `transfers` exposes focused list and keyed subscriptions. Graphical wallet providers only adapt those SDK subscriptions and add browser/native behavior through the `wallet` platform ports.

the wallet value snapshot also exposes spark-local swaps. call `quoteSwap({ assetIn, assetOut, amountIn, signal? })` with null for bitcoin, an exact token identifier for usdb/mainnet or usdv/regtest, and an integer base-unit string. review the returned input, expected/minimum output, included `feeBps`, `slippageBps`, provider, and expiry before calling `executeSwap(quote.id)`. read `movements` for persisted status; `refreshMovements()` checks existing operations and `refundMovement(id)` requests an eligible refund. these commands reuse the same unlocked wallet and payment queue. an input receipt lost after dispatch remains unresolved and blocks another swap; never replace it with a new send. provider acceptance alone is not completion. mainnet amm uses exact Spark receipts for `completed`/`refunded`. regtest execution uses `dispatched`/`refund-dispatched` for verified finalized withdrawal/refund requests; these states still wait for the wallet credit before another swap. its `payout` contains the provider proof and exact amount, while `receipt` remains reserved for an exact Spark transfer. mainnet usdb has no assumed dollar valuation. see the repository's `guidelines/swaps.md` for provider/custody boundaries and current live gates.

settled swap activities expose `movement.cost`; summarized history/events expose `swap.cost` and `swap.amountReceived`. cost is the actual input value minus verified output value at an independent observation saved on confirmation, not the provider fee estimate. its shape is `{ usdUnits, usdDecimals, bitcoinUsdPrice, observedAt, recordedAt }`: signed integer-string dollar units divided by `10 ** usdDecimals`, positive for a cost and negative for a gain. the frozen bitcoin dollar price also fixes the historical sat equivalent. the account uses its existing subscribed `account.bitcoin` observation, without adding a watcher for each swap. cost is null for old operations without a saved valuation, observations older than five minutes, or assets without an independent dollar valuation; never replace it with today's price or an assumed stablecoin peg. costs and receipt references are saved in the canonical activity cache before history publishes them.

`account.payment.createRequest({ amountSats, expirySeconds? })` creates an exact-amount bitcoin request with a Lightning invoice and embedded Spark invoice. `account.payment.createRequest({ tokenIdentifier, amountUnits, expirySeconds? })` creates a request for a reviewed token on the account wallet network, using integer base units as a string. Both return `{ request, link, receiveRequest }` with the same signed v2 request format, binding the public recipient identity, wallet, network, asset, exact amount, and expiry. Token requests have an empty invoice and `receiveRequest: null`; they are reviewed and paid in veyl, without converting to bitcoin. The default expiry is 24 hours and the maximum is seven days. The link contains no private conversation identifiers. Bitcoin requests can also be paid from an external Lightning wallet. Opening either link never sends money automatically.

Encrypted settings remain commands on `account.user`, so graphical clients and the high-level Node runtime mutate the same normalized owner instead of creating another settings facade. `account.push` owns only authenticated server lease add/drop; APNs permission, token, environment, badge, presentation, and tap behavior remain iOS ports. `delete({ confirm: true, password })` verifies the currently observed vault inside the same account- and session-bound operation that drains decryptable inbox/chat membership, marks every discovered chat deleted, commits server account deletion, clears the unlocked encrypted cache, clears local avatar state, and locks. A verification, discovery, chat-marking, or server failure leaves the unlocked account and cache available for retry; platform credential, remembered-account, push-token, and final auth cleanup runs only after that shared command commits.

Platform code still owns the WebAuthn/native passkey ceremony itself, Face ID and secure storage, foreground/background and browser events, navigation, toasts, and UI; the surrounding auth state machine belongs to `openAuth()`. `unlock(password, options)` accepts platform lifecycle callbacks such as `onSeedDecrypted`, `onSettingsUnlocked`, and `onSessionReady` without importing those platforms. wallet startup remains asynchronous: chat keys, vault proof, and the encrypted account cache make the account usable independently of wallet startup. `online` describes account authorization; wallet startup failures appear in `walletError` without disabling chat or calls. transient wallet retries reuse completed authorization, and `session.walletReady` resolves when a boot attempt succeeds. wallet reconciliation uses bounded idle scheduling independent of navigation.

## Errors and mutation outcomes

Ordinary input and policy mistakes throw normal errors with a useful message so a caller can correct the request. Money mutations add a stable contract only when the SDK cannot prove whether the operation committed:

```js
try {
  await veyl.wallet.send('@alice', 10, {
    operationId: 'agent-job-42',
  });
} catch (error) {
  if (error.code === 'operation_outcome_unknown') {
    console.error({
      operation: error.operation,
      operationId: error.operationId,
      outcome: error.outcome,
      retryable: error.retryable,
    });
  }
}
```

an unknown payment outcome has `retryable: false`, including lightning. the shared payment owner records an encrypted attempt before dispatch and retains it if the response is lost. its `operationId` identifies that durable attempt; a caller-supplied label or lightning transfer id does not authorize a retry. only repetition of that payment is blocked until its outcome is reconciled; independent recipients, amounts and chat requests remain available against the live spendable balance. chat requests retain one identity per network, chat and epoch-qualified message reference. paying the same request after restart or a failed chat confirmation reuses its original receipt without submitting money again. an ordinary send without a request identity remains blocked when its exact rail, recipient set, asset and amount match an unresolved intent. check authoritative transaction history; never infer failure from a timeout or absence in a partial history page. direct cli errors and persistent-session transport preserve these fields. this local protection does not coordinate separate devices or survive deletion of the application's storage.

## Account

Creating an account accepts Veyl's [Terms](https://veyl.glyphteck.com/terms#terms), which include the community rules.

```js
const accountKey = await veyl.account.create({
  username: 'runner',
  network: 'REGTEST',
});

await veyl.account.login();
const local = await veyl.account.me();
const terms = await veyl.account.terms();
await veyl.account.acceptTerms();
await veyl.account.logout();
await veyl.account.logoutAll();
await veyl.account.delete({ confirm: true });
```

- `create` makes an account authenticated by a local machine credential, publishes its account type as `sdk`, and returns its account key directly.
- `createPasskey({ username, webUrl, onUrl })` creates a normal passkey account through a browser-assisted WebAuthn flow, installs a local machine credential for future CLI sessions, and returns that account key directly.
- `login({ username?, key?, saveKey? })` authenticates with an account key. The key can instead come from `open({ accountKey })` or `VEYL_ACCOUNT_KEY`.
- `loginPasskey({ username?, webUrl?, onUrl? })` authenticates through the browser-assisted passkey flow.
- `me` returns the local account summary without forcing login.
- `terms` reports the canonical Terms link, whether the account's acceptance is current, and when it last accepted the Terms.
- `acceptTerms()` records the current agreement through the shared user owner without forcing a vault unlock.
- `logout` signs out and tears down only this runtime.
- `logoutAll` revokes every product session generation, tears down this runtime locally, and stops a persistent CLI owner after its in-flight work drains.
- `delete({ confirm: true, key? })` drains decryptable inbox state, marks all discoverable chats deleted, destroys the complete account/network encrypted cache scope, proves vault possession inside the same destructive operation, deletes identifiable account data, removes the local profile, and stops a persistent CLI owner after its in-flight work drains. `key` is required only when the runtime has no saved vault key.

Account summaries report identity, network, local credential/vault availability, public wallet/chat keys, auth kind, public `app` or `sdk` account type, and current signed-in/unlocked state. Linking a passkey promotes the type to `app`; account and vault keys are never included in summaries.

## Vault

```js
const vaultKey = await veyl.vault.create({ saveKey: false });
await veyl.vault.unlock({ key: vaultKey });
await veyl.vault.lock();
const { mnemonic, network, accountNumber } = await veyl.vault.export({ key: vaultKey });
```

Vault creation returns the vault key directly. That key unlocks the vault and every feature derived from it. Creation and unlock use the shared local encryption, seed derivation, Spark wallet, chat, cache, peer, and transfer owners. `export` returns the Spark mnemonic, network, and account number and must be treated as secret output. Preserve all three for independent recovery: mainnet uses account number 1 and regtest uses 0. See [wallet recovery](https://github.com/glyphteck/veyl/blob/main/guidelines/wallet-recovery.md).

## Profile, settings, and cache

```js
await veyl.profile.show();
await veyl.profile.getPresence({ timeoutMs: 15_000 });
await veyl.profile.setPresenceVisibility('private'); // appear offline on every account device
await veyl.profile.setPresenceVisibility('public'); // share online status and last active
await veyl.profile.uploadAvatar(webpBytes);
await veyl.profile.deleteAvatar();
await veyl.profile.setChatAdmission({
  direct: 'closed',
  groups: 'closed',
  allow: ['@owner'],
});

await veyl.settings.show();
await veyl.settings.update({ moneyFormat: 'btc' });

await veyl.cache.show();
await veyl.cache.clear();
```

`uploadAvatar` accepts prepared WebP bytes and uses the shared profile/avatar backend owner. `setChatAdmission` publishes a public `direct` mode (`open`, `requests`, or `closed`), a `groups` mode (`open` or `closed`), and a fixed-size padded set of private pair-capability commitments for up to eight allowed direct peers. Allowed peers may be usernames or public chat keys; their identities are resolved locally and are not published in the policy. Missing policies remain open for ordinary accounts, while malformed present policies fail closed. Existing membership does not bypass a closed policy: disallowed chats are hidden and unavailable for admission-controlled actions, but policy changes never delete a direct or leave a group. `groups: closed` also prevents new group membership.

Settings use the shared normalization and encrypted settings document. Changing `walletNetwork` locks the current vault so the next unlock boots the selected network. Cache methods operate on the same vault-encrypted display and media cache as the other clients.

Presence visibility is an independent account-wide relay policy, not an encrypted settings field. `getPresence` waits for the acknowledged policy and returns `{ visibility, pending, error, availability, status, lastActiveAt }`. `setPresenceVisibility` accepts only `public` or `private` and resolves after the shared presence owner confirms the revisioned change; connection, conflict, and timeout errors reject explicitly. An unloaded policy never implies public visibility. The matching CLI commands are `veyl profile presence` and `veyl profile presence-set <public|private>`.

## Peers

```js
await veyl.peers.show('@alice');
await veyl.peers.search('ali', { count: 20 });
await veyl.peers.list({ count: 30, recent: true });
await veyl.peers.blocked();
await veyl.peers.block('@alice');
await veyl.peers.unblock('@alice');
```

Peer resolution accepts `@username`, username, chat public key, or wallet public key when the operation supports it. Blocking self is rejected. A block independently submits a narrow user report, retires every private chat route containing that peer, and returns `reported` separately from `blocked`; report failure never prevents the block. Peer/profile results are public projections and do not expose local cache internals. `peers.show` authoritatively refreshes a cached identity; a confirmed missing profile evicts it and deletes loaded chats through the shared missing-peer owner. Normal chat/wallet actions keep the shared cached fast path, while their server-side operations still validate the authoritative route they mutate.

Profile and peer results include `presence: { availability, status, lastActiveAt }`; `active` is derived only from `availability === 'online'`, never from a stored profile flag. Availability is `unknown`, `online`, or `offline`; unknown/private/unobserved peers must not be presented as confirmed offline. Last-active timestamps are coarse buckets, not exact interaction times. Presence reflects the current bounded observation set; `@active` / `@online` search filters observed local peers rather than querying a global directory of online users.

## Chat

```js
const chats = await veyl.chat.list({ count: 30 });
const notes = await veyl.chat.notes();
const direct = await veyl.chat.openDirect('@alice');
const group = await veyl.chat.create(['@alice', '@bob'], { title: 'builders' });
const activeGroup = await veyl.chat.create(['@alice', '@bob'], {
    title: 'builders',
    initialMessage: 'first light',
});

await veyl.chat.addMembers(group.id, ['@carol']);
await veyl.chat.updateSettings(group.id, {
  title: 'night builders',
});
await veyl.chat.updateAvatar(group.id, preparedWebpBytes);
await veyl.chat.updateAvatar(group.id, null); // restore the member stack
await veyl.chat.kickMember(group.id, memberChatPK);

const groupPage = await veyl.chat.readById(group.id, { count: 30 });
await veyl.chat.markReadIn(group.id);
const enteredGroup = await veyl.chat.enterById(group.id, { readPolicy: 'auto' });
const groupMessage = await veyl.chat.sendTo(group.id, 'hello everyone');
await veyl.chat.replyIn(group.id, groupMessage.message.id, 'follow up');
await veyl.chat.reactIn(group.id, groupMessage.message.id, '+1');
enteredGroup.leave();

await veyl.chat.leaveMembership(group.id);

// Direct-peer conveniences resolve the canonical direct chat first.
const page = await veyl.chat.read('@alice', { count: 30 });
await veyl.chat.markRead('@alice');

const entered = await veyl.chat.enter('@alice', {
  count: 30,
  readPolicy: 'auto',
});
const unsubscribe = entered.subscribe((snapshot) => {
  console.log(snapshot.messages);
});
await entered.loadOlder();
entered.leave();
unsubscribe();

const sent = await veyl.chat.send('@alice', 'hello');
await veyl.chat.retry('@alice', failedCid);
await veyl.chat.reply({ peer: '@alice', messageId: sent.message.id }, 'reply');
await veyl.chat.react('@alice', sent.message.id, '+1');
await veyl.chat.unreact('@alice', sent.message.id);
await veyl.chat.save('@alice', sent.message.id);
await veyl.chat.unsave('@alice', sent.message.id);
await veyl.chat.update('@alice', sent.message.id, 'edited'); // own text, strictly within 10 minutes
await veyl.chat.delete('@alice', sent.message.id);
await veyl.chat.retention('@alice', '24h');
await veyl.chat.deleteChat('@alice', { cleanup: true });
```

Focused live presence and writing use the same encrypted ephemeral room as the graphical clients:

```js
const compositionId = 'ab'.repeat(16);
await veyl.chat.enterLive(direct.id);
await veyl.chat.markTyping(direct.id, true, compositionId);

// Renew while the agent is still producing output.
await veyl.chat.markTyping(direct.id, true, compositionId);
await veyl.chat.markTyping(direct.id, false, compositionId);
await veyl.chat.sendTo(direct.id, 'finished', { compositionId });
await veyl.chat.leaveLive(direct.id);
```

Passing the same 32-hex `compositionId` to the final text send replaces the temporary writing row in place. A cancellation should call `markTyping(chatId, false)` without a composition id to clear the handoff. Node clients receive the realm-matched live transport by default; an explicit `chat.live` port remains available for embedded runtimes.

All materialized chats are stable logical objects. `notes`, `openDirect`, and `create` without `initialMessage` first return local provisional routes; they consume no KeyPackage and create no remote state or chat-list row until first content is sent. Direct open and first send both resolve the complete encrypted owner history before creating a fresh random direct. Only a membership change rotates a fresh epoch beneath an existing `chatId`; title, picture, retention, reads, reactions, and ordinary messages remain inside the current epoch. Added members do not receive old keys. A chat that becomes a group keeps permanent group lineage and never replaces a later ordinary direct chat.

Group pictures are 512x512 WebP bytes no larger than 256 KiB. `updateAvatar` encrypts them locally, uploads one opaque immutable ciphertext, and distributes only its encrypted capability through signed chat settings. Passing `null` restores the automatic member stack. Raw avatar capabilities are deliberately not accepted by the public SDK. `forever` is reserved protocol state and is not exposed by official clients.

`readById` pages current and owner-authorized historical epochs until `count` visible messages are covered or history ends. `markReadIn` writes the latest applicable receipt by stable chat id. Direct `read`/`markRead` are canonical-peer conveniences. Every projected message includes stable chat target and verified actor identity; group actions never need a fake direct peer.

`readMessageIn(chatId, messageId)` reads and verifies one exact durably committed encrypted message without consulting optimistic local rows, mounting a retained route, or advancing read state. It is useful for reconciling a caller-owned id after an ambiguous send outcome.

When direct admission is `requests`, `list()` includes the authenticated pending row with `messageRequest: true` and `listen()` emits one `message-request` event containing its decrypted initial message. It is not a writable chat until `resolveRequest(chatId, 'accept')` succeeds. `resolveRequest(chatId, 'reject')` consumes the one-time Welcome and removes it without creating a chat.

`enterById` mounts a logical chat route and returns a stable handle with `getSnapshot`, `subscribe`, `loadOlder`, `markRead`, and `leave`. `enter(peer)` does the same after resolving the canonical direct. Re-entering the same `chatId` reuses the handle. `readPolicy` is `auto`, `manual`, or `none`. Route-local retention holds and explicit-delete behavior match web/iOS.

Attachments:

```js
await veyl.chat.sendAttachment('@alice', {
  bytes,
  type: 'file',
  mimeType: 'application/pdf',
  name: 'document.pdf',
  caption: 'read this',
});

await veyl.chat.sendAttachmentMany(['@alice', '@bob'], attachment);
await veyl.chat.forward('@alice', messageId, ['@bob']);
const downloaded = await veyl.chat.readAttachment('@alice', messageId);
```

the caller supplies/consumes bytes; shared owners derive paths, encrypt/decrypt, upload/download, create message actions, and project media cache state. `chat.forward` forwards an existing attachment. forwarded attachments cannot be saved forever, and forwarding reuses the original encrypted object without extending its expiry or transmitting management authority. participants in the original conversation keep the existing save/unsave authority.

audio attachment objects may include `duration` in fractional seconds and optional `waveform`: canonical padded base64 encoding of 32 unsigned bytes, representing peak-normalized rms amplitude in equal-duration bins. compact audio events expose the same validated `message.waveform`, and sharing/echoing should preserve it. generic sdk/cli uploads do not decode audio to generate it; the command schema does not accept it, and graphical clients display a stable generic waveform when it is absent. that placeholder is presentation-only and is never attached to a message as measured audio. real waveform data stays inside the encrypted message.

media metadata is removed automatically before encryption, including when recognizable media is sent as a generic file. supply prepared jpeg, png, webp, gif, or supported mp4/mov/m4a bytes; video must use h.264 or hevc. normalize other image/video formats first. raw heic/avif/webm, unsupported movie tracks/codecs, and malformed media reject with `media-sanitization-failed` instead of uploading the original. orientation, color transforms, animation timing, audio/video synchronization, and display matrices are retained. this does not scrub ordinary documents, visible/audible content, or authored filenames/captions. public `account.profile.setAvatar` also sanitizes prepared webp bytes before publishing.

## Invites

```js
const created = await veyl.invite.link({ kind: 'chat' });
const parsed = veyl.invite.read(created.link);
```

`kind` may be `join`, `chat`, `send`, or `request`; send/request links may include integer `sats`. These are direct URL strings produced and parsed by `core/invite.js`. SDK does not expose camera or QR behavior.

## Wallet

bitcoin amounts are integer sats. `send` and `request` also accept an exact integer string or bigint of token base units when `tokenIdentifier` is supplied. the network-specific asset registry validates the identifier, issuer, and decimals; token values are never converted through btc or a floating-point number.

structured `wallet_send` and `wallet_request` commands use `{ peer, amount, tokenIdentifier? }`, with `amount` as an integer string. `wallet_pay_request` derives the asset and full amount from the request.

```js
await veyl.wallet.balance();
await veyl.wallet.address();
await veyl.wallet.claim({ count: 100 });
await veyl.wallet.send('@alice', 10, { operationId: 'agent-job-42' });
const request = await veyl.wallet.request('@alice', 10);
await veyl.wallet.pay(request.message.requestId, {
  operationId: 'request-payment-42',
});
await veyl.wallet.payInvoice(encodedInvoice, {
  amountSats: 10,
  operationId: 'invoice-payment-42',
  maxFeeSats: 5,
});
await veyl.wallet.transactions({ count: 50, offset: 0 });
await veyl.wallet.transaction(txId);
await veyl.wallet.search('@alice', { count: 50 });
```

Wallet boot, signing, balance, transaction aggregation, peer attribution, claim, payment-request validation, and wallet mutation serialization are shared. `raw: true` adds JSON-safe SDK detail only where supported.

payment history and `transaction` events expose persisted veyl activities, not individual provider receipts. a completed swap is one `swap:<operation id>` row with both receipts in `sources`. ordinary payments have provider/asset-qualified ids. `wallet.transaction(id)` accepts either an activity id or an included receipt id and returns the owning activity; use the returned activity id for list identity. `wallet.transactions` uses newest-first `offset`/`count` pagination for every asset; `raw: true` adds full activity records, never the hidden swap legs. balances and settlement verification still use provider receipts independently.

watchtower-exited funds are available through the same wallet owner:

```js
const { leaves } = await veyl.wallet.recoveries();
const review = await veyl.wallet.prepareRecovery({
  leafId: leaves[0].leafId,
  destinationAddress: bitcoinAddress,
  satsPerVbyteFee: 2,
  maxFeeSats: 1000,
});
// display the exact source, address and fee cap before execution.
const recovery = await veyl.wallet.recover(review);
```

this recovery requires spark operators. the destination and source output are network-checked; signed transaction bytes are encrypted and persisted before broadcast. a failed broadcast can resume the saved `review` returned by `wallet.recoveries()` without signing another payment. `broadcast` is not `confirmed`: only bitcoin chain confirmation completes recovery. the cli equivalents are `wallet recoveries`, `wallet prepare-recovery leaf-id address sats-per-vbyte maximum-fee-sats`, and `wallet recover 'review-json'`. these methods do not provide an independent exit backup.

bitcoin exit material is retained separately from ordinary wallet history. while unlocked, these methods can read the encrypted cache or restore cloud ciphertext even when spark startup fails:

```js
const tree = await veyl.wallet.exitBackup(); // validated local material, or null
const envelope = await veyl.wallet.exportExitBackup(); // encrypted portable file
await veyl.wallet.restoreExitBackup(); // fill an absent local copy from opaque storage
await veyl.wallet.importExitBackup(envelope); // rejects a different retained local snapshot
await veyl.wallet.refreshExitBackup(); // requires a ready Spark wallet for fresh ownership
```

the cli provides `wallet exit-backup`, `wallet export-exit-backup`, `wallet restore-exit-backup`, `wallet refresh-exit-backup`, and `wallet import-exit-backup <backup-path>`. the first returns private transaction material; prefer the encrypted export for files. neither the envelope nor cloud storage includes the mnemonic. store that separately with the network and account number.

independent recovery can run without a veyl login or spark instance:

```js
import { restoreWalletExitBackup } from '@glyphteck/veyl';

const tree = await restoreWalletExitBackup({
  mnemonic, // obtain privately; never put it in command-line arguments or logs
  network: 'MAINNET',
  environment: 'prod',
  envelope, // an exported encrypted file; with this field, no network request occurs
});
```

omit `envelope` to retrieve the encrypted r2 copy using seed-derived read capabilities. the environment is the original account's storage realm, independent of the bitcoin network. a package validates signatures and ancestry; it does not by itself establish current unspent coverage. use the independent withdrawal owner below to inspect chain state and execute eligible paths. see [recovery limits](../../../guidelines/wallet-recovery.md).

```js
const status = await veyl.wallet.exitStatus();
const review = await veyl.wallet.prepareExit({ destination, feeRate: 2 });
// show the exact destination, amount, excludedSats, fee cap, funding and waits.
await veyl.wallet.startExit(review); // explicit money authorization
await veyl.wallet.resumeExit(); // resume the same saved withdrawal
```

these methods require vault unlock, not spark readiness. unresolved local payments/swaps/recoveries block authorization. the encrypted journal retires ordinary spark spending on this installation and is retained through cache cleanup. stop using the same wallet on other devices. the fixed fee rate/cap may require waiting for lower bitcoin fee demand; automatic repricing is not implemented. a fragmented tree's fee funding can exceed its balance, and sub-dust paths can be excluded explicitly.

```js
import { openIndependentWalletExit } from '@glyphteck/veyl';

const exit = await openIndependentWalletExit({
  mnemonic, network: 'REGTEST', environment: 'dev',
  envelope, // omit for initial cloud retrieval, or if journal already exists
  journal: durableJournal,
});
const review = await exit.prepare({ destination, feeRate: 2 });
// obtain approval of this exact review before starting:
await exit.start(review);
await exit.resume();
exit.close();
```

`durableJournal.read()` returns saved `Uint8Array` ciphertext or null; `write(bytes)` must atomically persist the bytes and return `true` only after durable completion. the caller must enforce one writer. do not use an in-memory journal for actual funds. reopen with the same mnemonic, realm, network and journal to preserve signed progress and authorization; no spark, firebase login or envelope is needed then. the initial cloud backup contains exit material, not an already-approved destination or execution progress. preserve the encrypted journal separately when moving an active withdrawal to another installation. bitcoin chain connectivity is still required to prepare or progress an exit.


the regtest prototype asset is usd veyl (`USDV`), with six decimals and a fixed test valuation of exactly one usd per token. the one-million-token supply is held by `@faucet`. mainnet separately allows the exact reviewed `USDB` identifier; it has no assumed dollar valuation. the network-specific registry owns both identities.

```js
const balance = await veyl.wallet.balance();
const usdv = balance.tokens.find(asset => asset.ticker === 'USDV');
// the registry has already verified this record; use its identifier for actions.
const request = await veyl.wallet.request('@faucet', '1250000', {
  tokenIdentifier: usdv.tokenIdentifier, // 1.25 usdv, expressed in base units
});
await veyl.wallet.send('@alice', '1000000', { tokenIdentifier: usdv.tokenIdentifier });
const page = await veyl.wallet.transactions({ tokenIdentifier: usdv.tokenIdentifier, count: 50 });
```

`balance.tokens` reports `ownedUnits`, `availableUnits`, `unavailableUnits`, formatted `amount`, `usdPrice`, and `usdValue`. null quantities mean unavailable rather than zero; inspect `tokenBalancesReady` and `tokenBalanceError`. only usdv has the fixed test valuation. values have no mainnet backing or redemption rights.

token history uses the same `offset`/`count` activity pagination, returning `{ transfers }`. `tokenIdentifier` selects activities with that asset in their source references, including swaps into or out of it. provider cursors are internal and rejected by this public history API. ordinary token payments carry exact `amountUnits`, asset, direction, status and timestamp; swaps retain both source references and their conversion identity. pages are encrypted in the wallet-identity/network cache. token amounts are excluded from btc-only charts and aggregates. pay a token request with the ordinary `wallet.pay(requestId)` command; its encrypted asset identity determines which balance is spent and must match the confirmation.

token requests use message type `tokenreq`, and their encrypted settlement actions use `tokenreqpay`. bitcoin retains `req` and `reqpay`. sdk token request events carry `tokenIdentifier`, `amountUnits`, `paidAmountUnits`, and `remainingAmountUnits` without an `amountSats` field. ios `0.76.0 (26)` ignores the new token message types; its bitcoin wallet remains usable, but it cannot display or pay token requests. never place token base units in a bitcoin request payload.

the current request renderer and payment review preserve token identity on web and ios. the web cash selector chooses the payment asset independently of display currency: showing a token amount in btc never authorizes spending bitcoin. token invoices/qr preserve the canonical asset, and stablecoin swaps use the wallet movement owner described above. the approved ios build's limitations are described above.

## Lightning and withdrawal

both `lightning.pay` and `wallet.payInvoice` for a lightning invoice require an explicit `maxFeeSats`. quote first if needed, apply your spending policy, and authorize that limit. the gui shows the amount, maximum fee, maximum total, destination, and network before sending.

```js
const invoice = await veyl.lightning.invoice(10, { memo: 'coffee', expirySeconds: 3600 });
await veyl.lightning.quote(invoice.encodedInvoice, { amountSats: 10 });
await veyl.lightning.pay(invoice.encodedInvoice, {
  amountSats: 10,
  maxFeeSats: 5,
  transferId: '019c0000-0000-7000-8000-000000000001',
});
await veyl.lightning.receive(receiveId);
await veyl.lightning.send(sendId);

const review = await veyl.withdrawal.prepare(address, 1000, { speed: 'MEDIUM' });
await veyl.withdrawal.confirm(address, 1000, {
  speed: review.exitSpeed,
  feeQuoteId: review.feeQuoteId,
  feeAmountSats: review.feeAmountSats,
  operationId: review.operationId,
});
```

`withdrawal.quote` returns a quote without building the full review. quote and prepare do not spend or broadcast, but the spark sdk may restructure wallet leaves while producing an exact quote. confirmation requires the reviewed quote id, fee amount and generated operation id together. retain that review when retrying: confirmation of the same completed operation returns its saved receipt; an uncertain operation is queried and never submitted again. the operation id is the 32-character hexadecimal value returned by preparation, not an arbitrary job label. these records protect the local installation, not independent devices or deleted application data.

## Passkeys

```js
const rows = await veyl.passkeys.list();
const link = await veyl.passkeys.createLink({ webUrl });
await veyl.passkeys.delete(link.id, { webUrl, onUrl });
```

Rows combine pending links and verified passkeys, mark the passkey currently authenticating the session, and expose shared `canDelete` eligibility. Link URLs are one-time credentials and should not be logged or stored after handoff. Deleting a verified passkey may require browser-assisted verification by another passkey; deleting a pending link does not.

## Support

```js
const ticket = await veyl.support.submit('description', {
  id: 'stable_submission_id', // reuse this id for retries of the same draft
  attachments: [{ name: 'screenshot.png', mimeType: 'image/png', bytes }],
});
console.log(ticket.id);
await veyl.support.report('@alice', { messageId, note: 'context' });
```

Support submissions use the same ticket owner as web/iOS. The agent classifies them after submission. A ticket accepts up to 4,000 characters and three files of up to 5 MiB each (PNG, JPEG, WebP, GIF, PDF or UTF-8 text). Attachments are deliberately shared with the operator for support. Abuse reports separately use account-scoped encrypted-message lookup, report metadata and evidence reservation. When `messageId` selects an attachment, the runtime reads and uploads the attachment as report evidence before submitting because attachment reports are invalid without their evidence path. Automated tests should not submit fake reports or support messages to shared environments.

## Live events

```js
const controller = new AbortController();

await veyl.listen({
  replay: false,
  signal: controller.signal,
  onEvent(event) {
    if (event.type === 'message') {
      void veyl.chat.reply(event, 'received');
    }
  },
});
```

`listen` subscribes to the shared live chat-list and transfer-store owners. It emits `ready`, `message`, `message-delete`, `transaction`, and `error` events. Message events carry a stable `chatId`, verified actor/member summaries, and a qualified message target. Text messages include `message.mentions`: an authenticated array of half-open utf-16 spans shaped as `{ start, end, kind: 'member', chatPK }` or `{ start, end, kind: 'everyone' }`. The visible `message.text` remains the exact authored copy across profile renames or deletion; use the member `chatPK`, not reparsed display text, when identity matters. Replies retain `message.replyId`; reaction controls are emitted as `message.type === 'rxn'` with `reactTo` pointing at the original message, so agents can preserve the exact action instead of flattening it into presentation text. Compact, incoming-only events and transient chat subscriptions are the defaults: the encrypted chat list acts as a wake index, so a changed chat opens briefly, processes its current and history pages, and releases instead of retaining one listener per chat. Set `compact: false`, `incomingOnly: false`, or `persistentChats: true` only when the caller needs those broader behaviors. A headless viewer may set `read: true, relayReads: true`; the listener then advances each peer read from its existing decrypted batch through a short encrypted live-room lease before emitting the event, while the durable write remains coalesced.

Use `listen` for account-wide agent wakeups. Use `chat.enterById` when an agent is actively inside any notes/direct/group chat; use `chat.enter(peer)` only as a canonical-direct convenience.

## Local fleet owner

`openFleetOwner` is the canonical long-running host for one local agent managing many Veyl accounts. The host keeps one fleet root seed and derives a separate account key, vault key, and Veyl master seed for every monotonically allocated account index.

```js
import {
  FLEET_MANIFEST_VERSION,
  createFleetSeed,
  openFleetOwner,
  saveFleetManifest,
} from '@glyphteck/veyl';

const homeDir = '/secure/veyl-home';
const created = await createFleetSeed({
  homeDir,
  name: 'agents',
});
// Back up created.seed once. Its local file is owner-only mode 0600.

await saveFleetManifest({
  version: FLEET_MANIFEST_VERSION,
  name: 'agents',
  nextAccountIndex: 0,
  profiles: [],
}, { homeDir });

const fleet = await openFleetOwner({
  homeDir,
  manifest: { name: 'agents', homeDir },
});

await fleet.provision({
  username: 'worker-one',
  roles: ['read'],
  network: 'REGTEST',
});

fleet.use(agentPolicy);
await fleet.start();
await fleet.run('worker-one', (client) => client.wallet.balance());
await fleet.stop();
```

```json
{
  "version": 2,
  "name": "agents",
  "nextAccountIndex": 2,
  "profiles": [
    {
      "profile": "worker-one",
      "accountIndex": 0,
      "username": "worker-one",
      "roles": ["read"],
      "network": "REGTEST",
      "state": "ready",
      "enabled": true
    },
    {
      "profile": "worker-two",
      "accountIndex": 1,
      "username": "worker-two",
      "roles": ["read"],
      "network": "REGTEST",
      "state": "ready",
      "enabled": true
    }
  ]
}
```

The root format and derivation domains are versioned. `nextAccountIndex` only moves forward, so removing a profile never reuses its key slot. The manifest accepts only public ownership and routing data; account and vault keys are rejected. Derived keys exist in memory while an account is used, and local fleet profile files contain only public identifiers.

Losing the root loses every derived account. Back it up as carefully as a wallet seed.

An existing account cannot retroactively acquire a derived master seed. Adopting one requires a one-time authenticated handoff that installs its root-derived account key and encrypts the existing master seed under its root-derived vault key without changing wallet/chat material.

The Veyl namespace seed is separate from a fleet root. `veyl namespace init` creates that owner-only local key once. When its default file exists under the fleet `homeDir`, provisioning automatically signs a short-lived claim bound to the exact derived machine credential so a reserved canonical username can use the normal public account-creation transaction. The seed is never stored in the manifest or an account profile. Other fleet operators do not have this key and cannot claim the reserved namespace.

The owner opens one ordinary public client per enabled ready profile, runs account boots with bounded concurrency, tags events with their manifest account, preserves per-account policy order, fails visibly on backlog overflow, and closes every client on stop. An owner-only PID lock prevents two local processes from operating the fleet. It reclaims a dead same-host PID after a crash but never signals or replaces a healthy owner. Shared owners still control chat ordering, wallet serialization, encryption, cache, and account revocation.

`createFleet` and `createFleetFromManifest` are lower-level host primitives for environments that deliberately provide another secret owner. They do not derive or persist fleet secrets.

## Fleet action journal

Long-running agents should durably record external effects before executing them:

```js
import { openFleetActionJournal } from '@glyphteck/veyl';

const journal = await openFleetActionJournal({
  homeDir,
  name: 'agents',
});

await journal.run('worker-one:reply:source-message-id', {
  account: 'worker-one',
  kind: 'chat.send',
  replaySafe: true,
  source: { peer: '@alice', messageId: 'source-message-id' },
}, () => client.chat.send('@alice', 'hello', {
  cid: '_deterministic_reply_cid',
}));
```

Completed results survive restarts. An interrupted replay-safe chat action may run again with the exact same CID. An interrupted payment becomes `fleet_action_reconciliation_required`; the caller must reconcile the request or transfer before proceeding.

`openFleetEventCheckpoint({ homeDir, name })` stores a bounded set of processed message IDs per account and chat. Use it to suppress already-handled source events across restarts; use the action journal separately for external-effect outcomes. Agent replay expands older history until it finds that chat's checkpoint, up to a bounded 500-message limit, instead of assuming the latest window covers the whole downtime. If no checkpoint is found within the bound, startup reports an error rather than silently skipping or replaying an ambiguous range. A one-time adoption can baseline the visible event window without running behavior, preventing a replacement agent from replying to historical messages on its first start.

## Local profiles and secrets

The default profile store is `~/.veyl`; override it with `homeDir` or `VEYL_HOME`. Profile directories use mode `0700`, and credential/secret/session files use mode `0600`.

Standalone profiles may include Firebase uid, username, network, account authentication material, vault metadata, public wallet/chat keys, and optionally the vault key. Root-derived fleet profiles intentionally store no account or vault key. Use `saveKey: false` / `--no-save-key` when another secret owner supplies a key.

An embedding process can expose its already-unlocked client through the normal owner-only CLI socket without opening a second runtime:

```js
import { startSessionRuntime } from '@glyphteck/veyl';

const controller = new AbortController();
const session = startSessionRuntime(veyl, {
  profile: 'agent',
  name: 'harness',
  signal: controller.signal,
});

// Other local processes use: veyl --profile agent --session harness ...
controller.abort();
await session;
```

The session owns a user-only local socket, drains in-flight calls on stop, and locks the shared client's vault when it exits.

## Close

```js
await veyl.close();
```

`close` locks the vault, stops live owners and wallet connections, signs out Firebase, and closes cloud resources.
