Skip to content

@flowsta/holochain ​

SDK for integrating Holochain apps with Flowsta Vault.

@flowsta/holochain provides functions for agent identity linking, Vault sign-in, document signing, and CAL-compliant backups. It wraps Flowsta Vault's IPC endpoints into a simple TypeScript API.

Current version: 3.10.3. v3 is a breaking release - error states no longer read as "no data"; see Migrating to v3 and the notes through this page.

What changed in 3.4 - 3.10

  • 3.10.2 - 3.10.3 - keepRegistered registers each note once, reads a listing at the server's 32-note cap as no sign of a stall, and judges a stall only on the server the conductor was configured with: conductorServer names it when it is not the first in servers; registrations on the others are counted in registeredElsewhere.
  • 3.10.0 - keepRegistered keeps a device listed on its bootstrap server(s) when the conductor's own registration has stalled, and hands the conductor the peers it has not heard of (see Keep the device listed). Where devices meet is the conductor's config, not the SDK (Where the devices meet).
  • 3.9.0 - openWebSignedIn({ clientId, redirectUri }) sends the person from a desktop app to its web app already signed in (see openWebSignedIn); localAgentPubKey on authenticateWithVault for a Vault 1.6.1+ that remembers a desktop app's origin.
  • 3.8.0 (not published to npm; first published in 3.9.0) - pairs with Vault 1.6.1: getAppNetworkSecret({ clientId, appName }) gives your app one private-network secret per identity, the same on every one of the person's devices (see One identity, several devices); getAppSecret({ label }) is the derived secret itself; retrieveFromVault({ across: 'devices', device }) reads one named device's copy.
  • 3.7.0 - pairs with Vault 1.6.0 (one identity on several devices): retrieveFromVault({ across: 'devices' }) reads the newest backup under a label on any of the person's devices and returns fromDevice (null = this device); listVaultBackups() returns otherDevices when other devices hold backups for your app. Without the option nothing changes, and older Vaults ignore it.
  • 3.6.0 - signDocument publishes the signature to the Sign It network for a linked app (new publish option, defaults to true when the app is bound; new published result field; new PublishForbiddenError and QuotaExceededError). Before 3.6.0 the SDK never asked the Vault to publish, so every SDK-made signature stayed local with actionHash: null. The backup label default latest is now really applied when you omit label (backupToVault, retrieveFromVault, restoreFromVault, startAutoBackup). getVaultStatus returns did. SigningDnaNotInstalledError is deprecated - the Vault never emits that code.
  • 3.5.0 - pairs with Vault 1.5.0 (the identity switcher): getVaultStatus returns activeIdentity, identityEpoch, instanceId; onIdentityChanged compares epochs; the GET calls send expected_identity too; the resolver prefers a locked Vault holding your identity over an unlocked one holding someone else's.
  • 3.4.0 - the resolver probes all three ports in parallel on every call and ranks the answers; signDocument and authenticateWithVault send expected_identity; new getVaultIdentity() and reconnectIdentity(); onIdentityChanged starts from the bound identity.

Installation ​

bash
npm install @flowsta/holochain

Agent Linking ​

linkFlowstaIdentity ​

Request an identity link from the user's Flowsta Vault:

typescript
import { linkFlowstaIdentity } from '@flowsta/holochain';
import { decodeHashFromBase64 } from '@holochain/client';

// The Vault returns a standard-base64 Ed25519 signature; the zome wants its 64 bytes.
const base64ToSignature = (b64: string): Uint8Array =>
  Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));

const result = await linkFlowstaIdentity({
  appName: 'ChessChain',
  clientId: 'flowsta_app_abc123',
  localAgentPubKey: myAgentKey,   // uhCAk... format
});

// Commit to your DHT
await appWebsocket.callZome({
  role_name: 'my-role',
  zome_name: 'agent_linking',
  fn_name: 'create_external_link',
  payload: {
    external_agent: decodeHashFromBase64(result.payload.vaultAgentPubKey),
    external_signature: base64ToSignature(result.payload.vaultSignature),
  },
});

Linking also binds (v3)

On success, linkFlowstaIdentity records the Vault identity it linked with (persisted in localStorage where available, with an in-memory fallback). From then on the write-shaped calls - backupToVault, signDocument, authenticateWithVault - throw IdentityMismatchError when the Vault present is DEFINITELY a different identity; reads rely on the Vault's own 409 answer. Apps that manage links themselves can call bindVaultIdentity(agentPubKey) / getBoundIdentity() / clearBoundIdentity() directly, agentKeysMatch(a, b) compares keys across their base64url and base58 encodings (null = can't compare - refusal requires certainty), and onIdentityChanged(cb) polls for identity switches so your UI can react before a call refuses (see After an identity switch). The binding is one slot per origin - the identity your app operates under now, overwritten by the next linkFlowstaIdentity. If your app keeps data for more than one Flowsta identity on the same device or origin, name that storage with partitionKeyFor(agentPubKey) (v3.3.0): the first 16 hex characters of SHA-256 over the agent key's raw bytes, the same for both spellings of a key and the same key the Flowsta Vault and Flowsta's own apps use for their per-identity folders, so the agent key itself never appears in a folder or database name.

The SDK finds the Vault (v3; parallel probe since 3.4.0)

With no ipcUrl, every call probes 127.0.0.1:27777, 27778 and 27779 in parallel and picks the best Vault that answered (resolveVaultUrl is exported): unlocked under the identity this app is bound to, then holding the bound identity while locked (Vault 1.5.0 names its identity even locked - 3.5.0), then any unlocked Vault, then any initialized one, then any answer; ties go to the lower port. So a locked Vault, or another user account's Vault, on 27777 no longer hides yours on 27778, and on a shared computer a locked Vault that is yours outranks an unlocked one that is not. When nothing answers, the last URL that did (or port 27777) is returned and the call reports its own not-found semantics. An explicit ipcUrl is used verbatim.

getFlowstaIdentity ​

Query linked agents on your DHT. Returns an array of linked agent public keys (as raw bytes):

typescript
import { getFlowstaIdentity } from '@flowsta/holochain';

const linkedAgents = await getFlowstaIdentity({
  appWebsocket,
  roleName: 'my-role',
  zomeName: 'agent_linking',  // optional; this is the default
  agentPubKey: someAgentKey,  // Uint8Array from @holochain/client
});

// linkedAgents is Uint8Array[] - array of linked agent public keys
if (linkedAgents.length > 0) {
  console.log(`Linked to ${linkedAgents.length} Flowsta identities`);
}

getVaultStatus ​

Check if Vault is running and unlocked. Returns a VaultStatus. From v2.3.0 the result also carries displayName and profilePicture for the currently-unlocked identity; from v2.4.1 it also carries webUsername (the unique global username the user claimed at flowsta.com). Renders "Signed in as <Name>" chips without an extra request, no signup form, no avatar upload:

typescript
import { getVaultStatus } from '@flowsta/holochain';

const status = await getVaultStatus();
// {
//   running: boolean,
//   unlocked: boolean,
//   blocked?: boolean,         // v3.1.0: the BROWSER refused the loopback request
//   agentPubKey?: string,      // only while unlocked
//   did?: string,              // v3.6.0: did:flowsta:<agent key>, only while unlocked
//   activeIdentity?: string,   // v3.5.0 + Vault 1.5.0: the identity the Vault holds, unlocked OR locked
//   identityEpoch?: number,    // v3.5.0 + Vault 1.5.0: counts every identity change on that device
//   instanceId?: string,       // v3.5.0 + Vault 1.5.0: the answering Vault process (changes on restart)
//   displayName?: string,      // v2.3.0+, scope-gated
//   profilePicture?: string,   // v2.3.0+, scope-gated
//   webUsername?: string,      // v2.4.1+, scope-gated
//   email?: string,            // v3.2.0 + Vault 1.3.0: only after the user allowed it in a Vault dialog
//   emailVerified?: boolean,   // v3.2.0: always true alongside email
//   version?: string,
// }

Older Vaults omit activeIdentity, identityEpoch and instanceId. activeIdentity is how you tell "locked, but still my identity" from "locked, someone else" before any unlock; identityEpoch never repeats a past value, so A→B→A between two reads still shows as a change.

blocked is not "not running" (v3.1.0)

Chrome 142+ asks the person before a public page may reach 127.0.0.1; if they decline, every Vault call fails exactly like an absent Vault. getVaultStatus() reports blocked: true in that case, and signDocument / authenticateWithVault throw VaultBlockedError instead of VaultNotFoundError. The backup and link calls (backupToVault, retrieveFromVault, linkFlowstaIdentity) still report VaultNotFoundError when the browser blocks the request - check getVaultStatus().blocked first where that matters. Show the browser's settings path or offer relay login - don't tell them to install a Vault they have. loopbackPermissionState() is exported if you want to explain the prompt before it appears.

Scope gating

The displayName, profilePicture, and webUsername fields are only populated when your app's client_id has the matching scope (display_name, profile_picture, username) configured at dev.flowsta.com AND the user approved that scope at link time. If a scope isn't granted, the field is undefined regardless of whether the Vault identity has the value set.

email (v3.2.0, Vault 1.3.0+) is stricter: the email scope alone does not populate it. The user has to allow it in a Vault dialog - request it on authenticateWithVault with scopes: ['email'] - and only a verified address is ever shared. Your app receives it as text it can keep, and the user is told exactly that; if they later stop sharing in the Vault's Connections page, the field disappears for future reads (it does not unsend).

revokeFlowstaIdentity ​

Notify Vault that a link has been revoked. Best-effort - if Vault is not running, returns { success: false } without throwing. It is also false when the Vault refuses: the Vault accepts a revocation only from a Flowsta page or from the linked app revoking its own link (the calling origin must be the one that linked this agent key), and answers 403 forbidden to anyone else. Revoke from the same origin you linked from:

typescript
import { revokeFlowstaIdentity } from '@flowsta/holochain';

await revokeFlowstaIdentity({
  appName: 'ChessChain',
  localAgentPubKey: myAgentKey, // uhCAk... format
});

getFlowstaLinkStatus ​

