Skip to content

Build a Holochain app ​

Let users prove their Flowsta identity on your Holochain app's DHT.

When users link their Flowsta identity to your app, a cryptographic attestation (IsSamePersonEntry) is committed to your DHT. Anyone on your network can verify the link using Ed25519 cryptography - no shared DNA or API dependency required.

Required and optional ​

Required for every Holochain app that links a Flowsta identity:

  • The two agent-linking zomes in your DNA (Step 1) and @flowsta/holochain in your front end (Step 2).
  • A registered app at dev.flowsta.com - the Vault refuses a link request without a client_id.
  • The link ceremony (Steps 4-5), and the rule that comes with it: one app agent links to one Flowsta identity. A Vault that holds a different identity than the one your app linked with is a stop, never a prompt to link again - see Identity switching.

Optional, each a few lines once the link exists: the user's profile fields (Step 3 scopes), encrypted backups and reinstall recovery, encrypted private data, document signing through Sign It, and per-identity profiles so the app works for several identities on one computer (Model 2).

Prerequisites ​

  1. A Holochain application with its own DNA
  2. A registered app at dev.flowsta.com (for client_id)
  3. Users have Flowsta Vault installed on their desktop

Works with any framework

This guide applies to Tauri, Electron, and any other desktop framework. The @flowsta/holochain SDK communicates with Flowsta Vault via its localhost IPC server and works in any JavaScript environment - no framework-specific adapter needed.

Step 1: Add Agent-Linking Zomes ​

Add the flowsta-agent-linking crates to your DNA, pinned to a release tag:

toml
# integrity/Cargo.toml
[dependencies]
flowsta-agent-linking-integrity = { git = "https://github.com/WeAreFlowsta/flowsta-agent-linking", tag = "v0.3.0" }
toml
# coordinator/Cargo.toml
[dependencies]
flowsta-agent-linking-coordinator = { git = "https://github.com/WeAreFlowsta/flowsta-agent-linking", tag = "v0.3.0" }

Tag v0.3.0 (integrity 0.2.0, coordinator 0.3.0) targets Holochain 0.6. The crates pin exact HDK versions, so your DNA's hdi and hdk must be on the same line - hdi = "=0.7.0" and hdk = "=0.6.0" for this tag - or the build fails with trait-resolution errors. Build the zomes with RUSTFLAGS='--cfg getrandom_backend="custom"' cargo build --release --target wasm32-unknown-unknown.

Reference them in your dna.yaml:

yaml
integrity:
  zomes:
    - name: agent_linking_integrity
      bundled: ../../target/wasm32-unknown-unknown/release/flowsta_agent_linking_integrity.wasm
coordinator:
  zomes:
    - name: agent_linking
      bundled: ../../target/wasm32-unknown-unknown/release/flowsta_agent_linking_coordinator.wasm
      dependencies:
        - name: agent_linking_integrity

Keep the zome names agent_linking_integrity and agent_linking: the SDK's getFlowstaIdentity calls the coordinator zome agent_linking by default.

Step 2: Install the SDK ​

bash
npm install @flowsta/holochain

Step 3: Configure Scopes ​

In your app's settings at dev.flowsta.com, select the scopes your app needs. Scopes control which Flowsta profile fields the Vault's local IPC server (GET /status) returns to your app once the user has linked it. The user sees the scope list in the Vault approval dialog before they approve.

ScopeWhat you receiveNotes
openidBasic identity (auto-included)Always present - not shown to the user
didThe user's Permanent ID (did:flowsta:…)Always returned by /status while unlocked; the scope is shown in the dialog for transparency
public_keyThe Vault's Holochain agent public keyAlways returned by /status while unlocked; the scope is shown in the dialog for transparency
holochain"Holochain identity" line in the dialogDefault scope for Holochain-type apps. No runtime effect in the Vault; at the Flowsta API it adds agent_pub_key to /oauth/userinfo
display_nameUser's display nameProfile UI
usernameUser's @usernameProfile UI
profile_pictureAvatar (data URI or URL)Profile UI

