@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
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_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):
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 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.
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
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<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
| 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; 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:
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.
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:
#[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:
// 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 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:
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:
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.
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:
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:
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
- 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 (no metadata about content type) - 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" (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_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
| Error | 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 |
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
| 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) |
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
- Building Holochain Apps - Step-by-step integration guide
- Agent Linking - How attestations work
- IPC Endpoints - Raw IPC API reference