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.1.0. v3 is a breaking release - error states no longer read as "no data"; see Migrating to v3 and the notes through this page.

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';

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 account switches so your UI can react before a call refuses.

The SDK finds the Vault (v3)

With no ipcUrl, every call resolves the Vault by sweeping 127.0.0.1:27777-27779 and caching the answer (resolveVaultUrl is exported). A second Vault instance shifts ports - "absent" used to fail open at 27777. 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',
  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. From v2.3.0 the result also carries displayName and profilePicture for the currently-unlocked account; 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,
//   displayName?: string,      // v2.3.0+, scope-gated
//   profilePicture?: string,   // v2.3.0+, scope-gated
//   webUsername?: string,      // v2.4.1+, scope-gated
//   version?: string,
// }

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 the signing / sign-in / backup calls throw VaultBlockedError instead of VaultNotFoundError). 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 account has the value set.

revokeFlowstaIdentity

Notify Vault that a link has been revoked. Best-effort - if Vault is not running, returns { success: false } without throwing:

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 accounts,
    // 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 + greyed-out profile chip. Your Own AI uses the auto re-link on launch pattern.

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);
}

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.

FunctionReturnsSummary
signDocument(options)Promise<SignDocumentResult>Sign a file hash. User approves in Vault. Commits a SignatureRecord to the signing DNA.
getSigningStatus(ipcUrl?)Promise<{ available: boolean; vaultRunning: boolean; vaultUnlocked: boolean }>Lightweight check before rendering a "Sign with Flowsta" button. Does not prompt the user.

Error classes: VaultBlockedError (v3.1.0), VaultNotFoundError, VaultLockedError, UserDeniedError, SigningDnaNotInstalledError.

Your app must be linked in Vault (via linkFlowstaIdentity) with a stable origin - the IPC /sign-document endpoint is gated on the caller origin matching a linked app.

Full parameter and response tables: Sign It SDK Reference.

Sign In with Your Vault

Let users prove who they are with the key on their own device - no password, 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 Flowsta auth challenge through the local Vault's IPC server. Browser-safe: plain fetch, no dependencies. The user approves in a Vault dialog (~60 seconds).

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

// 1. Get a challenge from the Flowsta API
const challengeRes = await fetch('https://auth-api.flowsta.com/auth/vault/challenge', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ client_id: YOUR_CLIENT_ID }),
});
const { challenge } = await challengeRes.json(); // "flowsta-auth-challenge:v1:…"

// 2. Ask the Vault to sign it - pass the challenge string EXACTLY as issued
const result = await authenticateWithVault(challenge, {
  appName: 'ChessChain',
  reason: 'Sign in to ChessChain',
});
// { signature: string, agentPubKey: string, did: string }

// 3. Exchange the signature for a session
const tokenRes = await fetch('https://auth-api.flowsta.com/auth/vault/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    challenge,
    signature: result.signature,
    agent_pub_key: result.agentPubKey,
  }),
});

Options (all optional): { ipcUrl?, appName?, reason? }. 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).

Pass the challenge verbatim, and mind the browser

Pass the challenge string exactly as POST /auth/vault/challenge issued it - the SDK handles the encoding the Vault expects internally; 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);

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.

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 - Vault's "Download Export" 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.

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

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). Your Own AI is replay (per-user transcript DHTs) plus extension blocks - its AI configurations, per-AI images, and profile-memory file ride the same backup as its Holochain records.

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; 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 "Download Export" - 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.

Your Own AI ships live examples: ai_configs (its AI personalities), thumbnails (per-AI images), and memory_facts (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. Your Own AI is the live replay reference - 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 passphrase) 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 an AdminWebsocket + 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.

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 getData() callback that returns the backup data directly. 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. Omit label to create a new timestamped snapshot, or pass a label (typically "latest") to overwrite a named backup:

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