Profile fields for scopes you haven't selected are returned as null from /status, even if the user has that data in their Vault, and all profile fields are null until the user has linked your app (the Vault recognizes your app by the origin it linked from). Note that the username scope yields the web_username field in the /status response (webUsername in the SDK). The Vault stores the granted scopes at link time: a scope change at dev.flowsta.com takes effect the next time the user links your app - no rebuild needed, but no effect on an already-linked install until it re-links.

Step 4: Request Identity Linking ​

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

// Request link from Vault
const result = await linkFlowstaIdentity({
  appName: 'ChessChain',
  clientId: 'flowsta_app_abc123', // from dev.flowsta.com
  localAgentPubKey: myAgentKey,   // uhCAk... format
});

// result.payload contains:
// - vaultAgentPubKey: the Vault's agent key
// - vaultSignature: Ed25519 signature of the 78-byte linking payload (base64)

Linking also binds your app to that Vault identity (SDK v3): later write-shaped calls - backups, document signing, Vault sign-in - refuse with IdentityMismatchError when the Vault holds a different identity. See Identity binding.

One app agent, one identity

Linking attests on your public DHT that this app agent and this Flowsta identity are the same person. Never link the same app agent to a second identity: a Vault that holds another identity is a stop, not a prompt to call linkFlowstaIdentity again. Since Vault 1.5.0 one Vault can hold several identities on one computer, so your app will meet this case. The Vault refuses an agent that another identity in the same Vault already links (409 agent_linked_elsewhere), and the zome refuses a second live link to a different identity. Read Identity switching before you ship.

Step 5: Commit to Your DHT ​

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

// The Vault's signature arrives base64-encoded; the zome wants the raw 64 bytes.
const signatureBytes = (b64: string) =>
  Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));

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: signatureBytes(result.payload.vaultSignature),
  },
});

The create_external_link function:

  1. Refuses when your agent already holds a live link to a different external agent (linking the same one again is allowed - reinstall, restore)
  2. Verifies the Vault's Ed25519 signature
  3. Creates a local signature from your app's agent key
  4. Commits an IsSamePersonEntry with both signatures
  5. Creates lookup links for querying

Step 6: Query Linked Agents ​

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

const linkedAgents = await getFlowstaIdentity({
  appWebsocket,
  roleName: 'my-role',
  agentPubKey: someAgentKey,
});

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

Or call the zome directly:

typescript
const linkedAgents = await appWebsocket.callZome({
  role_name: 'my-role',
  zome_name: 'agent_linking',
  fn_name: 'get_linked_agents',
  payload: myAgentKey,
});

Error Handling ​

Handle Vault availability gracefully:

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

// Check if Vault is available first
const status = await getVaultStatus();

if (status.blocked) {
  showMessage('Your browser blocked access to Flowsta Vault - allow local network access for this site');
  return;
}

if (!status.running) {
  showMessage('Please install and start Flowsta Vault');
  return;
}

if (!status.unlocked) {
  showMessage('Please unlock your Flowsta Vault');
  return;
}

try {
  const result = await linkFlowstaIdentity({ /* ... */ });
} catch (error: any) {
  if (error.name === 'UserDeniedError') {
    showMessage('Identity linking was canceled');
  } else if (error.name === 'InvalidClientIdError') {
    showMessage('App registration error');
  } else if (error.code === 'agent_linked_elsewhere') {
    // Another identity in this Vault already links this app agent.
    showMessage(error.description);
  }
}

status.blocked (SDK 3.1.0) means the browser refused the loopback request - Chrome 142+ asks for a Local Network Access permission - so the Vault may well be running; write-shaped calls throw VaultBlockedError in that case. See the full error reference in Agent Linking.

When the user switches identity ​

From Vault 1.5.0 one Vault holds several identities on one computer, one unlocked at a time. Your app is linked and bound to one of them. When the Vault holds another, every write-shaped SDK call refuses with IdentityMismatchError and the Vault answers 409 identity_mismatch to any request that names the expected identity. Show the state and offer to switch back, open the app as the identity the Vault holds now, or disconnect - never link the same agent again. The full contract, and the two ways to build for it, are on Identity switching.

