@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: 2.5.0.
Installation
npm install @flowsta/holochainAgent Linking
linkFlowstaIdentity
Request an identity link from the user's Flowsta Vault:
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),
},
});getFlowstaIdentity
Query linked agents on your DHT. Returns an array of linked agent public keys (as raw bytes):
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:
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:
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.
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
getFlowstaLinkStatusreturnsunlinked, 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
getFlowstaLinkStatusinstead. 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.
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.
| Function | Returns | Summary |
|---|---|---|
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: 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).
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 VaultNotFoundError, VaultLockedError, UserDeniedError, 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, which only Chromium-based browsers (and desktop apps) permit from HTTPS pages. On Firefox, Safari, and phones, use the relay login below.
Building a desktop app? See Sign in with your Vault for Tauri apps for the full desktop flow.
startRelayLogin + openVaultDeepLink
Relay login covers browsers that can't reach a local Vault over loopback: phones (no Vault on the device) and Firefox/Safari on desktop. The browser and the user's desktop Vault meet at the Flowsta API:
startRelayLogin()mints a short user code (formattedXXXX-XXXX).- Show the code. On a phone, the user types it into their desktop Vault. On desktop non-Chromium browsers, call
openVaultDeepLink(userCode)to hand it to the Vault via theflowsta://protocol - and always render the typed-code fallback too, because deep-link success is not detectable from the page. - The user approves in their Vault; polling resolves with the session.
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 Firefox/Safari: 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.
- 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.
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 lives | Durability comes from | You back up | Restore works by |
|---|---|---|---|
| On a shared DHT (many users, one network) | The network - peers keep replicating entries | The canonical payload (feeds the CAL export), plus any app-generated keys in app_keys | Recognition - on sign-in, the fresh agent joins the user's identity link graph and their 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 agent | The Vault backup - the user's own devices are usually the only peers | The canonical payload with human_readable + raw_record per record | Replay - 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 backup | Extension blocks on the same payload | File restore - read the block back, write the file or settings when absent locally |
Real apps mix models. ProofPoll is recognition (shared polls DHT) with its keys escrowed in the backup. 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_readableview plus your extension blocks,app_keysincluded. - 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: nothing to schedule. Data re-syncs from the DHT once the user signs in and their agents link.
- Replay apps: detect "fresh install, non-empty Vault backup" right after sign-in (empty local state +
listVaultBackupsshows records for yourclientId) and run the restore in your startup sequence, visibly. If your app keeps its own data key inapp_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.
An empty backup never overwrites a real one (v2.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. Since v2.6.0, startAutoBackup probes the existing backup and skips exactly that write, delivering EmptyBackupSkippedError (code empty_backup_skipped) to onError - a skip, not a failure; the next non-empty backup writes normally. Opt out with protectNonEmpty: false. If you post with backupToVault directly, run the same check with wouldOverwriteNonEmptyBackup(options, payload).
What you write vs what Flowsta provides
| Component | What it does | Approximate lines |
|---|---|---|
decode_record_for_export Tauri command | One 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 command | One 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 startup | Tells 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).
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:
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.
The one exception is a key the Vault can't supply: cryptographic material your app generates itself, not derived from the user's Flowsta seed - an independent agent key, or a local data-encryption key for encrypted entries. 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.
Recognition first, replay when the backup IS the data
For shared-DHT apps, the recommended recovery is recognition: the user signs in with their Flowsta identity, the Vault recognizes their agent set, and their on-network data re-syncs from the DHT - no key import and 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:
{
"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_readableview 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, andapp_keysescrow.
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
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.
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:
#[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.
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:
// Frontend
startAutoBackup({
clientId,
appName: 'YourApp',
getData: () => invoke('build_canonical_backup'),
intervalMinutes: 60,
});// 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.
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 (v2.6.0+, both signatures): protectNonEmpty (default true) skips 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. A skipped write delivers EmptyBackupSkippedError to onError. 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:
import { backupToVault } from '@flowsta/holochain';
await backupToVault(
{ clientId: 'flowsta_app_abc123', appName: 'ChessChain', label: 'latest' },
canonicalPayload,
);backupToVault writes unconditionally. If you call it outside startAutoBackup, guard overwriting labels yourself (v2.6.0+):
import { wouldOverwriteNonEmptyBackup } from '@flowsta/holochain';
if (!(await wouldOverwriteNonEmptyBackup({ clientId, label: 'latest' }, payload))) {
await backupToVault({ clientId, appName, label: 'latest' }, payload);
}retrieveFromVault
Retrieve a stored backup. Omit label to get the most recent snapshot. Returns null when no backup exists for this clientId/label or the Vault isn't reachable - always handle it:
import { retrieveFromVault } from '@flowsta/holochain';
const backup = await retrieveFromVault({
clientId: 'flowsta_app_abc123',
label: 'latest',
});
if (!backup) {
// No backup stored, or Vault not running - nothing to import.
return;
}
await importData(backup.data);
// backup.data is whatever was stored; for canonical-shape backups
// it follows the canonical v1 payload format.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.
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}`);
}If no backup exists (or the Vault is unreachable), the result is { totalRecords: 0, succeeded: 0, failed: [] }. 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:
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[]:
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:
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 app | Encrypt with | Recovery story |
|---|---|---|
| Single-device, and losing the device may lose the data | The agent's lair-managed keys via crypto_box (the pattern below) | The key lives only in that device's lair keystore: back the keystore up, or the data dies with the device. This model does not extend to multi-device - every device runs a different agent key, and a lair keystore must never be copied between machines (two conductors on one key silently break gossip). |
| 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 all | An app-generated random symmetric key, stored locally | Ship 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
- 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.) - Commit the ciphertext as a public entry with a generic
"private"hint (the entry body carries no content-type metadata) - Peers replicate the opaque bytes via gossip - they can see the entry exists but cannot read it
- 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 (back up the lair keystore, 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_apidirectly in Rust (see ProofPoll'scrypto.rs) - Electron - Use
lair_keystore_apivia a native Node.js addon, or call lair through its Unix socket - Any backend - Connect to lair's socket and use the
CryptoBoxXSalsaBySignPubKeyrequest
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.
| Error | Description |
|---|---|
FlowstaHolochainError | Base class. Carries a stable machine-readable code (and sometimes a description) |
VaultNotFoundError | Vault not running or not installed |
VaultLockedError | Vault has never been unlocked this session (backups and retrieval work while locked after first unlock) |
UserDeniedError | User rejected the approval dialog |
InvalidClientIdError | Client ID not registered |
MissingClientIdError | No client_id provided |
ApiUnreachableError | Cannot reach Flowsta API |
SigningDnaNotInstalledError | Vault'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-backup limit |
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 (v2.6.0) | Auto-backup skipped a write: the payload had zero user records but the Vault backup has some (typical right after a reinstall, before recovery). Not a failure - finish recovery and the next non-empty backup writes normally |
Function Reference
| Function | Description |
|---|---|
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 |
retrieveFromVault(options) | Retrieve stored backup - returns null when none exists or Vault is unreachable |
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 } |
Next Steps
- Building Holochain Apps - Step-by-step integration guide
- Agent Linking - How attestations work
- IPC Endpoints - Raw IPC API reference