Skip to content

@flowsta/holochain

SDK for integrating Holochain apps with Flowsta Vault.

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

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_direct_link',
  payload: {
    other_agent: decodeHashFromBase64(result.payload.vaultAgentPubKey),
    other_signature: base64ToSignature(result.payload.vaultSignature),
  },
});

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,
//   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,
// }

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 recognises 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 recognises 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 recognise this app's agent — the
    // user unlinked from Vault's UI, switched Flowsta accounts, or
    // restored Vault from a different recovery phrase. Show a
    // "Reconnect or Disconnect" banner. 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;
}

Recommended UX pattern: render a top-of-page banner when state is unlinked, offering "Reconnect" (re-link with the current Vault account) or "Disconnect" (deliberately revoke). Never silently revoke. Apps that collapsed this to a boolean and auto-revoked frustrated users who had simply closed Vault briefly.

ProofPoll has the reference implementation — see ProofPoll/src/lib/context.ts and ProofPoll/src/routes/layout.tsx for the layout-level banner + greyed-out profile chip 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<SigningStatusResult>Lightweight check before rendering a "Sign with Flowsta" button. Does not prompt the user.

Error classes: 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.

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.
  • 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.

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.

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)

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; debounce 30s
  heartbeatMinutes: 30,                             // default; safety-net retry
  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 — your app never holds the user's cryptographic keys, and a backup shouldn't carry them. 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.

Restore is recognition, not replay

To get a user's data back on a new device, the recommended path is recognition: they sign in with their Flowsta identity, the Vault recognises their agent set, and their on-network data re-syncs from the DHT — no key import and no record replay. (restoreFromVault is still available if your app wants to explicitly replay its backed-up records onto a fresh source chain.)

Reinstall recovery

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.

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),
    counts: ours.latestSummary?.counts_by_entry_type,
  });

  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 semantics

Replayed entries get new action hashes and timestamps (Holochain doesn't support direct source-chain import). Content matches what the user originally authored; cryptographic chain continuity does not. For most apps — polls, votes, games, messages — content-level restore is what users care about.

ProofPoll has the live reference implementation — see src-tauri/src/commands.rs (the three Tauri commands at the bottom) and src/routes/layout.tsx (the auto-backup wire-up + restore-on-first-launch modal).

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 recognises the canonical shape regardless of who built it. ProofPoll uses this pattern — see build_canonical_backup for the full code.

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.

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).

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

retrieveFromVault

Retrieve a stored backup. Omit label to get the most recent snapshot:

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

const backup = await retrieveFromVault({
  clientId: 'flowsta_app_abc123',
  label: 'latest',
});
// backup.data is whatever was stored; for canonical-shape backups
// it follows the canonical v1 payload format.

restoreFromVault

Walk a backup and call your dispatcher once per record. Per-record failures are caught; the function continues through the remaining records and returns a { totalRecords, succeeded, failed } summary.

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

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

listVaultBackups

List every app's backups in the user's Vault. From v2.4.0, each app entry can include a latestSummary field with per-entry-type counts from the most recent canonical-shape backup:

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

const stats = await listVaultBackups();
for (const app of stats.apps) {
  console.log(`${app.appName}: ${app.backupCount} backups, ${app.totalSize} bytes`);
  // app.latestSummary?.counts_by_entry_type → e.g. { Game: 12, Move: 84 }
}

Encrypted Entries on Public DHT

Holochain apps can store private data on the public DHT by encrypting entries client-side before committing them. The encrypted blob is replicated across peers for resilience (survives device loss), but only the author can decrypt it.

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 (no metadata about content type)
  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"             (no content type metadata)

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
  • DHT is the backup — data survives device loss because peers replicate the ciphertext
  • 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

ErrorDescription
VaultNotFoundErrorVault not running or not installed
VaultLockedErrorVault has never been unlocked this session (backups and retrieval work while locked after first unlock)
UserDeniedErrorUser rejected the approval dialog
InvalidClientIdErrorClient ID not registered
MissingClientIdErrorNo client_id provided
ApiUnreachableErrorCannot reach Flowsta API
BackupTooLargeError (v2.4.0)Backup payload exceeds Vault's 50 MB per-app limit
DispatcherFailedError (v2.4.0)A restoreFromVault dispatcher threw on a record. Carries the failed record and underlying cause
RestoreInProgressError (v2.4.0)Concurrent restoreFromVault calls collided for the same client_id
DecodeFailedError (v2.4.0)decodeRecordForExport threw for a record. Backup keeps walking; the offending record carries _warning: "decode_failed"

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)
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.
startAutoBackup(options)Start automatic backups. Two overloaded signatures — canonical-shape (v2.4.0+) returns AutoBackupController; legacy getData() returns stop()
backupToVault(options, data)Store data in Vault
retrieveFromVault(options)Retrieve stored backup
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. Each app entry may include a latestSummary from canonical-shape backups

Next Steps

Documentation licensed under CC BY-SA 4.0.