User Data Backups & Reinstall Recovery ​

Integrate the canonical-shape backup pipeline so your users never lose data on reinstall, device wipe, or move to a new machine. Start by choosing your recovery model: recognition + seed adoption (shared DHT - the network is the durability; the backup escrows the app's agent seed, so a new machine adopts the seed and continues as the same author while data re-syncs, nothing replayed), replay (per-user DHT or single-author data - the Vault backup is the durability; walk it and re-commit records), or extension blocks for data that isn't on Holochain at all (settings, local files, images). All three ride the same payload, and most real apps mix them.

For replay apps, you write two small Tauri commands - decode_record_for_export (decode an entry to plain JSON for the user's data export) and restore_record (re-create an entry by calling the matching zome function). When you add a new entry type, you add one match arm in each. The SDK handles the rest: it captures the user's source chain, runs your decoder per record, posts the canonical-shape payload to Vault. On reinstall, the SDK's restoreFromVault walks the backup and calls your restore_record once per entry. For shared-DHT apps, you instead generate your agent seed app-side, escrow it in the payload's app_keys block, and reuse the adoption flow on restore. Either way, Vault provides the encryption, storage, the Your Data UI, and the CAL §4.2.1-compliant data export with the user's cryptographic keys included.

From one payload, users get the full story on Vault's Your Data page: your app listed with per-entry-type counts, a per-app Export of just your app's data, the all-app Export Data section (one Export button for everything), and delete. Backups are encrypted at rest with the user's Vault key and stored per identity.

Auto-backups run on every write (debounced 30s) plus a 30-minute heartbeat retry. They work even when the Vault is locked, as long as it has been unlocked at least once in the current session. Three write guards protect the reinstall window, where the first auto-backup fires before recovery: backupToVault refuses an empty payload over a non-empty backup (on by default; since SDK v3 the guard covers every write path, not just auto-backup), refuses to write while the Vault holds a different identity than the one your app linked and bound to (IdentityMismatchError, v3), and apps whose payload carries app_keys must also refuse to overwrite a backup escrowing a different key than the local one - see the guard contract. Each backup object is capped at 50 MB (BackupTooLargeError); the SDK does not split payloads, so split larger data into named parts yourself.

If your app stores encrypted entries on the public DHT, decrypt them when you build the human_readable view so the user's export remains readable - the Vault encrypts the backup at rest, so no double-encryption is needed.

Encrypted Private Data ​

Your app can store private data on the public DHT using client-side encryption: peers replicate the ciphertext for resilience, but only the key-holder can decrypt. Pick the key model before the cipher - it decides whether the data is readable on a second device and whether it survives losing the first one:

  • Agent-keyed data - encrypt with the agent's lair-managed keys (xsalsa20poly1305 crypto_box, 256-bit). Generate the agent seed app-side and escrow it in app_keys so the key survives device loss - after seed adoption the restored device decrypts everything the lost one authored. ProofPoll is the working reference.
  • Multi-device / recoverable data - derive a symmetric key from a user-level secret (recovery phrase or password) with HMAC and a domain-separation constant. Every device derives the same key, and recovery needs only the secret. Flowsta Vault itself uses this model in production for its own private data.
  • Standalone-first apps (fully usable before the user links a Flowsta identity) - generate a random symmetric key locally, give the user a key-export UX, and escrow the key in their Vault backup via the app_keys block once they link Flowsta - it then rides both their single-app export and their full data export.

Whatever the model: use a generic "private" hint on encrypted entries and keep metadata (entry type, relationships) inside the ciphertext - don't leak what kind of private data is stored. If your app keeps data for more than one Flowsta identity on the same computer, name that storage by partitionKeyFor(agentPubKey) (SDK 3.3.0) so one identity's data never mixes with another's.

See Encrypted Entries on Public DHT for the full pattern and key-model guidance.

Next Steps ​

Documentation licensed under CC BY-SA 4.0.