await backupToVault(
  { clientId: 'flowsta_app_abc123', appName: 'ChessChain', label: 'latest' },
  canonicalPayload,
);

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), and VaultNotFoundError. 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. Omit label to get the most recent snapshot. 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. 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
});

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. 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 every app's backups in the user's Vault. Each app entry carries { clientId, appName, backupCount, totalSize, lastBackupAt }. Returns empty stats ({ appCount: 0, totalBackups: 0, totalSize: 0, apps: [] }) if the Vault is unavailable:

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 passphrase)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 with no account at allAn 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.

ErrorDescription
FlowstaHolochainErrorBase class. Carries a stable machine-readable code (and sometimes a description)
VaultNotFoundErrorVault not running or not installed
VaultLockedErrorVault has never been unlocked this session (backups and retrieval work while locked after first unlock; since v3 a never-unlocked Vault THROWS this from retrieveFromVault rather than returning null)
UserDeniedErrorUser rejected the approval dialog
InvalidClientIdErrorClient ID not registered
MissingClientIdErrorNo client_id provided
ApiUnreachableErrorCannot reach Flowsta API
SigningDnaNotInstalledErrorVault's signing DNA isn't available - the user needs the latest Flowsta Vault
BackupTooLargeError (v2.4.0)Backup 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)Thrown by restoreFromVault only when every record fails - the dispatcher itself is broken. Per-record failures don't throw; they're returned in result.failed
RestoreInProgressError (v2.4.0)Concurrent restoreFromVault calls collided for the same client_id
DecodeFailedError (v2.4.0)Never 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)A 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)Code identity_mismatch, carries 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). Never retry after a restore - stop and tell the user to unlock the matching Vault

Function Reference

FunctionDescription
linkFlowstaIdentity(options)Request identity link from Vault
getFlowstaIdentity(options)Query linked agents on DHT
getVaultStatus(ipcUrl?)Check Vault status (includes displayName, profilePicture from v2.3.0; webUsername from v2.4.1)
revokeFlowstaIdentity(options)Notify Vault of revocation
getFlowstaLinkStatus(options)Check link status - three-state result (linked/unlinked/offline). Recommended over checkFlowstaLinkStatus. (v2.3.0)
checkFlowstaLinkStatus(options)Check link status - boolean result. Deprecated since v2.3.0; use getFlowstaLinkStatus.
signDocument(options)Sign a file hash via Vault - user approves in the Vault UI
getSigningStatus(ipcUrl?)Check signing availability before rendering a sign button. Returns { available, vaultRunning, vaultUnlocked }
authenticateWithVault(challenge, options?)Sign a Flowsta auth challenge with the Vault's device key ("Sign in with your Vault"). Returns { signature, agentPubKey, did }
startRelayLogin(apiUrl, options?)Start a relay sign-in for browsers that can't reach a local Vault. Returns { userCode, expiresIn, poll }
openVaultDeepLink(userCode)Hand a relay code to a local Vault via flowsta:// (fire-and-forget - always show the typed-code fallback)
startAutoBackup(options)Start automatic backups. Two overloaded signatures - canonical-shape (v2.4.0+) returns AutoBackupController; legacy getData() returns stop()
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. Guards itself since v3: refuses empty-over-non-empty (EmptyBackupSkippedError) and bound-identity mismatches (IdentityMismatchError)
retrieveFromVault(options)Retrieve stored backup - null means 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
dumpCellStateForBackup(options) (v2.4.0)Build a canonical-shape records array from a Holochain admin dump
listVaultBackups(ipcUrl?)List all backups in Vault - per-app { clientId, appName, backupCount, totalSize, lastBackupAt }. Deliberately fails open (empty stats when the Vault is unavailable) - don't infer "no backup" from it alone
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 - sweeps 127.0.0.1:27777-27779 and caches; explicit ipcUrl wins verbatim
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
agentKeysMatch(a, b) (v3.0.0)Compare agent keys across base64url and base58 encodings - true/false/null (can't compare)
onIdentityChanged(cb, opts?) (v3.0.0)Poll for Vault account switches (UX aid - the asserting calls check independently)

Next Steps

Documentation licensed under CC BY-SA 4.0.