Added in v2.3.0. The recommended way to check whether Vault still recognizes your app's agent. Returns a three-state shape that distinguishes "Vault running but agent not linked" from "Vault not running" - they look the same to a boolean but want very different UX responses.

typescript
import { getFlowstaLinkStatus } from '@flowsta/holochain';

const status = await getFlowstaLinkStatus({
  clientId: 'flowsta_app_abc123',
  localAgentPubKey: myAgentKey, // uhCAk... format
});

switch (status.state) {
  case 'linked':
    // Vault is running and recognizes this app's agent. Full access.
    // status.appName is the display name Vault has on file for your app.
    break;
  case 'unlinked':
    // Vault is running but does NOT recognize this app's agent - the
    // user unlinked from Vault's UI, switched Flowsta identities,
    // restored Vault from a different recovery phrase, or RESET Vault
    // (a full erase clears all app links - so this also fires after a
    // reset even when the user reconnects with the SAME recovery
    // phrase: the identity is unchanged but the link must be re-made).
    // Re-link to restore it (see the two patterns below). Do NOT
    // auto-revoke - past data attributed to the local agent stays the
    // user's either way.
    break;
  case 'offline':
    // Vault not reachable. Trust local link state as authoritative -
    // the Vault may simply be closed.
    break;
}

Re-linking patterns. When state is unlinked, re-link to restore the connection (which re-establishes the app in Vault's connected-apps list). Two patterns are in use, both valid - pick by how proactive your app should be. Either way, never silently revoke: apps that collapsed link status to a boolean and auto-revoked frustrated users who had simply closed Vault briefly.

  • Reconnect banner (user-initiated). Render a top-of-page banner when state is unlinked, offering "Reconnect" (re-link with the current Vault) or "Disconnect" (deliberately revoke). No surprise dialog - the user chooses when. Best when the app keeps working without the link.
  • Auto re-link on launch. On startup, if the app has a session but getFlowstaLinkStatus returns unlinked, re-link immediately (Vault shows its normal approval dialog). Keeps Vault's connected-apps list accurate without the user hunting for a banner. Retry briefly so a Vault unlocked shortly after launch still reconnects. Best when you want the connection always reflected in Vault.

ProofPoll is the reference for the banner pattern - see ProofPoll/src/lib/context.ts and ProofPoll/src/routes/layout.tsx for the layout-level banner + grayed-out profile chip. The other pattern is auto re-link on launch: re-link silently the moment the app starts and finds the Vault unlocked with the same identity.

checkFlowstaLinkStatus ​

⚠️ Deprecated since v2.3.0 - use getFlowstaLinkStatus instead. The boolean shape conflates "Vault not running" with "agent genuinely unlinked", which leads to silent auto-revoke when the Vault is simply closed. Kept for backwards compatibility.

typescript
import { checkFlowstaLinkStatus } from '@flowsta/holochain';

const status = await checkFlowstaLinkStatus({
  clientId: 'flowsta_app_abc123',
  localAgentPubKey: myAgentKey, // uhCAk... format
});

if (status.linked) {
  console.log('App name:', status.appName);
}

After an identity switch ​

A Vault 1.5.0 can hold several identities and switch between them while your app is open. Three helpers cover it; correctness never depends on them, because every write-shaped call asserts the bound identity on its own.

getVaultIdentity(ipcUrl?) (3.4.0) - the agent key of the identity that is unlocked right now, or null when the Vault is locked, absent, or blocked by the browser. One status read.

onIdentityChanged(callback, options?) (3.0.0) - polls /status (there is no push channel to apps) and calls callback(next, previous) when the identity changes; returns a stop function. options is { ipcUrl?, intervalMs? }, intervalMs defaults to 5000. It starts from the bound identity (3.4.0), so an app that opens against a Vault already switched to another identity hears about it on the first tick. Against Vault 1.5.0 it compares identityEpoch (3.5.0): A→B→A between two polls is still reported, and a switch is seen through a locked Vault; against older Vaults it compares the unlocked agent key, and a locked Vault is not a change. An unreachable Vault is never a change.

reconnectIdentity({ clientId, localAgentPubKey, ipcUrl? }) (3.4.0) - after a switch, decides between a silent rebind and the full ceremony. Pass the local agent key of the profile your app has swapped to. Returns a ReconnectIdentityResult:

stateMeaningWhat to do
reconnectedThe identity now in the Vault already holds a link for this app (the person consented before); the binding was updated. Carries agentPubKey and appNameCarry on
approval_neededThe Vault is unlocked under an identity that has no link for this app. Carries agentPubKeyRun linkFlowstaIdentity (the normal approval dialog)
lockedThe Vault is locked; the binding was left aloneWait, or ask the user to unlock
offlineNo Vault reachable; the binding was left aloneKeep local state, retry later
typescript
import { onIdentityChanged, reconnectIdentity, linkFlowstaIdentity, partitionKeyFor } from '@flowsta/holochain';

const stop = onIdentityChanged(async (next, previous) => {
  // Point storage at the identity's own partition (never the key itself).
  const partition = await partitionKeyFor(next);
  await openDatabase(`chesschain-${partition}`);

  const outcome = await reconnectIdentity({ clientId, localAgentPubKey: myAgentKeyFor(partition) });
  if (outcome.state === 'approval_needed') {
    await linkFlowstaIdentity({ appName: 'ChessChain', clientId, localAgentPubKey: myAgentKeyFor(partition) });
  }
});
// later: stop();

partitionKeyFor(agentPubKey) (3.3.0) returns the first PARTITION_KEY_LENGTH (16) hex characters of SHA-256 over the agent key's 39 raw bytes, null when the string is not an agent key; it needs Web Crypto (crypto.subtle: every browser, Node 19+) and throws FlowstaHolochainError (code no_crypto) without it.

Sign It - Document Signing ​

Added in v2.2.0. Ask the Vault to sign a file hash on the user's behalf - the user approves each request in Vault. Since 3.6.0 a linked app's signature is also published to the Sign It network from the person's own device, so anyone can verify the file.

signDocument ​

typescript
import { signDocument } from '@flowsta/holochain';

const result = await signDocument({
  clientId: 'flowsta_app_abc123',
  appName: 'ArtStudio',
  fileHash: sha256Hex,              // 64 hex characters
  label: 'Illustration.png',        // shown in the approval dialog
  intent: 'authorship',             // 'authorship' | 'approval' | 'witness' | 'receipt' | 'agreement'
  aiGeneration: 'none',             // 'none' | 'assisted' | 'generated'
  contentRights: {
    license: 'cc-by',
    commercialLicensing: 'open_to_licensing',   // 'not_available' | 'open_to_licensing'
    aiTraining: 'not_allowed',                  // 'allowed' | 'allowed_with_attribution' | 'requires_license' | 'not_allowed'
    contactPreference: 'allow_contact_requests', // 'no_contact' | 'allow_contact_requests'
  },
  // publish: true,                 // default: true when this app is bound (linked), false otherwise
});
// result: SignDocumentResult
// { success, fileHash, signature, agentPubKey, signedAt, actionHash: string | null, published: boolean }

SignDocumentOptions is { clientId, appName, fileHash, label?, intent?, aiGeneration?, contentRights?, publish?, ipcUrl? }. contentRights is camelCase here and mapped to the bridge's snake_case fields for you.

Who may sign, who may publish. Any app may request a signature - the user approves each one, and the dialog names the app. Publishing is allowed for Flowsta pages and linked apps only. publish therefore defaults to true when the app is bound (it linked through linkFlowstaIdentity) and false otherwise. A signature made without publishing is returned to your app (actionHash: null, published: false) but is not on the network and cannot be verified by anyone else; a published one carries the hex actionHash of the record. Every publish draws on the person's signing quota (or the sponsor pool when your organization sponsors signing), and the dialog says so.

Throws VaultBlockedError (3.1.0), VaultNotFoundError, VaultLockedError, UserDeniedError, PublishForbiddenError (3.6.0 - publishing asked for by an app that is not linked; link first, or pass publish: false), QuotaExceededError (3.6.0 - the period's quota is used up), IdentityMismatchError (the Vault holds a different identity than the bound one), or FlowstaHolochainError (code 'timeout' after ~70 s without an answer).

getSigningStatus ​

getSigningStatus(ipcUrl?) returns { available, vaultRunning, vaultUnlocked } - a lightweight check before rendering a "Sign with Flowsta" button; it does not prompt the user. available is true when a Vault is running and unlocked.

Full parameter and response tables: Sign It SDK Reference. The raw endpoint is POST /sign-document.

Sign In with Your Vault ​

Let users prove who they are with the key on their own device - no shared secret for your app to store, no one in between. Your backend issues a challenge, the user approves in their Vault, and the Vault signs the challenge with the user's device key. Two flows cover every environment.

authenticateWithVault ​

Sign a challenge through the local Vault's IPC server. Browser-safe: plain fetch, no dependencies. The user approves in a Vault dialog (~60 seconds; the SDK waits up to 125 s so a locked Vault can be unlocked first).

The Vault refuses to sign any challenge that starts with flowsta- (reserved_prefix) unless the caller is a Flowsta page - that prefix belongs to Flowsta's own sign-in protocols, and POST /auth/vault/challenge always issues one. So the two flows differ in who mints the challenge:

Your app (any origin): your own challenge, verified by your backend. Mint a random nonce server-side, have the Vault sign it, and verify the Ed25519 signature yourself. The agent key is u + base64url (no padding) of 39 bytes; bytes 3..35 are the raw Ed25519 public key.

typescript
import { authenticateWithVault } from '@flowsta/holochain';

// 1. Your backend mints a random, single-use challenge (never 'flowsta-' prefixed)
const { challenge } = await fetch('https://api.chesschain.example/auth/challenge', { method: 'POST' })
  .then((r) => r.json());                // e.g. "chesschain-login:4f2a…" (32+ random bytes, hex)

// 2. The Vault signs it - the user approves in a dialog
const result = await authenticateWithVault(challenge, {
  appName: 'ChessChain',
  reason: 'Sign in to ChessChain',
  clientId: YOUR_CLIENT_ID,   // v3.2.0: needed for scopes
  scopes: ['email'],          // v3.2.0: ask for the email in the same dialog
});
// { signature: string, agentPubKey: string, did: string, email?: string, emailVerified?: boolean }

// 3. Your backend verifies and opens a session
await fetch('https://api.chesschain.example/auth/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ challenge, signature: result.signature, agent_pub_key: result.agentPubKey }),
});
typescript
// Backend (Node 19+): verify the Vault's signature over the challenge's UTF-8 bytes
import { webcrypto } from 'node:crypto';

async function verifyVaultSignature(challenge: string, signatureB64: string, agentPubKey: string) {
  const raw = Buffer.from(agentPubKey.slice(1), 'base64url');          // 39 bytes
  if (raw.length !== 39) return false;
  const key = await webcrypto.subtle.importKey('raw', raw.subarray(3, 35), { name: 'Ed25519' }, false, ['verify']);
  return webcrypto.subtle.verify('Ed25519', key, Buffer.from(signatureB64, 'base64'), Buffer.from(challenge, 'utf8'));
}

Check that the challenge is one you issued, unused, and recent; the agentPubKey that verifies is the user's identity - match it against the key your app linked to, or against did (did:flowsta:<agent key>).

Flowsta's own pages: the API challenge. Pages on https://*.flowsta.com fetch POST /auth/vault/challenge, pass the flowsta-auth-challenge:v1:… string to authenticateWithVault exactly as issued, and exchange the result at POST /auth/vault/token. That flow is first-party only - from any other origin the Vault answers reserved_prefix at step 2.

Options (all optional): { ipcUrl?, appName?, reason?, clientId?, scopes?, localAgentPubKey? } (AuthenticateWithVaultOptions). localAgentPubKey (3.9.0) is the install key your desktop app linked with: a Vault 1.6.1+ that remembers your app's origin signs silently only when it matches the link, because any local process can send an Origin header; browsers need not send it. With clientId and scopes: ['email'] (v3.2.0, Vault 1.3.0+) the Vault's dialog shows the user the address that will be shared and says your app receives it as text it can keep; if they allow, email and emailVerified: true come back. The Vault records the grant (so later sign-ins auto-approve) and files it with Flowsta only when your origin is linked to that clientId; an unlinked origin gets the address for this sign-in only. Scopes your app is not registered for at dev.flowsta.com are ignored, an unverified email is never offered, and a site the user chose to remember still sees the dialog the first time it asks for email. The result is an AuthenticateWithVaultResult: { signature, agentPubKey, did, email?, emailVerified? }. Throws VaultBlockedError (v3.1.0 - the browser refused the loopback request), VaultNotFoundError, VaultLockedError, UserDeniedError, IdentityMismatchError (v3 - the Vault holds a different identity than the one this app is bound to), or FlowstaHolochainError (code 'timeout' if the user doesn't respond; 'reserved_prefix' for a flowsta- challenge from a non-Flowsta origin).

Pass the challenge verbatim, and mind the browser

Pass the challenge string exactly as your backend issued it - the SDK handles the encoding the Vault expects internally (it signs the string's UTF-8 bytes); re-encoding or trimming it yourself will make verification fail. Also note this flow reaches the Vault at http://127.0.0.1. Firefox and Chromium-based browsers (and desktop apps) permit that from HTTPS pages - Chrome 142+ asks the person for a Local Network Access permission first; a denied permission surfaces as VaultBlockedError (v3.1.0) - say so, rather than "install the Vault". Safari blocks the loopback as mixed content and Brave blocks it silently unless the site is on Brave's allowlist; on those, and on phones, use the relay login below.

Building a desktop app? See Sign in with your Vault for Tauri apps for the full desktop flow.

Relay login covers browsers that can't reach a local Vault over loopback: phones (no Vault on the device), Safari, and Brave on desktop. The browser and the user's desktop Vault meet at the Flowsta API:

  1. startRelayLogin() mints a short user code (formatted XXXX-XXXX).
  2. Show the code. On a phone, the user types it into their desktop Vault. On Safari or Brave desktop, call openVaultDeepLink(userCode) to hand it to the Vault via the flowsta:// protocol - and always render the typed-code fallback too, because deep-link success is not detectable from the page.
  3. The user approves in their Vault; polling resolves with the session.
typescript
import { startRelayLogin, openVaultDeepLink } from '@flowsta/holochain';

const session = await startRelayLogin('https://auth-api.flowsta.com');
showCode(session.userCode);              // e.g. "7GK2-M4QX", expires in session.expiresIn seconds

// Desktop Safari/Brave: also try the deep link (fire-and-forget)
openVaultDeepLink(session.userCode);

// Poll every 2-3 seconds
const timer = setInterval(async () => {
  const result = await session.poll();
  // result: { status: 'pending' | 'claimed' | 'approved' | 'denied' | 'expired', token?, user? }
  if (result.status === 'approved') {
    clearInterval(timer);
    saveSession(result.token, result.user); // token is returned exactly once
  } else if (result.status === 'denied' || result.status === 'expired') {
    clearInterval(timer);
    showRetry(result.status);
  }
}, 2500);

startRelayLogin returns a RelayLoginSession ({ userCode, expiresIn, poll }); poll() resolves a RelayPollResult ({ status: RelayStatus, token?, user? }). RelayPollResult.token is present exactly once, when status === 'approved' - store it immediately; a later poll returns 'expired'. The optional clientLabel on startRelayLogin is a short display label bound into the signed challenge (max 64 chars), not an OAuth client_id. openVaultDeepLink opens flowsta://relay/v1?code=…; see flowsta:// URLs.

openWebSignedIn ​

v3.9.0, Vault 1.6.1+

Open your web app in the OS browser already signed in as the identity this desktop app holds, without the browser reaching the Vault. The four calls are the API's existing web sign-in, made from the desktop app instead of a page: POST /auth/vault/challenge for the web client, the Vault signs it over loopback (a linked app may sign for its declared companion client; silently when the person's trust setting allows), POST /auth/vault/token, then GET /oauth/authorize as a Bearer with PKCE, which returns the web app's callback URL with the code. The PKCE verifier and the state are appended in the URL fragment (#v=…&s=…&a=<agent key>), which never reaches a server or a Referer; the web app's callback page adopts them (@flowsta/login-button 0.2.0 handleCallback(), or adoptHandoffFragment()) and runs its normal code exchange.

typescript
import { openWebSignedIn } from '@flowsta/holochain';

const { url, consentRequired } = await openWebSignedIn({
  clientId: WEB_CLIENT_ID,          // the web app's client id (the desktop app's companion)
  redirectUri: 'https://app.example.com/auth/callback/', // one of its registered redirect URIs, exact
  scopes: ['openid', 'display_name'],                     // default
  appName: 'YourApp',
  openUrl: (u) => openUrl(u),       // optional: open it; omitted = the URL is only returned
});

Options (OpenWebSignedInOptions): clientId, redirectUri, scopes?, apiUrl? (default https://auth-api.flowsta.com), ipcUrl?, appName?, openUrl?, localAgentPubKey? (the install key your app linked with; without it a remembered app still gets the dialog). Result (OpenWebSignedInResult): { url, consentRequired }; consentRequired is true when the API wants the person to see the consent page once for the web client, in which case url is that page and the callback follows it.

Throws FlowstaHolochainError with code challenge_failed, token_failed or authorize_failed for the API steps, and the usual Vault errors (VaultNotFoundError, VaultLockedError, UserDeniedError, reserved_prefix when the app is not linked or the client is neither its own nor its companion). On any throw, open the web app signed out; the person signs in there with the button.

The full desktop flow, with what to show the person, is on Desktop App Authentication and Sign-in UX.

Backups ​

Flowsta Vault provides encrypted local storage for app data backups. With the canonical-shape pipeline (v2.4.0+), the SDK and Vault together give you:

  • Automatic encrypted backups of your users' Holochain data, written after every change.
  • One-click reinstall recovery - when a user reinstalls, the SDK walks the backup and replays each entry via a small dispatcher you write; shared-DHT apps instead adopt their escrowed agent seed and continue as the same author.
  • CAL §4.2.1-compliant user data export - the Vault's Export (Your Data page) produces a portable JSON file with the user's cryptographic keys + their data in plain English. The export every CAL-licensed Holochain app is obliged to provide; you write nothing.

Users view, export, and delete their backups from the Vault's Your Data page at any time.

The backup pipeline: your app posts one canonical payload (Holochain records, the app_keys agent seed, and extension blocks) to the Flowsta Vault, which stores it encrypted at rest, lists it on the Your Data page, and produces the CAL 4.2.1 export; on a new machine the user restores by recovery model - seed adoption for shared-DHT apps continuing as the same author, replay for per-user DHTs, and extension blocks written back only when absent

Backups work while the Vault is locked

As of @flowsta/holochain v2.1.0, backups can be stored and retrieved even when the Vault is locked - as long as it has been unlocked at least once in the current session. Listing (listVaultBackups) and deleting need the Vault unlocked; locked, listVaultBackups returns empty stats.

Choose your recovery model ​

Backup and restore are not one-size-fits-all - the right shape depends on where your data's durability comes from. All three models use the same Vault pipeline and canonical payload, so you can start small and add more later without changing what your users see.

Your data livesDurability comes fromYou back upRestore works by
On a shared DHT (many users, one network)The network - peers keep replicating entriesThe canonical payload (feeds the CAL export), plus the app's agent seed in app_keysRecognition + seed adoption - the app adopts the escrowed seed and continues as the same author; published data re-syncs from the DHT. Nothing is replayed.
On a per-user DHT (each user is their own network) or authored by a single agentThe Vault backup - the user's own devices are usually the only peersThe canonical payload with human_readable + raw_record per recordReplay - walk the backup and re-commit each record via your zome functions (restoreFromVault or your own walker). New action hashes are minted; remap any stored references.
Outside Holochain (settings, local encrypted files, images)The Vault backupExtension blocks on the same payloadFile restore - read the block back, write the file or settings when absent locally

When the person has several devices (Vault 1.6.0+), each of their Vaults keeps a copy of the backups made on the others, and your app can read the newest under a label across them (across: 'devices'). A replay restore must then be switched off when another device already holds the data - it would author every record again as new; read the other device's backup instead, or sync through your own network. A recognition app's escrowed seed is per device by default: one seed per install, never the same agent on two conductors.

Real apps mix models. ProofPoll is recognition plus seed adoption (shared polls DHT; its agent seed rides app_keys, so a restore continues as the same author). A replay app (per-user data that lives only with the person) adds extension blocks - its settings, images and other local files ride the same backup as its Holochain records.

One identity, several devices ​

(3.9.0, Vault 1.6.1+) A person installs your app on each of their devices and signs in with the same Flowsta identity on each. For your app's own Holochain data to sync between those installs they must be on one private network - the same network_seed on every device:

typescript
import { getAppNetworkSecret } from '@flowsta/holochain';

// Before installing your DNA on a signed-in device:
const { secretHex, source } = await getAppNetworkSecret({ clientId, appName: 'My App' });
// Use secretHex as the DNA's network_seed (and, hashed with a second label, as a data key).

getAppNetworkSecret never changes a secret an identity already has. In order: a backup under the label on this device, then on any other device, then the Vault-derived secret (getAppSecret - POST /app-secret, the same on every device with nothing to copy or wait for), and only then a random one written to this device's backup slot for the others to find. source says which. Needs the Vault unlocked (VaultLockedError).

The five rules that make the rest work, learned building Flowsta's own apps on it:

  1. Every device has its own agent key on that network - never the same key on two conductors (gossip fails silently).
  2. List at a founding anchor: the first device's agent key (or any fixed hash) is the address every device links and reads at; nobody needs its private half for that, so losing the first device loses nothing.
  3. Latest wins, by updated_at then author, for anything two devices may both write; keep every version.
  4. A deletion is a record: never rely on deleting another device's entry (author-only); write a tombstone every reader honours.
  5. Devices on different Holochain generations wait with a clear message; the backup copies (across: 'devices', device) are the catch-up lane while they do.

Where the devices meet: your conductor's bootstrap and relay ​

The SDK supplies identity: the keys, the per-device keys, the private-network secret, the backups. Where your app's devices find each other is your conductor's business, not the SDK's. Its network: config names a bootstrap server - the address book where devices on a network register and look each other up - and a relay, which carries traffic between devices that cannot reach each other directly. Any kitsune2 bootstrap server works: Holochain's public one, your own (kitsune2-bootstrap-srv), or a community node.

Your users' devices never join Flowsta's networks. Those - identity, signing, the Vault's private records - run on bootstrap.flowsta.com, which checks a sign-in and only admits apps with auth material Flowsta issued (see Holochain Architecture). Using it for your own app's network is optional and comes with registering your app; nothing in the SDK needs it.

6. Keep the device listed ​

(3.10.0) A sixth rule, learned the hard way. The conductor's networking (kitsune2 0.4.x, Holochain 0.6) re-sends each agent's note to the bootstrap every 15 minutes, retries a refused send unchanged on a backoff that reaches 5 minutes, and the server refuses notes older than 3 minutes. So a device that is awake and cut off for more than 3 minutes across a re-sign - Wi-Fi gone, server down - is never listed again until its conductor restarts, and a request caught by a laptop sleep never times out. Sleep by itself is harmless. Measured 2026-10-19 building multi-device sync into Flowsta's own apps; what they ship is keepRegistered, which needs no restart:

typescript
import { keepRegistered } from '@flowsta/holochain';

// Once the conductor is up (any @holochain/client AdminWebsocket 0.20+):
const keeper = keepRegistered({
  adminWebsocket,
  servers: [
    { url: 'https://bootstrap.example.com' },                       // open
    // { url: 'https://bootstrap.flowsta.com', authMaterialBase64 }, // sign-in gated
  ],
  log: (line) => console.info(line),
});
// on shutdown: keeper.stop()

Once a minute it reads the conductor's own fresh notes over the admin port, PUTs the ones a server lacks (notes 45 s to 170 s old: old enough that the conductor has plainly not sent them, young enough to be accepted), and hands the conductor listed peers it has not heard of (addAgentInfo) - against every server you name, so a second server is one more line in the list. Each note is registered at most once, the conductor's own rate; and a listing at the server's cap of 32 notes per network (RENDEZVOUS_LISTING_CAP, 3.10.2) is not read as a stall, since in a network with more agents than that a note is simply evicted. A stall is judged only on the server the conductor was configured with (its config's network.bootstrap_url): the first in servers, or conductorServer (3.10.3) when the conductor started on another one because the first was down. The conductor never registers on the others, so the keeper's registrations there are extra reach, counted in registeredElsewhere. It writes a line only when the picture changes: the first time it stepped in, when the conductor registers again, when no server answers.

7. Post what should travel, read what moved ​

Anything that must appear on the person's other devices rides a Vault backup, so post one (debounced) after every change to it, not only after Holochain writes; and poll listVaultBackups() once a minute, reading a sibling's copy only when its created_at moved. Both in Keeping the devices in step. intervalMs: 0 gives you runOnce() for your own loop; parseRendezvousNote, rendezvousIdCore, notesToRegister and notesToFeed are exported for apps that want to build their own.

What your users see ​

You post one payload; the Vault turns it into the whole user-facing story:

  • Your Data page - your app appears with per-entry-type counts ("12 polls, 38 votes") whenever the payload is canonical-shape.
  • Per-app Export - the Export button next to your app's backup downloads just your app's data: every record's human_readable view plus your extension blocks, app_keys included.
  • Download All Data - the full CAL §4.2.1 export: the user's identity, their keys, and every connected app's backup in one readable file. See Data Portability.
  • Delete - users can delete your app's backups at any time. Backups are retained after unlinking until the user deletes them.

Backups are encrypted at rest on the user's device with their Vault key; the downloadable export is the user's off-machine copy.

When restore runs ​

  • Recognition apps: after sign-in, compare the seed escrowed in the Vault backup against the local one. When they differ (a fresh install after machine loss), offer the seed adoption step; once the app runs under the adopted seed, published data re-syncs from the DHT on its own.
  • Replay apps: detect "fresh install, non-empty Vault backup" right after sign-in (empty local state + listVaultBackups shows records for your clientId) and run the restore in your startup sequence, visibly. If your app keeps its own data key in app_keys, restore the key first, then replay records.
  • Extension blocks: restore alongside either model - write files back only when they're absent locally.
  • Make restore idempotent (dedupe on a content id or timestamp inside your records) - users will run it twice.

Three write guards protect the restore window

An empty backup never overwrites a real one (every write since v3; introduced 2.6.0). Auto-backup runs immediately on start - including the first start after a reinstall, when the local chain is empty but the user's Vault backup is not. The guard lives inside backupToVault itself, so every write path is protected: an empty canonical payload that would replace a non-empty backup throws EmptyBackupSkippedError (code empty_backup_skipped); under startAutoBackup it arrives via onError - a skip, not a failure; the next non-empty backup writes normally. Opt out with protectNonEmpty: false.

A wrong identity never writes (v3). With an identity bound (automatic after linkFlowstaIdentity), a Vault holding a different identity throws IdentityMismatchError before anything lands in their slot. Unlike the empty-payload skip, a mismatch must stop and tell the user - never retry it after a restore.

A foreign key is never overwritten. If your payload carries app_keys, refuse to write whenever the backup already stored in the Vault escrows a different key than the local one - a fresh install's first backup would otherwise destroy the very key the user needs to restore. Retrieve the existing backup, compare its app_keys against yours, and hold the write (fail closed, including when the check itself fails) until the user restores the key or explicitly keeps the new one. ProofPoll's backup_escrow_gate is the working reference.

What you write vs what Flowsta provides ​

ComponentWhat it doesApproximate lines
decode_record_for_export Tauri commandOne match per entry type: rmp_serde::from_slice(bytes) → serde_json::to_value(struct). Used at backup time so the user's data export is human-readable.~5 per entry type
restore_record Tauri commandOne match per entry type: decode entry bytes → call the matching zome function. Used by restoreFromVault to replay records on reinstall.~5 per entry type
startAutoBackup call in your app's startupTells the SDK to back up after every write (debounced) plus a heartbeat retry.~10
Restore-on-first-launch modal (recommended)Detect empty local state + Vault backup, prompt user, call restoreFromVault.~30 (UX is yours)
Seed escrow + adoption (shared-DHT apps only)Generate the agent seed app-side, escrow it in app_keys, adopt it on restore.copy the reference, adapt paths

When you add a new entry type to your DNA, you add one match arm in each of the two Tauri commands. That's the entire ongoing backup-related maintenance - Vault provides encryption, storage, the Your Data UI, the restore walker, and the CAL data export.

Canonical-shape backups (v2.4.0+) ​

The canonical payload format carries two views per record: a human_readable view (decoded entry as plain JSON, for the user's CAL export) and a raw_record view (the signed Holochain record, for restore + verification).

typescript
import { startAutoBackup } from '@flowsta/holochain';
import { invoke } from '@tauri-apps/api/core';

const controller = startAutoBackup({
  clientId: 'flowsta_app_abc123',
  appName: 'ChessChain',
  adminWebsocket: adminWs,                          // your AdminWebsocket instance
  cellId: gamesCellId,                              // [DnaHash, AgentPubKey] tuple
  cellRoleName: 'games',
  agentPubKey: myAgentBytes,                        // filter source chain to user's own records
  decodeRecordForExport: (entryType, entryB64) =>
    invoke('decode_record_for_export', { entryType, entryBytesB64: entryB64 }),
  triggerOnWrite: true,                             // default; back up after each write
  debounceSeconds: 30,                              // default; debounce window for write-triggered backups
  heartbeatMinutes: 30,                             // default; safety-net retry (0 disables)
  label: 'latest',                                  // default since 3.6.0; single overwriting backup
  onSuccess: (r) => console.log('Backed up:', r.dataSize, 'bytes'),
  onError: (e) => console.warn('Backup skipped:', e.message),
});

// Call after each successful zome write to debounce-trigger a backup:
controller.triggerBackupSoon();

// On sign-out / app close:
controller.stop();

On the Rust side, your decode_record_for_export command:

rust
use base64::Engine as _;

#[tauri::command]
pub async fn decode_record_for_export(
    entry_type: String,
    entry_bytes_b64: String,
) -> Result<serde_json::Value, String> {
    let bytes = base64::engine::general_purpose::STANDARD
        .decode(&entry_bytes_b64)
        .map_err(|e| format!("base64: {}", e))?;
    match entry_type.as_str() {
        "Game" => {
            let g: Game = rmp_serde::from_slice(&bytes).map_err(|e| e.to_string())?;
            serde_json::to_value(g).map_err(|e| e.to_string())
        }
        "Move" => {
            let m: Move = rmp_serde::from_slice(&bytes).map_err(|e| e.to_string())?;
            serde_json::to_value(m).map_err(|e| e.to_string())
        }
        other => Ok(serde_json::json!({
            "_warning": format!("Unknown entry type: {}", other),
            "raw_bytes_hex": hex::encode(&bytes),
        })),
    }
}

The Game and Move structs already have #[derive(serde::Serialize, serde::Deserialize)] for their DNA-side use, so the body of each arm is essentially one line of decode + one line of serde_json::to_value. No field-by-field mapping.

CAL §4.2.1: keys come from the Vault, not the backup (2.4.0+) ​

A BackupPayload carries data only by default - when all of your app's cryptography derives from the user's Flowsta identity, your app never holds their keys and a backup shouldn't carry any. The user's identity lives in their Flowsta Vault.

CAL §4.2.1 (the user's data plus the keys to operate it) is satisfied at the Vault level, not per-backup. The Vault's "Export All Data" bundles the user's data together with their device seed - the key material their 24-word recovery phrase derives - so the export is self-sufficient: they can re-derive their identity on any compatible Holochain conductor and use their data, with no lock-in.

So there's nothing extra to do in your backup for CAL completeness: post the canonical data payload, and the Vault supplies the key material in its own export.

Keys the Vault can't supply belong in the backup: cryptographic material your app generates itself, not derived from the user's Flowsta seed. Include it in a top-level app_keys block on your payload. Vault preserves extension fields verbatim through the export pipeline, so the key reaches both the single-app export (the Export button next to your app's backup) and the full Export (Your Data page) - keeping every export self-sufficient, which is exactly what CAL §4.2.1 asks of you. Two kinds of key live here:

  • Your app's agent seed (shared-DHT apps). Generate the 32-byte seed in your app, import it into lair, and derive your agent from it - then escrow it as { "_readme": …, "device_seed_hex": …, "version": 1 }. Lair-created seeds can't be extracted, which is why the seed must be born app-side: it's the difference between an export that carries the user's records and one that carries their means of authorship. See Seed adoption for the restore half.
  • A local data-encryption key for encrypted entries or local files, when it isn't derived from the agent key.

Match the restore to where durability lives

For shared-DHT apps, recovery is recognition plus seed adoption: the user signs in, the app adopts its escrowed seed, and their on-network data re-syncs from the DHT as the same author - no record replay. Reach for restoreFromVault when the Vault backup is the durability - per-user DHTs and single-author data. See Choose your recovery model.

Non-Holochain data: extension blocks ​

Not everything worth protecting is a Holochain record - app settings, local encrypted files, images. Add them as top-level extension blocks on the same canonical payload. The Vault preserves unknown top-level fields verbatim through the whole pipeline - Your Data, the per-app export, and Download All Data - exactly as it does for app_keys:

json
{
  "version": 1,
  "_summary": { "countsByEntryType": { "Game": 12 }, "totalRecords": 12 },
  "cells": [ { "role_name": "games", "records": [ /* … */ ] } ],

  "settings": {
    "_readme": "Your app preferences as stored on this device.",
    "data": { "theme": "dark", "notation": "algebraic" }
  },
  "thumbnails": {
    "_readme": "Your board images (base64 JPEG), keyed by id.",
    "data": { "board-1": "…base64…" }
  }
}

Guidelines:

  • Give every block a _readme. It lands in the user's export - explain what the block is in plain language.
  • Prefer readable JSON; base64 only for binaries. If a file is encrypted on disk, include a decrypted human_readable view alongside the raw bytes - the Vault encrypts backups at rest, so there's no double-encryption concern, and the user's CAL export stays readable.
  • On restore, write a block back only when the local copy is absent - never clobber newer local state.
  • Apps with no Holochain data at all can use the same canonical shape with an empty cells: [] - you still get the Your Data listing, both exports, and app_keys escrow.

Typical extension blocks: a configuration block (the app's settings), an images block (per-item pictures), and an encrypted local file carried with both a readable view and its raw bytes.

Reinstall recovery: replay ​

For replay apps (per-user DHT or single-author data): when the user reinstalls your app - or installs it on a new machine - offer to restore their data from their Vault backup. The SDK walks the backup and calls your restore_record dispatcher once per record. (Shared-DHT apps restore differently - see Seed adoption below.)

typescript
import { listVaultBackups, restoreFromVault } from '@flowsta/holochain';
import { invoke } from '@tauri-apps/api/core';

// On app startup, after sign-in succeeds and the conductor is ready:
const backups = await listVaultBackups();
const ours = backups.apps.find(a => a.clientId === clientId);
const localGames = await invoke<Game[]>('get_my_local_games');

if (ours && ours.backupCount > 0 && localGames.length === 0) {
  // Empty local source chain + Vault has a backup - offer to restore.
  const userConfirmed = await showRestorePrompt({
    when: new Date(ours.lastBackupAt * 1000),
    backupCount: ours.backupCount,
    totalSize: ours.totalSize,          // bytes across this app's backups
  });

  if (userConfirmed) {
    const result = await restoreFromVault({
      clientId,
      dispatcher: async (record) => {
        await invoke('restore_record', {
          entryType: record.entryType,
          entryBytesB64: record.raw_record.entry_b64,
        });
      },
      onProgress: (current, total) => updateProgressUI(current, total),
    });
    console.log(`Restored ${result.succeeded}/${result.totalRecords}`);
  }
}

On the Rust side, restore_record:

rust
#[tauri::command]
pub async fn restore_record(
    state: tauri::State<'_, Arc<AppState>>,
    entry_type: String,
    entry_bytes_b64: String,
) -> Result<(), String> {
    let bytes = base64::engine::general_purpose::STANDARD
        .decode(&entry_bytes_b64)
        .map_err(|e| e.to_string())?;
    let client = state.app_client.lock().await;
    let client = client.as_ref().ok_or("Conductor not ready")?;

    match entry_type.as_str() {
        "Game" => {
            let g: Game = rmp_serde::from_slice(&bytes).map_err(|e| e.to_string())?;
            let input = CreateGameInput { /* fields from g */ };
            let payload = ExternIO::encode(input).map_err(|e| e.to_string())?;
            call_zome(client, GAMES_ZOME, "create_game", payload).await?;
        }
        "Move" => {
            let m: Move = rmp_serde::from_slice(&bytes).map_err(|e| e.to_string())?;
            let input = MakeMoveInput { /* fields from m */ };
            let payload = ExternIO::encode(input).map_err(|e| e.to_string())?;
            call_zome(client, GAMES_ZOME, "make_move", payload).await?;
        }
        other => log::warn!("Skipping unknown entry type: {}", other),
    }
    Ok(())
}

Restore re-authors entries - every replayed record gets a new action hash. See the warning under restoreFromVault. The replay pattern that holds up in practice: key-first restore, a startup retry loop, and collect-and-continue on damaged records.

Seed adoption (shared-DHT apps) ​

For recognition apps, the records need no restoring - the network still holds them. What machine death takes is the agent key that authored them, and that's what the escrowed seed brings back. On a fresh machine:

  1. The user installs your app (it starts under a new, throwaway agent) and signs in with Flowsta.
  2. Your app retrieves its Vault backup, sees app_keys.device_seed_hex differs from the local seed, and offers the restore.
  3. On accept, the app adopts the escrowed seed and restarts; it now derives the same agent the lost machine had.
  4. The user signs back in, and their polls / posts / votes show up authored by them - because they were never re-authored at all. Data arrives via DHT sync, usually within minutes.

The adoption step replaces key-derived state, so its ordering is safety-critical. Follow this contract exactly:

  1. Stop your conductor and lair processes platform-correctly. On Windows that means taskkill /PID <pid> /T /F - Unix-style kill does not exist there, and calling it is a silent no-op that leaves the databases locked.
  2. Wipe the key-derived state (conductor data dir, lair dir, lair's own password file) with retries, and verify each removal actually happened - Windows releases file locks a beat after processes die, and remove_dir_all can report success while files are still pending-delete.
  3. Only after the wipe is verified, commit the new seed - and preserve the outgoing seed file with a timestamp rather than deleting it; a key must never be silently destroyed.
  4. If the wipe cannot complete, abort with nothing changed. Committing the seed over a half-wiped install relaunches into a hybrid where lair cannot start and nothing works.
  5. Restart the app; on next launch, import the seed into lair, derive the agent, and cross-check that lair's derived key equals your app-side derivation.

ProofPoll is the live reference for the whole model: src-tauri/src/device_seed.rs (app-side seed generation, lair import, escrow block) and src-tauri/src/seed_adopt.rs (the adoption engine with the ordering above behind a testable seam, the write guard, and the two-step restore UI contract). The engine is deliberately app-agnostic - the stop / wipe / verify / commit steps sit behind a small trait, so any conductor + lair layout can reuse the shape by implementing a handful of methods.

Rust-side alternative for AppWebsocket apps ​

startAutoBackup accepts an AdminWebsocket. If your app's frontend only has an AppWebsocket (typical for Tauri apps where the Rust side manages the conductor), generate the canonical payload from a Tauri command using zome queries, then feed it via the legacy getData() signature:

typescript
// Frontend
startAutoBackup({
  clientId,
  appName: 'YourApp',
  getData: () => invoke('build_canonical_backup'),
  intervalMinutes: 60,
});
rust
// Rust side - build the same canonical-shape payload from zome queries
#[tauri::command]
pub async fn build_canonical_backup(
    state: tauri::State<'_, Arc<AppState>>,
) -> Result<serde_json::Value, String> {
    let my_key = /* current agent_pub_key */;
    let client = state.app_client.lock().await;
    let client = client.as_ref().ok_or("Conductor not ready")?;

    let mut records: Vec<serde_json::Value> = Vec::new();
    let mut counts = serde_json::Map::new();

    // Query the user's own records via your zome functions,
    // build each into a record with human_readable + raw_record:
    //   - re-encode the entry struct via rmp_serde to get entry_b64
    //   - serde_json::to_value(struct) for human_readable
    // (See ProofPoll's build_canonical_backup for the full pattern.)

    Ok(serde_json::json!({
        "version": 1,
        "_readme": "Your YourApp data, backed up automatically by Flowsta Vault…",
        "license": "Cryptographic Autonomy License v1.0 (CAL-1.0)",
        "app": { "name": "YourApp" },
        "agent_pub_key": my_key,
        "_summary": { "countsByEntryType": counts, "totalRecords": records.len() },
        "cells": [{
            "role_name": "games",
            "_readme": "Each record below is one thing you did…",
            "records": records,
        }],
    }))
}

Vault recognizes the canonical shape regardless of who built it. ProofPoll uses this pattern - see build_canonical_backup for the full code.

The getData path needs its own write trigger

Unlike the V2 signature (whose controller exposes triggerBackupSoon() and backs up after every write by default), the getData signature only backs up on start and then per interval - a record created two minutes after launch doesn't reach the Vault until the next launch or the next tick. Add the missing half yourself: after every successful zome write, schedule a debounced backupToVault post (30 s debounce, one in flight, guarded with wouldOverwriteNonEmptyBackup). Route writes through one wrapper module so the trigger can't be forgotten per call site - see ProofPoll's src/lib/backup.ts and the backedUp() chokepoint in src/lib/holochain.ts.

startAutoBackup ​

Start automatic backups. Two signatures:

v2.4.0+ canonical-shape (recommended). Pass a FlowstaAutoBackupConfigV2: an AdminWebsocket (anything satisfying AdminWebsocketLike - dumpFullState is all the SDK calls) + decodeRecordForExport; the SDK captures the user's source chain and builds the canonical payload. Returns an AutoBackupController. Write-triggered backups (triggerOnWrite, default true) are debounced by debounceSeconds (default 30); heartbeatMinutes (default 30, 0 disables) adds a safety-net retry that only runs when there's been a write since the last backup. label defaults to 'latest' (one overwriting backup) - applied since 3.6.0; earlier versions dropped an omitted label and the Vault wrote a new timestamped snapshot on every run.

Multi-cell apps (v2.5.0+): pass additionalCells: [{ cellId, roleName }, …] alongside the primary cellId - each cell becomes its own entry in the payload's cells[], and restore walks them all. Without it, only the primary cell is backed up.

v2.3.0 legacy getData (backwards-compatible). Pass a FlowstaAutoBackupConfig with a getData() callback that returns the backup data directly (intervalMinutes, default 60; 0 = on start only). Returns a stop() function. Still supported; use this signature if your app builds the payload itself (see Rust-side alternative above).

Non-empty protection (every write since v3; introduced 2.6.0): protectNonEmpty (default true) refuses any write whose payload has zero user records while the existing Vault backup has some - the reinstall trap where the immediate first backup would destroy the user's real one. The guard lives in backupToVault, so it covers every write path; under startAutoBackup the refusal arrives as EmptyBackupSkippedError on onError (direct callers see it thrown). onError can also receive IdentityMismatchError (v3) - unlike the empty skip, never retry that one; surface it. Non-canonical payloads (no _summary.totalRecords) are never blocked.

See the canonical-shape example above for the v2.4 signature in use.

backupToVault ​

Trigger a single backup with arbitrary data. Takes FlowstaBackupOptions ({ clientId, appName, label?, contentType?, ipcUrl?, protectNonEmpty? }) and returns a FlowstaBackupResult ({ success, label, dataSize, createdAt }). label defaults to 'latest' (since 3.6.0), one named backup that each write overwrites; pass another label to keep a separate named object (named backups are never auto-rotated). Timestamped snapshots exist only at the bridge level - call POST /backup without a label yourself if you want them:

typescript
import { backupToVault } from '@flowsta/holochain';

const result = await backupToVault(
  { clientId: 'flowsta_app_abc123', appName: 'ChessChain', label: 'latest' },
  canonicalPayload,
);
console.log(result.dataSize, 'bytes at', new Date(result.createdAt * 1000));

Since v3, backupToVault guards itself - no pre-check needed. It refuses two dangerous writes by default:

  • An empty canonical payload over a non-empty backup throws EmptyBackupSkippedError (protectNonEmpty: false opts out).
  • A bound-identity mismatch (see Identity binding) throws IdentityMismatchError - never retry this one; tell the user.

It also throws BackupTooLargeError (50 MB per object), VaultLockedError (never unlocked this session), VaultNotFoundError (unreachable - including a browser that blocked the loopback; this call does not raise VaultBlockedError), and FlowstaHolochainError with the Vault's own code for anything else (not_linked, client_id_mismatch, restore_choice_pending, …). Note that the bridge answers not_linked to an origin that has not linked - back up only after linkFlowstaIdentity. wouldOverwriteNonEmptyBackup(options, payload) remains exported for apps that want the probe's answer before building an expensive payload, or that opted out of the default guard.

retrieveFromVault ​

Retrieve a stored backup. Takes FlowstaBackupRetrieveOptions ({ clientId, label?, across?, ipcUrl? }); label defaults to 'latest' (since 3.6.0). across: 'devices' (3.7.0, Vault 1.6.0+) reads the newest backup under that label on any of the person's devices and needs a label; the result then carries fromDevice (null when it was made on this device). Returns { data, label?, createdAt, dataSize } | null - data is the stored JSON. Since v3, null means exactly one thing - the Vault confirmed no backup exists for this clientId/label. Every other outcome throws, so an offline Vault can never read as "no data":

typescript
import {
  retrieveFromVault,
  VaultNotFoundError,
  VaultLockedError,
  IdentityMismatchError,
} from '@flowsta/holochain';

try {
  const backup = await retrieveFromVault({
    clientId: 'flowsta_app_abc123',
    label: 'latest',
  });
  if (!backup) {
    // CONFIRMED: no backup stored. Safe to treat as a fresh start.
    return;
  }
  await importData(backup.data);
  // backup.data is whatever was stored; for canonical-shape backups
  // it follows the canonical v1 payload format.
} catch (e) {
  if (e instanceof VaultNotFoundError) {
    // Vault not running - NOT "no backup". Ask the user to open it.
  } else if (e instanceof VaultLockedError) {
    // Never unlocked this session - ask the user to unlock.
  } else if (e instanceof IdentityMismatchError) {
    // The slot holds ANOTHER identity's backup - never overwrite it.
  } else {
    // Unreadable slot or other Vault error - retry later.
  }
}

restoreFromVault ​

Restore re-authors your entries

Restoring replays each record onto the current agent's fresh source chain - every restored entry gets a new action hash and a new timestamp (Holochain doesn't support direct source-chain import). Content matches what the user originally authored; cryptographic chain continuity does not - and for most apps (polls, votes, games, messages), content-level restore is what users care about. record.actionHash in the dispatcher is the hash at backup time. If your app keys data by action hash, build an old→new mapping during restore.

Walk a backup and call your dispatcher once per record (RestoreFromVaultOptions: { clientId, dispatcher, onProgress?, label?, ipcUrl? }; the dispatcher receives a BackupRecord). Returns a RestoreFromVaultResult ({ totalRecords, succeeded, failed }). Per-record failures are caught - the function continues through the remaining records and returns them in result.failed. DispatcherFailedError is thrown only when every record fails, which means the dispatcher itself is broken rather than any individual record.

typescript
import { restoreFromVault } from '@flowsta/holochain';

const result = await restoreFromVault({
  clientId: 'flowsta_app_abc123',
  dispatcher: async (record) => {
    // record: { entryType, actionHash, createdAtMs, human_readable, raw_record, cellRoleName }
    await invoke('restore_record', {
      entryType: record.entryType,
      entryBytesB64: record.raw_record.entry_b64,
    });
  },
  onProgress: (current, total) => console.log(`${current}/${total}`),
  label: 'latest',                              // default since 3.6.0
});

console.log(`Restored ${result.succeeded}/${result.totalRecords}`);
for (const f of result.failed) {
  console.warn(`Could not restore ${f.record.entryType}: ${f.error}`);
}

Since v3, { totalRecords: 0, succeeded: 0, failed: [] } means the Vault confirmed there is nothing to restore - and nothing else. An unreachable Vault throws VaultNotFoundError, a locked one VaultLockedError, a slot holding another identity's backup IdentityMismatchError (stop and tell the user - never retry that one), an unreadable slot FlowstaHolochainError. Wrap the call and treat only the zero-record result as a benign no-op. Calling restoreFromVault again for the same clientId while a restore is running throws RestoreInProgressError.

dumpCellStateForBackup ​

Build a canonical-shape records[] array from a Holochain admin dumpFullState call (DumpCellStateOptions in, DumpCellStateResult - { records: BackupRecord[], summary: BackupSummary } - out). Used internally by startAutoBackup's v2.4 signature; exposed so apps can serialize to file (debug) or transform before posting:

typescript
import { dumpCellStateForBackup } from '@flowsta/holochain';

const { records, summary } = await dumpCellStateForBackup({
  adminWebsocket: adminWs,
  cellId: gamesCellId,
  agentPubKey: myAgentBytes,
  roleName: 'games',
  decodeRecordForExport: (entryType, entryB64) =>
    invoke('decode_record_for_export', { entryType, entryBytesB64: entryB64 }),
});

buildBackupPayload ​

Build a canonical BackupPayload from your app's cell(s) without posting it to the Vault - the public way to construct the payload yourself, for writing to a file (debugging), inspecting what a backup will contain, or posting manually via backupToVault. Takes the same config object as startAutoBackup's canonical-shape signature; this is also where additionalCells applies - each extra cell becomes its own entry in the payload's cells[]:

typescript
import { buildBackupPayload, backupToVault } from '@flowsta/holochain';

const payload = await buildBackupPayload({
  clientId: 'flowsta_app_abc123',
  appName: 'ChessChain',
  adminWebsocket: adminWs,
  cellId: gamesCellId,
  cellRoleName: 'games',
  additionalCells: [{ cellId: chatCellId, roleName: 'chat' }],   // v2.5.0+
  agentPubKey: myAgentBytes,
  decodeRecordForExport: (entryType, entryB64) =>
    invoke('decode_record_for_export', { entryType, entryBytesB64: entryB64 }),
});

console.log(payload._summary.countsByEntryType);   // e.g. { Game: 12, Move: 84 }

// Post it yourself, or write it to a file for inspection:
await backupToVault({ clientId: 'flowsta_app_abc123', appName: 'ChessChain' }, payload);

listVaultBackups ​

List your app's backups - the Vault never lists other apps' backups to you, so apps holds your entry or nothing. Returns FlowstaBackupStats; each FlowstaBackupEntry carries { clientId, appName, backupCount, totalSize, lastBackupAt, labels? } - labels (v3.2.0, Vault 1.3.0+) is every label the app has stored, so you can reconcile your own index against the Vault in one call instead of retrieving per label; older Vaults omit it. otherDevices (v3.7.0, Vault 1.6.0+) lists, per other device of the person, the labels it holds for your app ({ device, backups: [{ label, createdAt, dataSize }] }); absent when there are none. See backups across devices. This call deliberately never throws: empty stats ({ appCount: 0, totalBackups: 0, totalSize: 0, apps: [] }) also mean "Vault unavailable", "Vault locked" (listing needs an unlocked Vault), or "this origin is not linked" - not only "no backups". Confirm absence with retrieveFromVault (which returns null only for a confirmed empty slot) before treating it as a fresh start:

typescript
import { listVaultBackups } from '@flowsta/holochain';

const stats = await listVaultBackups();
console.log(`${stats.appCount} apps, ${stats.totalBackups} backups, ${stats.totalSize} bytes`);
for (const app of stats.apps) {
  console.log(`${app.appName}: ${app.backupCount} backups, ${app.totalSize} bytes`);
}

For per-entry-type counts (e.g. { Game: 12, Move: 84 }), retrieve the backup itself and read the canonical payload's _summary.countsByEntryType.

Encrypted Entries on Public DHT ​

Holochain apps can store private data on the public DHT by encrypting entries client-side before committing them. Peers replicate the opaque blob for resilience, but only the key-holder can decrypt it.

Replicated ciphertext is only as durable as the key that opens it - so pick your key model before your cipher. It decides whether the data is readable on a second device, and whether it survives losing the first one.

Choosing a key model ​

Your appEncrypt withRecovery story
Single-device, and losing the device may lose the dataThe agent's lair-managed keys via crypto_box (the pattern below)Generate the agent seed app-side and escrow it in app_keys - then device loss is recoverable via seed adoption, and the encrypted entries decrypt again under the restored key. A seed born inside lair can't be extracted, and a lair keystore must never be copied between machines (two conductors on one key silently break gossip) - escrow-and-import is the supported path.
Your app holds a user-level secret (recovery phrase or password)A symmetric XSalsa20-Poly1305 (secretbox) key derived from the secret: HMAC-SHA256(domain-separation-constant, secret)Every device that knows the secret derives the same key - multi-device reads and secret-only recovery, with no key exchange. This is the model Flowsta Vault itself uses in production for its own private data.
Standalone-first: fully usable before the person links a Flowsta identityAn app-generated random symmetric key, stored locallyShip a key-export UX from day one, and when the user links Flowsta, escrow the key in their Vault backup via the app_keys block - it then rides both their single-app export and their full data export. Until exported or escrowed, device loss means data loss, however many peers hold the ciphertext.

The rest of this section documents the first pattern (agent-key crypto_box). The commit, validation, and metadata guidance applies to all three.

How it works ​

  1. Encrypt in your app backend using the agent's lair-managed keys (crypto_box_xsalsa_by_sign_pub_key - lair converts Ed25519 to x25519 internally). Works with any framework that can connect to lair (Tauri, Electron, Node.js, etc.)
  2. Commit the ciphertext as a public entry with a generic "private" hint (the entry body carries no content-type metadata)
  3. Peers replicate the opaque bytes via gossip - they can see the entry exists but cannot read it
  4. Decrypt when reading - only the author's lair private key can open the crypto_box

What peers see ​

cipher: [187, 202, 33, ...]  (opaque bytes, xsalsa20poly1305)
nonce:  [244, 219, 96, ...]  (24 bytes, random)
hint:   "private"             (entry body carries no content-type metadata)

Metadata caveat ​

The entry body reveals nothing - but link types and anchors are public on the DHT. A link type like ProofPoll's VoteToRationale tells peers "this encrypted blob is a vote rationale for that vote," and links from an agent-scoped anchor reveal how many private entries an agent has and when they were created. The contents stay sealed; the kind, count, timing, and relationships of private entries can be inferred from the link graph.

If metadata-hiding matters for your app: use a single opaque link type for all private data, avoid storing plaintext references to related entries (encrypt the reference inside the payload instead), and route by decrypted content rather than by link type.

The strongest form of this is a sealed envelope: one opaque entry type whose ciphertext carries the real entry type, timestamps, and relationships inside the payload, linked by a single link type with an empty tag - peers can infer nothing beyond record count and timing. And where the data is genuinely single-user, a per-user network seed narrows the audience further still: each user's records gossip only among their own devices and nodes, so even the residual metadata is seen by no one else.

Two more things your integrity zome should consider: cap the ciphertext size in your validate callback (peers must replicate whatever you allow), and if deletion matters for your data, enforce author-only deletes at the integrity level - a coordinator-side check can be bypassed by a modified client. Note also that DHT data is permanent: a "deleted" encrypted entry is tombstoned, not erased, so its ciphertext remains on peers indefinitely.

Key properties ​

  • 256-bit security - XSalsa20-Poly1305 with X25519 key exchange
  • Tied to Holochain identity - uses the agent's lair-managed keys, not a separate password
  • Peers hold ciphertext, not recovery - replication means the data survives device loss, but it's only readable again if the key survived too (escrow an app-side seed in app_keys, or use a key model from the table above)
  • Future-ready for sharing - X25519 naturally supports encrypting to other agents (not just self)

Framework support ​

The encryption happens via lair-keystore's client API (lair_keystore_api crate in Rust, or any language that can speak lair's protocol). Any framework that manages a local Holochain conductor can use this pattern:

  • Tauri - Use lair_keystore_api directly in Rust (see ProofPoll's crypto.rs)
  • Electron - Use lair_keystore_api via a native Node.js addon, or call lair through its Unix socket
  • Any backend - Connect to lair's socket and use the CryptoBoxXSalsaBySignPubKey request

Reference implementation ​

ProofPoll demonstrates this pattern with vote rationales (private notes on votes) and draft polls (encrypted until published). See ProofPoll's crypto.rs, EncryptedEntry type, and the encrypted entry Tauri commands.

Error Types ​

Every error the SDK throws extends FlowstaHolochainError, so a single instanceof FlowstaHolochainError catch-all works - check the subclasses first. code is the stable machine-readable string (the Vault's own error code where one exists); description carries the Vault's explanation when it sent one.

ErrorcodeDescription
FlowstaHolochainErrorvariesBase class. Also thrown directly with the Vault's code when no subclass fits (timeout, reserved_prefix, not_linked, client_id_mismatch, restore_choice_pending, conductor_not_ready, commit_failed, …) and for SDK-side conditions (no_crypto, no_signature, relay_start_failed, relay_poll_failed)
VaultNotFoundErrorvault_not_foundVault not running or not installed (or, from the backup and link calls, blocked by the browser)
VaultBlockedError (v3.1.0)vault_blockedThe browser refused this page's request to 127.0.0.1 (Chrome 142+ Local Network Access denied, Brave's localhost block). The Vault may be running. Thrown by signDocument and authenticateWithVault; getVaultStatus reports it as blocked: true
VaultLockedErrorvault_lockedVault is locked (signing, sign-in, linking), or has never been unlocked this session (backups and retrieval work while locked after a first unlock; since v3 a never-unlocked Vault THROWS this from retrieveFromVault rather than returning null)
UserDeniedErroruser_deniedUser rejected the approval dialog
InvalidClientIdErrorinvalid_client_idFlowsta did not recognize, or has disabled, the client_id (linkFlowstaIdentity; the Vault's app_not_found / app_disabled / invalid_client_id)
MissingClientIdErrormissing_client_idNo client_id provided (linkFlowstaIdentity)
ApiUnreachableErrorapi_unreachableCannot reach Flowsta to verify the client_id - the first link needs internet; later links use the Vault's cache
PublishForbiddenError (v3.6.0)tier_forbiddensignDocument asked to publish from an app that is not linked. Link first (linkFlowstaIdentity), or pass publish: false
QuotaExceededError (v3.6.0)quota_exceededThe signing quota for this period is used up (the person's plan, and the sponsor pool when your app sponsors)
BackupTooLargeError (v2.5.0)backup_too_largeBackup payload exceeds the Vault's 50 MB per-object limit. The cap is per backup object, not per app - split large payloads into named parts (read the cap from GET /backup/limits)
DispatcherFailedError (v2.4.0)dispatcher_failedThrown by restoreFromVault only when every record fails - the dispatcher itself is broken. Per-record failures don't throw; they're returned in result.failed. Carries record and cause
RestoreInProgressError (v2.4.0)restore_in_progressConcurrent restoreFromVault calls collided for the same client_id
DecodeFailedError (v2.4.0)decode_failedNever thrown by the SDK - reserved for your own decoders to throw. When decodeRecordForExport fails, backup keeps walking: the record keeps its signed raw_record (restore is unaffected) and its human_readable degrades to { _warning: 'decode_failed' }
EmptyBackupSkippedError (every write since v3; introduced 2.6.0)empty_backup_skippedA backup write was refused: the payload had zero user records but the Vault backup has some (typical right after a reinstall, before recovery). Under startAutoBackup it arrives via onError; direct backupToVault callers see it thrown. Not a failure - finish recovery and the next non-empty backup writes normally
IdentityMismatchError (v3.0.0)identity_mismatchCarries expected/actual. Thrown by retrieveFromVault/restoreFromVault (the slot holds another identity's backup - Vault 409) and by backupToVault/signDocument/authenticateWithVault (the Vault's active identity differs from the bound one, checked client-side and by the Vault's expected_identity gate). Never retry after a restore - stop and tell the user to unlock the matching Vault
SigningDnaNotInstalledErrorsigning_dna_not_installedDeprecated (3.6.0), never thrown - the Vault does not emit this code. Kept so existing catch branches compile; removed in 4.0

What each call does in each situation ​

SituationlinkFlowstaIdentitysignDocument / authenticateWithVaultbackupToVault / retrieveFromVault / restoreFromVaultlistVaultBackups / getFlowstaLinkStatus / revokeFlowstaIdentity
No Vault answersVaultNotFoundErrorVaultNotFoundErrorVaultNotFoundErrorempty stats / offline / { success: false }
Browser blocked the loopbackVaultNotFoundErrorVaultBlockedErrorVaultNotFoundErrorsame as above
Vault lockedVaultLockedErrorVaultLockedError (the Vault holds an /authenticate ~55 s for an unlock first)write and retrieve proceed after a first unlock this session, else VaultLockedErrorempty stats (listing needs unlock) / works / works
Vault holds a different identity than the bindingproceeds and re-binds (a deliberate, user-approved choice)IdentityMismatchErrorIdentityMismatchErrorthe Vault refuses the GET (409) → empty stats / offline
Origin not linkedn/asignature-only proceeds; publish: true → PublishForbiddenErrorFlowstaHolochainError (not_linked)empty stats / unlinked / { success: false }
User denies, or 60 s passUserDeniedError / FlowstaHolochainError (timeout)samen/an/a
Quota used up (publishing)n/aQuotaExceededErrorn/an/a
Backup slot holds another identity's datan/an/aretrieveFromVault/restoreFromVault: IdentityMismatchError; backupToVault: EmptyBackupSkippedError when the payload is empty (the probe refuses to overwrite), IdentityMismatchError when the app is bound; an unbound app with a non-empty payload can overwrite - see the write guardsn/a
No backup storedn/an/aretrieveFromVault → null; restoreFromVault → { totalRecords: 0 }; backupToVault writesempty stats

Function Reference ​

FunctionDescription
linkFlowstaIdentity(options)Request identity link from Vault; binds the app to the identity that approved (v3)
getFlowstaIdentity(options)Query linked agents on DHT (zomeName defaults to 'agent_linking')
getVaultStatus(ipcUrl?)Check Vault status - VaultStatus (includes displayName, profilePicture from v2.3.0; webUsername from v2.4.1; blocked from v3.1.0; activeIdentity, identityEpoch, instanceId from v3.5.0; did from v3.6.0)
getVaultIdentity(ipcUrl?) (v3.4.0)The unlocked identity's agent key, or null when locked, absent, or blocked
revokeFlowstaIdentity(options)Notify Vault of revocation - { success }, never throws; false when the Vault is absent or refuses (only the linked app's own origin, or Flowsta, may revoke)
getFlowstaLinkStatus(options)Check link status - three-state result (linked/unlinked/offline). Recommended over checkFlowstaLinkStatus. Sends expected_identity when bound (v3.5.0) (v2.3.0)
checkFlowstaLinkStatus(options)Check link status - boolean result. Deprecated since v2.3.0; use getFlowstaLinkStatus.
reconnectIdentity({ clientId, localAgentPubKey, ipcUrl? }) (v3.4.0)After an identity switch: rebinds silently when the new identity already holds a link for this app, else approval_needed; locked / offline leave the binding alone - ReconnectIdentityResult
signDocument(options)Sign a file hash via Vault - user approves in the Vault UI. Publishes to the Sign It network for a linked app (publish, v3.6.0); returns SignDocumentResult with actionHash and published
getSigningStatus(ipcUrl?)Check signing availability before rendering a sign button. Returns { available, vaultRunning, vaultUnlocked }
authenticateWithVault(challenge, options?)Sign your app's own challenge with the Vault's device key ("Sign in with your Vault"). Returns { signature, agentPubKey, did, email?, emailVerified? } (AuthenticateWithVaultResult)
startRelayLogin(apiUrl, options?)Start a relay sign-in for browsers that can't reach a local Vault. Returns RelayLoginSession ({ userCode, expiresIn, poll })
openVaultDeepLink(userCode)Hand a relay code to a local Vault via flowsta://relay/v1 (fire-and-forget - always show the typed-code fallback)
startAutoBackup(options)Start automatic backups. Two overloaded signatures - canonical-shape (FlowstaAutoBackupConfigV2, v2.4.0+) returns AutoBackupController; legacy getData() (FlowstaAutoBackupConfig) returns stop(). label defaults to 'latest' (applied since v3.6.0)
buildBackupPayload(config)Build a canonical BackupPayload from your cell(s) without posting to Vault. Supports additionalCells (v2.5.0+)
backupToVault(options, data)Store data in Vault under label (default 'latest', v3.6.0). Guards itself since v3: refuses empty-over-non-empty (EmptyBackupSkippedError) and bound-identity mismatches (IdentityMismatchError)
retrieveFromVault(options)Retrieve a stored backup (label default 'latest', v3.6.0) - { data, label?, createdAt, dataSize }, or null for CONFIRMED absent only (v3); unreachable/locked/foreign-identity/unreadable all throw
restoreFromVault(options) (v2.4.0)Walk a backup and call the provided dispatcher per record - RestoreFromVaultResult
dumpCellStateForBackup(options) (v2.4.0)Build a canonical-shape records array from a Holochain admin dump - DumpCellStateResult
listVaultBackups(ipcUrl?)List your app's backups - FlowstaBackupStats with per-app { clientId, appName, backupCount, totalSize, lastBackupAt, labels? }. Deliberately fails open: empty stats also mean unavailable, locked, or not linked - don't infer "no backup" from it alone. Sends expected_identity when bound (v3.5.0)
wouldOverwriteNonEmptyBackup(options, payload) (v2.6.0)Probe whether a payload would replace a non-empty backup, without writing. backupToVault runs this internally since v3
resolveVaultUrl(ipcUrl?) (v3.0.0)Resolve the Vault's IPC URL - probes 127.0.0.1:27777-27779 in parallel on every call and ranks the answers (bound identity first, v3.4.0/3.5.0); explicit ipcUrl wins verbatim
loopbackPermissionState() (v3.1.0)What the browser has decided about this page reaching 127.0.0.1: 'granted' | 'denied' | 'prompt' | 'unknown' ('unknown' outside Chrome/Firefox and in desktop apps)
bindVaultIdentity(agentPubKey) (v3.0.0)Record the Vault identity this app belongs to (automatic after linkFlowstaIdentity)
getBoundIdentity() / clearBoundIdentity() (v3.0.0)Read / clear the bound identity - one slot per origin
agentKeysMatch(a, b) (v3.0.0)Compare agent keys across base64url and base58 encodings - true/false/null (can't compare)
partitionKeyFor(agentPubKey) (v3.3.0)The folder-safe per-identity key: first 16 hex chars of SHA-256 over the key's 39 raw bytes; null for a non-key. Same for both spellings of a key and the same key the Vault and Flowsta's apps use. Async (Web Crypto)
PARTITION_KEY_LENGTH (v3.3.0)Constant 16 - the length of a partition key
onIdentityChanged(cb, opts?) (v3.0.0)Poll for Vault identity switches - opts { ipcUrl?, intervalMs? } (default 5000 ms); epoch-based against Vault 1.5.0 (v3.5.0); returns a stop function. UX aid - the asserting calls check independently

Types ​

Every option and result type is exported, so import type { … } from '@flowsta/holochain' works for all of them:

TypeUsed by
VaultStatusgetVaultStatus result
FlowstaLinkStatusgetFlowstaLinkStatus result (linked / unlinked / offline)
ReconnectIdentityResultreconnectIdentity result
LinkFlowstaIdentityOptions / LinkFlowstaIdentityResultlinkFlowstaIdentity - the result's payload holds vaultAgentPubKey and vaultSignature
GetFlowstaIdentityOptionsgetFlowstaIdentity
RevokeFlowstaIdentityOptionsrevokeFlowstaIdentity
CheckFlowstaLinkStatusOptionsgetFlowstaLinkStatus and checkFlowstaLinkStatus
SignDocumentOptions / SignDocumentResultsignDocument
AuthenticateWithVaultOptions / AuthenticateWithVaultResultauthenticateWithVault
RelayLoginSession / RelayPollResult / RelayStatusstartRelayLogin and its poll()
FlowstaBackupOptions / FlowstaBackupResultbackupToVault
FlowstaBackupRetrieveOptionsretrieveFromVault
FlowstaBackupStats / FlowstaBackupEntrylistVaultBackups
FlowstaAutoBackupConfig / FlowstaAutoBackupConfigV2 / AutoBackupControllerstartAutoBackup (legacy / canonical-shape signature / the controller it returns)
AdminWebsocketLikeThe adminWebsocket the canonical-shape signature needs - only dumpFullState({ cell_id })
BackupPayload / BackupRecord / BackupSummaryThe canonical payload, one record in it, and its _summary
DumpCellStateOptions / DumpCellStateResultdumpCellStateForBackup
RestoreFromVaultOptions / RestoreFromVaultResultrestoreFromVault

Next Steps ​

Documentation licensed under CC BY-SA 4.0.