Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Holochain Agent Skills

Validate Ask DeepWiki

Agent skills for Holochain hApp development, built to the Agent Skills Open Standard so they work in Claude Code, opencode, GitHub Copilot, Cursor, Gemini CLI and every other tool that adopted the format. Pinned to Holochain 0.7, verified against a reference hApp that actually compiles.

Install

mkdir -p .claude/skills && curl -fsSL \
  https://github.com/Soushi888/holochain-agent-skills/releases/download/v1.0.0-rc.1/holochain-agent-skills.tar.gz \
  | tar -xz -C .claude/skills

Run it from the root of the project that should get the skill, and replace .claude/skills with your harness’s path if it differs (the table below lists them all). The archive’s root is one directory per skill, so the extraction lands skills/holochain/SKILL.md with no rename step and no --strip-components. Every release also publishes SHA256SUMS, so you can verify what you downloaded.

Restart your agent afterwards. Most harnesses read skills once, at startup. Then ask it anything about Holochain, or invoke it by name (/holochain in Claude Code).

Other ways to install

Nix. Either take the source tree and point at the subdirectory:

inputs.holochain-agent-skills = {
  url = "github:Soushi888/holochain-agent-skills/v1.0.0-rc.1";
  flake = false;
};
# consume: "${inputs.holochain-agent-skills}/skills/holochain"

or use the flake, which also hands you the devShell glue:

inputs.holochain-agent-skills.url = "github:Soushi888/holochain-agent-skills/v1.0.0-rc.1";

# in your devShell:
shellHook = ''
  ${inputs.holochain-agent-skills.lib.mkSkillsHook {
    inherit pkgs;
    skills = [{
      src  = inputs.holochain-agent-skills.packages.${system}.holochain;
      name = "holochain";
    }];
  }}
'';

packages.<system>.holochain is rooted at the skill itself, so ${it}/SKILL.md exists. packages.<system>.default is the bundle, one directory per skill, matching the release archive. mkSkillsHook writes into .claude/skills, .cursor/skills and .agents/skills by default; pass targets to change that. It exists because every consumer writes the same rsync glue and hits the same wall: Nix store paths are read-only and rsync -a preserves that mode, so the second nix develop fails with a permission error. The hook passes --chmod=u+w.

Clone it, and use the installer. The repository ships a harness-detecting installer that finds every agent harness a project uses and installs into all of them at once:

git clone https://github.com/Soushi888/holochain-agent-skills ~/holochain-agent-skills
cd ~/holochain-agent-skills && bun run build

cd your-project
node ~/holochain-agent-skills/bin/install.mjs install --yes

Useful flags: --global installs into the home-directory scope instead of the project, --target claude,opencode picks specific harnesses, --dry-run prints what would happen, and --link symlinks rather than copies so a git pull updates every installed copy at once. install.mjs list prints every skill and every harness path it knows.

Pin a version. The archive URL above names its tag, so pinning is just choosing the tag. Release candidates are marked as prereleases, which means releases/latest/download skips them: it keeps pointing at the last stable release until 1.0.0 ships.

If you pinned this repository before 1.0, the path changed. The repository root used to be the skill, so glue that copied the root worked. It no longer does, and it fails silently: the root now holds no SKILL.md at all, so a harness pointed at a copy of it finds no skill and loads nothing, with no error to read. Append skills/holochain to whatever you pin:

# before 1.0
rsync -a --delete "${SRC}/" .claude/skills/holochain/
# 1.0 and later
rsync -a --delete "${SRC}/skills/holochain/" .claude/skills/holochain/

or drop the glue and use lib.mkSkillsHook above. The full breaking list is in CHANGELOG.md.

Where it installs

Extract the archive into whichever path your harness reads. If you use the bundled installer instead, detection looks for each harness’s marker directory in the project root, or in $HOME with --global: exactly one found means that one is used; several found means all of them, or an interactive choice if you are at a terminal; none found falls back to .claude/skills and .agents/skills.

HarnessProject pathGlobal path
Claude Code.claude/skills~/.claude/skills
Agent Skills (tool-agnostic).agents/skills~/.agents/skills
opencode.opencode/skills~/.config/opencode/skills
GitHub Copilot.github/skills~/.copilot/skills
Gemini CLI.gemini/skills~/.gemini/skills
Cursor.cursor/skills~/.cursor/skills

The bundled installer never prompts without an interactive terminal, so an agent or a CI job can run it unattended.

What it covers

DomainDescription
ArchitectureCoordinator and integrity zome split, DNA structure, Cargo workspace, Nix dev environment, progenitor pattern, multi-DNA, private entries
DesignDHT data modeling, entry and link type design, discovery strategy, validation rules
ScaffoldHolonix setup, Nix flake, hc CLI, hc scaffold, new project and new domain workflows
ImplementEntry types, link types, CRUD, cross-zome calls, signals, validation, HDK 0.7 API
TestSweettest two-agent scenarios, await_consistency, update and delete patterns, inline zomes
DeployKangaroo-Electron packaging, .webhapp bundling, CI/CD, versioning, auto-update

Version pins: hdk = "=0.7.0" | hdi = "=0.8.0" | holonix ref=main-0.7

This release targets Holochain 0.7 only, and carries no 0.6 API content by design. On a 0.6 project it will actively mislead you. Use the v0.2.0 tag for 0.6, or the skill’s UpgradeHolochain07 workflow to port.

Quick start

# Design a new data model
/holochain design data model for a marketplace listing with status transitions

# Scaffold a new hApp from scratch
/holochain scaffold new happ called my-network

# Implement a full CRUD zome
/holochain implement zome for Profile entry type

# Debug a flaky test
/holochain my Sweettest passes alone but fails when Bob reads Alice's entry

# Package for distribution
/holochain deploy package my happ for desktop distribution

Workflow triggers

Say…Triggers
“design data model”, “model entries”, “what entries”DesignDataModel
“scaffold”, “new happ”, “new project”, “setup environment”Scaffold
“implement zome”, “create zome”, “write zome”ImplementZome
“design access control”, “cap grant”, “who can call”DesignAccessControl
“upgrade to 0.7”, “port from 0.6”, “migrate hApp”UpgradeHolochain07
“deploy”, “package”, “webhapp”, “kangaroo”PackageAndDeploy

Ecosystem roadmap

v1 (current): the full development cycle. Architecture, design, scaffold, implement, test, deploy.

v2 (planned): ecosystem expansion, as further skills in this repository.

  • hREA and ValueFlows
  • holochain-open-dev patterns
  • ADAM (coasys) integration
  • unyt integration

v3 (vision): GUI and visual tooling. A visual DHT data model explorer, architecture diagram generation, progressive disclosure from junior to senior.

Repository layout

skills/ is the shipped payload. Everything else exists to build, validate, document and release it, and never reaches an installed copy.

skills/holochain/              THE SKILL. Nothing outside this directory ships.
  SKILL.md                       Entry point: routing table, context index, quick reference,
                                 toolchain currency and companion-library tables
  references/                    Reference material, loaded on demand
    architecture.md                Coordinator/integrity split, DNA structure, workspace, Nix
    progenitor.md                  Progenitor pattern, DNA properties, bootstrap founder
    patterns.md                    Entry types, links, CRUD, validation, signals, HDK 0.7 API
    scaffolding.md                 Holonix, Nix flake, hc CLI, hc scaffold
    access-control.md              Capability grants, cap claims, remote signals
    membranes.md                   genesis_self_check, membrane proofs, gating who may join
    cryptography.md                App-level signing and encryption
    scheduling.md                  Scheduled functions, persisted vs ephemeral
    countersigning.md              Atomic multi-agent commits
    cell-cloning.md                Partitioned data via clone cells
    error-handling.md              thiserror and WasmError patterns
    source-chain.md                query(), introspection, host functions, validation receipts
    networking.md                  Kitsune2 and iroh, NetworkConfig, bootstrap and relay servers
    testing.md                     Sweettest patterns, two-agent scenarios, E2E
    wind-tunnel.md                 Performance and load testing
    client.md                      @holochain/client, auth tokens, admin API, signals
    deployment.md                  Kangaroo-Electron packaging and distribution
    migration.md                   DNA migration and init properties
    troubleshooting.md             Literal error strings mapped to causes
    debugging.md                   Logs, hc sandbox, hc-client, inspecting a live conductor
    frameworks/                    Svelte and Effect-TS integration
    workflows/                     Step-by-step guided sequences, routed from SKILL.md
    example-happ/                  A real, compiling 0.7 hApp: ground truth for every example
  assets/templates/              Template files (flake.nix, manifests, zome sources, harness)

scripts/                       validate-skill.sh, bump-versions.sh, check-versions.sh,
                               build-release-assets.sh, install.ts, eval/
nix/                           skill.nix, mk-skills-hook.nix
docs/                          Requirements, roadmap, testing matrix. Not part of the skill
AGENTS.md                      Install instructions addressed to an agent

Contributing

Contributions welcome. Read CONTRIBUTING.md first; the short version is that no API shape may be written from recall, and every Rust example must match something that compiles in skills/holochain/references/example-happ/.

Before opening a pull request:

sh scripts/validate-skill.sh      # structure, routing, links, pins, removed APIs
sh scripts/eval/run-eval.sh       # routing regression floor
sh scripts/check-versions.sh      # the four version declarations agree

When updating for a new Holochain release, run scripts/bump-versions.sh rather than hand-editing pins, then run the validator to confirm nothing was missed. CI runs all three gates plus a Nix build and an mdBook build on every push and pull request.

Documentation site

https://soushi888.github.io/holochain-agent-skills/

License

Apache-2.0

Holochain Development Skill

Expert assistant for Holochain hApp development. Covers the full development spiral: architecture, design, scaffolding, implementation, testing, and deployment.

Proactive Invocation Rule

Always invoke this skill in the PLAN phase when the task touches a Holochain project. Do not wait to be asked explicitly.

Trigger conditions — any of these means the skill should be loaded before coding begins:

  • Working directory is a Holochain project (contains workdir/*.happ or dnas/*/zomes/)
  • Task involves .rs files inside zomes/coordinator/ or zomes/integrity/
  • Task involves entry types, link types, cross-DNA calls, or zome functions
  • Task involves a PR on a Holochain project

When proactively invoked: load references/architecture.md + references/patterns.md, run the ReviewZome checklist against any files being modified, surface issues before implementation begins.


Workflow Routing

WorkflowTriggerFile
ReviewZomereview zome, audit zome, check implementation, validate patterns, before implementing, PR review, pull request, sanity check, double-check, code review on zomereferences/workflows/review-zome.md
DesignDataModeldesign data model, model entries, what entries, what links, entry vs link, DHT schema, DHT shapereferences/workflows/design-data-model.md
Scaffoldscaffold, new happ, new project, setup environment, init project, Holonix, nix develop, hc scaffoldreferences/workflows/scaffold.md
ManualScaffoldproject files, scaffold without CLI, manual scaffold, by hand, AI creates files, no hc scaffold, scaffold in sessionreferences/workflows/manual-scaffold.md
ImplementZomeimplement zome, create zome, scaffold zome, write zome, full zome, CRUD zome, coordinator and integrity cratesreferences/workflows/implement-zome.md
DesignAccessControldesign access control, who can call, allowed to call, cap grant design, capability grantsreferences/workflows/design-access-control.md
UpgradeHolochain07upgrade to 0.7, port from 0.6, migrate hApp, holochain 0.7 upgrade, upgrade holochain version, bring up to date, move to the latest holochain, hdi upgradereferences/workflows/upgrade-holochain-0.7.md
PackageAndDeploydeploy, package, distribute, distribution, kangaroo, installer, desktop app, webhappreferences/workflows/package-and-deploy.md

Context Files

Load on demand based on task:

FileLoad When
references/architecture.mdCoordinator/integrity split, coordinator and integrity, DNA structure, Cargo workspace, Nix, dna_info, network_seed, private entries, multi-DNA (multiple roles, bridge call, OtherRole, cross-DNA call)
references/progenitor.mdProgenitor pattern, founder of the network, DnaProperties struct, check_if_progenitor, bootstrap mode, coordinator guard, integrity enforcement (Moss pattern), auto-registration in create_user, deploy-time injection (dna.yaml / Sweettest / Kangaroo / Moss)
references/scaffolding.mdNew project setup, Holonix installation, Nix flake, hc CLI, hc scaffold commands, new domain, adding a new domain to existing project
references/patterns.mdEntry types, link types, CRUD, cross-zome calls, validation (FlatOp, TypedAction<D>), HDK 0.7 get/link API (GetStrategy, LinkQuery, GetOptions, get_links), update chain, ZomeCallResponse variants (NetworkError, Unauthorized), warrants and chain forks, must_get, signals (remote signal, init cap grant)
references/access-control.mdCap grants, capability grants, capability system, cap claim, remote signal, recv_remote_signal setup, admin-only access
references/cryptography.mdApp-level signing and encryption: sign, sign_ephemeral, verify_signature in validation, secretbox vs box, create_x25519_keypair, encrypting to an AgentPubKey, and what encryption does not buy you
references/scheduling.mdScheduled functions, schedule(), Schedule::Persisted crontab vs Schedule::Ephemeral, #[hdk_extern(infallible)], scheduler loop timing, why scheduled fns run as the chain author
references/countersigning.mdCountersigning, atomic multi-agent commits, PreflightRequest, accept_countersigning_preflight_request, session times, enzymatic sessions, M of N optional signers, unstable-countersigning feature gate
references/cell-cloning.mdCell cloning, partitioned data, own copy of the DNA, clone roles, createCloneCell, clone_limit
references/error-handling.mdError types, WasmError, ExternResult, extern result patterns, thiserror
references/testing.mdFour-layer strategy, Sweettest (Rust-native), two agents, await_consistency, E2E Playwright + AdminWebsocket, Wind-Tunnel performance
references/wind-tunnel.mdPerformance/load testing with wind-tunnel: ScenarioDefinitionBuilder, call_zome, ReportMetric, multi-agent roles, sync lag, DHT sync lag measurement, load testing, InfluxDB metrics pipeline
references/client.mdholochain-client setup, callZome, signals, SvelteKit integration
references/troubleshooting.mdAny literal error string from the compiler, conductor, hc CLI or a test, keyed on the text you actually saw. Check here first when something fails. Build and compile errors, cannot find type, no variant named, unresolved import, feature does not exist, hc scaffold errors, integrity zome, scaffolder rc pins, conductor startup, peers never connect, validation passes locally but fails for other agents, test failures, deployment and data reset symptoms
references/networking.mdKitsune2 and iroh transport, conductor NetworkConfig, bootstrap_url, relay_url, running your own bootstrap server, arc factor and leecher nodes, request timeouts, gossip reporting
references/debugging.mdNothing threw but something is wrong: RUST_LOG and WASM_LOG, hc sandbox subcommands, hc-client call admin requests, dump-state, dump-network-stats, dump-network-metrics, calling a zome function by hand
references/membranes.mdMembrane proof, genesis_self_check, gating who may join, AgentValidationPkg validation, provideMemproofs, awaiting_memproofs, invite codes
references/source-chain.mdquery() and ChainQueryFilter over your own chain, agent_info / zome_info / call_info / dna_info, scratch-space chain head, sys_time, random_bytes, tracing from wasm, validation receipts
references/migration.mdDNA migration, init_properties, get_init_properties(), carry over, chain history, carrying data across DNA versions, why 0.7 is a new network
references/deployment.mdPackaging, distributing, Kangaroo-Electron, installers, desktop app, versioning, version bump, data resets after update

Quick Reference

Versions (current stable):  hdk = "=0.7.0"   hdi = "=0.8.0"   holonix ref=main-0.7
Client / tooling:           @holochain/client 0.21.0   hc-spin 0.700.0   nodejs_24
Dev commands:  nix develop  |  hc sandbox clean  |  cargo test
Build zomes:   RUSTFLAGS='--cfg getrandom_backend="custom"' cargo build --release --target wasm32-unknown-unknown
Scaffold:      hc scaffold entry-type MyEntry  |  hc scaffold link-type AgentToMyEntry

Toolchain currency

Verified against live registries and git refs on 2026-08-20. Re-verify before trusting these past a Holochain minor release.

ComponentCurrentSource
hdk / hdi / holochain0.7.0 / 0.8.0 / 0.7.0crates.io
@holochain/client0.21.0npm
@holochain/hc-spin0.700.0npm
holochain_scaffolding_cli0.700.0 (stable, 2026-07-31)crates.io / scaffolding v0.700.0
holonixbranch main-0.7github.com/holochain/holonix
kangaroo-electronbranch main-0.7github.com/holochain/kangaroo-electron
@holochain/tryorama0.19.2, last published 2026-05-15, no 0.7 releasenpm

Scaffolder version trap. Holonix main-0.7 ships hc-scaffold 0.700.0-rc.0, not the stable v0.700.0. The rc emits holonix?ref=main and -rc crate pins, and its generated validate() does not compile against the stable hdi 0.8.0 it also pins. Install the stable scaffolder alongside holonix rather than using the bundled one:

nix run github:holochain/scaffolding/v0.700.0 -- web-app my-app
# or: cargo install holochain_scaffolding_cli --version 0.700.0 --locked

Always pass --version. cargo install holochain_scaffolding_cli with no version installs 0.4000.4, a Holochain 0.4-era scaffolder. This crate’s version history mixes numbering schemes, and semver orders 0.4000.4 above 0.700.0, so crates.io reports the old release as the latest stable one. Verified 2026-08-17: the crates.io API returns "max_stable_version":"0.4000.4" while 0.700.0 sits further down the version list.

references/troubleshooting.md lists each rc symptom and its fix if you are stuck with the bundled binary.

Companion libraries: what is actually on 0.7

Verified against npm and each repo’s Cargo.toml on 2026-08-17. Version numbers in this ecosystem are not reliable signals, so check the pins rather than the tag.

ProjectState on 0.7What to do
@holochain-open-dev/elements, /utils, /file-storage0.700.0, published 2026-07-31 to 08-13Safe to use
@holochain-open-dev/profiles0.701.0, published 2026-08-11Safe to use
@holochain-open-dev/signalsstable is 0.601.0 (2026-01-26); only a dev tag at 0.700.0-dev.0 (2026-04-27)Pre-release only, do not treat as settled
Wind Tunnellatest tag v0.7.1 pins hdk 0.6.3; branch main pins hdk 0.7.0Track main. See references/wind-tunnel.md
hREAhapp-0.4.0-beta pins hdk 0.6.1Still 0.6 generation. Do not assume 0.7 compatibility
Moss / Weave (@theweave/api)0.7.0-dev.2, dev channel onlyPre-release
Tauri Holochain pluginstauri-plugin-holochain-service 0.2.3, last published 2025-11-10; tauri-plugin-holochain is a 0.0.0 placeholderUnmaintained against 0.7. Use Kangaroo
Official editor extension, non-JS/Rust bindingsnone foundThe supported clients are @holochain/client and the holochain_client crate

Unstable feature gates

Holochain 0.7 keeps several capabilities behind Cargo features that are off by default. Default features on holochain 0.7.0 are encryption, schema, wasmer-sys-cranelift only. Turning any of these on means building and shipping your own conductor, which stock holonix and Kangaroo binaries will not have.

FeatureGatesNotes
unstable-countersigningAtomic multi-agent commitsSee references/countersigning.md
unstable-migrationThe DNA manifest lineage field and UseExisting dependency matchingA declared ancestor chain. Holochain does not verify the lineage is truthful
unstable-functionsA small set of host functions including the deprecated sleep()Low value for most hApps
unstable-shardingDeclared in holochain 0.7.0’s feature list. Nothing in the vendored crate sources references it, so its current scope is unverifiedDo not design around it

Agent key management (DeepKey / DPKI) is not in the SDK. There is no dpki or deepkey surface anywhere in hdk 0.7.0 or hdi 0.8.0. It exists as a separate, still-unstable conductor service. Do not expect key rotation or key-to-person binding APIs from a zome.

Common Pitfalls Checklist

Run this against any zome code being written or reviewed. Each item is a class of bug that has burned projects before.

Entry Schema Evolution

  • #[serde(default)] on new optional fields — Any field added to an existing entry struct after initial deployment MUST have #[serde(default)]. Without it, existing entries serialized before the field existed will fail to deserialize. Option<T> alone is NOT sufficient.
    #![allow(unused)]
    fn main() {
    #[serde(default)]          // ← REQUIRED for fields added post-deployment
    pub new_field: Option<ActionHash>,
    }

Cross-DNA Calls

  • ZomeCallResponse is exhaustive — 0.7 has 5 variants: Ok(ExternIO), AuthenticationFailed(Signature, AgentPubKey), Unauthorized(ZomeCallAuthorization, Option<CapSecret>, ZomeName, FunctionName), NetworkError(String), CountersigningSession(String). Note Unauthorized carries no AgentPubKey in 0.7. Wildcard _ is safe but hides new variants. Exhaustive match is preferred.
  • Role name matches happ.yamlCallTargetCell::OtherRole("role_name") must exactly match the role name in workdir/happ.yaml. Typos fail silently at runtime.
  • Zome name matches coordinator crate nameZomeName("zome_name") must match the coordinator’s name in Cargo.toml. Check both.
  • Local mirror structs for cross-DNA types — Avoid importing the remote DNA’s Cargo crate. Define a local serialization mirror struct instead.

Validation Rules

  • No non-deterministic reads in validate() — no get(), get_links(), agent_info(), sys_time(). DHT reads ARE allowed through must_get_*, which defers on an unresolved dependency instead of failing. Everything else comes from the op itself.
  • Use op.flattened::<EntryTypes, LinkTypes>() — Not the old op.to_type(). references/patterns.md has the correct pattern.
  • Narrow actions with TypedAction::<D>::try_from_action(...)? — Not let r: Result<_, WrongActionError> = action.try_into(); followed by map_err(|e| wasm_error!(...)). try_from_action returns ExternResult and drops into a ?-chain directly. TypedAction<CreateData> and TypedAction<UpdateData> widen into TypedAction<EntryCreationData> infallibly with .into().
  • A shape sys validation already guarantees is an error, not Invalid — if a DeleteLink’s target is not a CreateLink, propagate with ?. Returning ValidateCallbackResult::Invalid blames the author for a fault in how the op reached your code.
  • TypedAction<D> derefs to Daction.data.entry_hash and action.entry_hash both work for reads. Keep action.data.x where you need to move the field out, since Deref only lends.
  • delete_link() requires GetOptionsdelete_link(hash, GetOptions::default()) not delete_link(hash).
  • get_links() takes a LinkQuery plus a GetStrategyget_links(LinkQuery::try_new(base, LinkTypes::X)?, GetStrategy::default()). Not GetLinksInputBuilder for most cases.
  • GetStrategy::Local vs Network — Use Local for own-data queries (fast, no network), Network for DHT queries (cross-agent data).

Shared Utility Patterns (project-specific)

  • agent_pub_key and created_at are NOT entry fields — They live in the action header. Remove them from entry structs.
  • If using a shared utility crate — verify intra-DNA and cross-DNA call helpers are used consistently rather than raw call() inline.

Examples

Example 1: Design a new entry type for a marketplace listing

User: "I need to model a Listing entry with status transitions"
→ Loads references/patterns.md (entry types, status enum, link types)
→ Designs ListingStatus enum (Active/Archived/Deleted)
→ Defines link types (AgentToListing, PathToListing, ListingUpdates)
→ Implements soft-delete via status field update, not entry deletion

Example 2: Debug a cross-agent test that fails intermittently

User: "My Sweettest passes alone but fails when another agent reads the entry"
→ Loads references/testing.md
→ Identifies missing await_consistency call before cross-agent read
→ Adds await_consistency(&cells).await.unwrap() after Alice's create, before Bob's get
→ Test passes reliably

Example 3: Scaffold a new hApp from scratch

User: "Start a new Holochain project for a community coordination app"
→ Loads references/scaffolding.md + references/workflows/scaffold.md
→ If hc scaffold CLI is available: guides nix flake setup → hc scaffold happ → entry types
→ If no CLI (AI coding session): invokes references/workflows/manual-scaffold.md → writes identical structure
→ Both paths produce the same standard hc scaffold architecture
→ Verifies compilation with hc s sandbox generate workdir/

Example 4: Implement CRUD for a new zome

User: "Implement a full resource zome with create, read, update, delete"
→ Loads references/architecture.md + references/patterns.md
→ Invokes references/workflows/implement-zome.md
→ Creates integrity crate (entry struct, link enum, validation)
→ Creates coordinator crate (create/read/update/delete functions)
→ Writes Sweettest tests at foundation + integration layers

Holochain Architecture

Coordinator vs. Integrity Zomes

Every domain in a Holochain hApp is split into two crates:

LayerCrate typeRole
IntegrityhdiDefines entry types, link types, and validation rules. Deterministic; the only reads available are must_get_*.
CoordinatorhdkImplements CRUD functions, calls other zomes, emits signals. Can be updated post-deployment.

Why the split matters:

  • Integrity code is committed to the DNA hash — it cannot change without forking the network
  • Coordinator code can be hot-swapped without breaking agent data
  • Validation runs in integrity: deterministic, and the only reads available are the must_get_* family, which defers on an unresolved dependency rather than failing

What belongs where

Integrity crate only:

  • #[hdk_entry_types] enum
  • #[hdk_link_types] enum
  • validate() callback
  • Entry structs with #[hdk_entry_helper]
  • Status enums (e.g., ListingStatus)

Coordinator crate only:

  • create_*, get_*, update_*, delete_* pub functions
  • recv_remote_signal handler
  • post_commit hook (signals)
  • Cross-zome calls

DNA Structure

Each domain = one pair: {domain}_integrity + {domain} (coordinator).

dnas/
└── my_dna/
    ├── dna.yaml
    └── zomes/
        ├── integrity/
        │   ├── my_domain_integrity/
        │   │   ├── Cargo.toml
        │   │   └── src/
        │   │       ├── lib.rs         # Entry types, link types, validate()
        │   │       └── types.rs       # Entry structs
        ├── coordinator/
        │   └── my_domain/
        │       ├── Cargo.toml
        │       └── src/
        │           ├── lib.rs         # pub extern "C" fn declarations
        │           └── my_entry.rs    # CRUD implementation
        └── utils/                     # Shared crate (optional)
            ├── Cargo.toml
            └── src/
                ├── lib.rs
                ├── errors.rs          # thiserror enums
                └── cross_zome.rs      # external_local_call helpers

Cargo Workspace

Root Cargo.toml — always pin HDK/HDI with exact versions (=):

[workspace]
resolver = "2"
members = [
    "dnas/my_dna/zomes/integrity/my_domain_integrity",
    "dnas/my_dna/zomes/coordinator/my_domain",
    "dnas/my_dna/zomes/coordinator/utils",
]

[workspace.dependencies]
hdi = "=0.8.0"
hdk = "=0.7.0"
serde = { version = "1", features = ["derive"] }
thiserror = "1"

Why exact pins? Holochain zome compilation is extremely sensitive to minor version differences. Range deps (^) cause breakage when new patch releases change internal APIs.

Individual crate Cargo.toml:

[package]
name = "my_domain_integrity"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]
name = "my_domain_integrity"

[dependencies]
hdi = { workspace = true }
serde = { workspace = true }

Nix Dev Environment

Standard flake.nix using holonix (pin to main-0.7 branch for HDK 0.7.x):

{
  inputs = {
    holonix.url = "github:holochain/holonix?ref=main-0.7";
    nixpkgs.follows = "holonix/nixpkgs";
    flake-parts.follows = "holonix/flake-parts";
  };

  outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } {
    systems = builtins.attrNames inputs.holonix.devShells;
    perSystem = { inputs', ... }: {
      devShells.default = inputs'.holonix.devShells.default;
    };
  };
}

Enter dev shell: nix develop


Manifest Files

happ.yaml

manifest_version: "0"
name: my_happ
description: "My hApp"
roles:
  - name: my_dna
    provisioning:
      strategy: create
      deferred: false
    dna:
      bundled: "./my_dna.dna"
      modifiers:
        network_seed: ~
        properties: ~

dna.yaml

manifest_version: "0"
name: my_dna
integrity:
  network_seed: ~
  properties: ~
  origin_time: 1704067200000000
  zomes:
    - name: my_domain_integrity
      bundled: "./zomes/integrity/my_domain_integrity.wasm"
coordinator:
  zomes:
    - name: my_domain
      bundled: "./zomes/coordinator/my_domain.wasm"
      dependencies:
        - name: my_domain_integrity

Scaffolding Commands

# Generate entry type boilerplate (integrity + coordinator stubs)
hc scaffold entry-type MyEntry

# Generate link type
hc scaffold link-type AgentToMyEntry

# Build and verify compilation
hc s sandbox generate workdir/

# Run tests
bun run test

DNA Properties & Progenitor Pattern

DNA properties let you embed configuration into the DNA at deploy time. The progenitor pattern uses this to designate one agent as the permanent administrator of a DHT network — their pubkey is burned into the DNA at install time via modifiers.properties, making admin authority immutable and cryptographically verifiable.

Reading network info from DNA properties:

#![allow(unused)]
fn main() {
let info = dna_info()?;
let network_seed = info.modifiers.network_seed.to_string();
let dna_hash = info.hash;
}

For the full progenitor implementation — DnaProperties struct, check_if_progenitor(), coordinator guard, optional integrity enforcement, bootstrap auto-registration, deploy-time injection (dna.yaml / Sweettest / Kangaroo / Moss), and pitfalls — see progenitor.md.

Cross-ref: access-control.md for capability grants and delegated admin patterns.


Private Entries

#![allow(unused)]
fn main() {
// In integrity crate — mark entry as private:
#[hdk_entry_types]
pub enum EntryTypes {
    #[entry_type(visibility = "private")]
    MyPrivateEntry(MyPrivateEntry),
    MyPublicEntry(MyPublicEntry),  // default is public
}
}

Key semantics:

  • Private entries are stored on the author’s source chain only — never published to the DHT
  • Other agents can see the action (action hash, author, timestamp) but cannot retrieve the entry content
  • Private ≠ encrypted — other agents simply cannot fetch the entry, but if the data were leaked, it would be readable
  • Use encryption (e.g., x_25519_x_salsa20_poly1305_encrypt) if you need genuine confidentiality beyond network-level privacy

When to use private entries:

  • Personal notes or drafts not meant for others
  • Intermediate state that should not be globally visible
  • Data that only the agent and explicitly authorized parties should read

Multi-DNA Architecture

Most hApps can use a single DNA. When to consider multiple DNAs (roles):

PatternWhen to use
Single DNAAll agents share the same DHT network; simplest
Multiple rolesSeparate concerns with different network boundaries (e.g., public + private data)
Clone cellsPartitioned data — separate instances per user, group, or time period

Bridge Calls Between Roles

#![allow(unused)]
fn main() {
// Call a function in a different role within the same hApp:
let response = call(
    CallTargetCell::OtherRole("other_role_name".into()),
    "other_zome".into(),
    "function_name".into(),
    None,
    input,
)?;
}

happ.yaml Multi-Role Structure

manifest_version: "0"
name: my_happ
roles:
  - name: primary_role
    provisioning:
      strategy: create
      deferred: false
    dna:
      bundled: "./primary.dna"
  - name: secondary_role
    provisioning:
      strategy: create
      deferred: false         # provisioned on install, clonable at runtime
    dna:
      bundled: "./secondary.dna"
      modifiers:
        network_seed: ~
      clone_limit: 10         # allow up to 10 clones of this role

deferred — ignored by the conductor on install. AppBundle::resolve_cell destructures Create { .. } without reading it, so a create role is always provisioned. Leave it false. clone_limit — enables cell cloning for this role, and does not require an unprovisioned base cell (see cell-cloning.md).

Progenitor Pattern

The progenitor is a single agent whose public key is burned into the DNA at install time via DNA modifiers.properties. Every peer in the network can read the progenitor’s identity deterministically, making admin authority immutable and cryptographically verifiable without a centralized registry.

Do not use init_properties for this. Holochain 0.7 added an InitProperties type set on RoleSettings::Provisioned and read with get_init_properties(). It looks like a natural fit and is not one. Those bytes are conductor-local: opaque to the conductor, never written to the DHT, readable only from the init callback, and cleared once init succeeds or the app is uninstalled.

The progenitor pattern needs the opposite properties. Integrity validation runs on every peer, deterministically, against data that must be identical network-wide. A validating peer cannot see another agent’s init properties, so a check written against them would pass for the author and fail, or fail to be checkable, for everyone else.

modifiers.properties is part of the DNA hash and therefore agreed by every member of the network. That is what makes it safe to validate against. Init properties are for seeding a freshly migrated chain with per-install state; see migration.md.

Two reference implementations inform this page:

  • Requests & Offers (happenings-community/requests-and-offers) — coordinator-only enforcement, auto-registration via the first create_user call
  • Moss (lightningrodlabs/moss) — opt-in at group creation, integrity-level enforcement in validate(), progenitor key transported via invite-link

1. DnaProperties struct

Place this in a shared utils crate consumed by all integrity and coordinator zomes. The SerializedBytes derive is required — Holochain serializes YAML properties to MessagePack at install time, and try_into() decodes it:

#![allow(unused)]
fn main() {
// dnas/my_dna/utils/src/dna_properties.rs
use hdi::prelude::*;

#[derive(Serialize, Deserialize, SerializedBytes, Debug, Clone)]
pub struct DnaProperties {
    pub progenitor_pubkey: Option<String>,  // null = dev / bootstrap mode
}

impl DnaProperties {
    pub fn get() -> ExternResult<Self> {
        dna_info()?
            .modifiers
            .properties
            .try_into()
            .map_err(|e| wasm_error!(WasmErrorInner::Guest(
                format!("Failed to deserialize DnaProperties: {e}")
            )))
    }

    pub fn get_progenitor_pubkey() -> ExternResult<Option<AgentPubKey>> {
        match Self::get()?.progenitor_pubkey {
            None => Ok(None),
            Some(s) => AgentPubKey::try_from(s).map(Some).map_err(|e| {
                wasm_error!(WasmErrorInner::Guest(
                    format!("Invalid progenitor pubkey in DNA properties: {e}")
                ))
            }),
        }
    }
}
}
#![allow(unused)]
fn main() {
// dnas/my_dna/utils/src/lib.rs
pub fn check_if_progenitor() -> ExternResult<bool> {
    match DnaProperties::get_progenitor_pubkey()? {
        None => Ok(false),   // no progenitor configured → bootstrap mode
        Some(progenitor) => Ok(progenitor == agent_info()?.agent_initial_pubkey),
    }
}
}

check_if_progenitor() returns false when no progenitor is configured. Bootstrap logic (who becomes the first admin in that case) lives in your application code — see section 4.

Moss variant: Moss uses { progenitor: AgentPubKeyB64 | null } (field name progenitor, not progenitor_pubkey) with the same Option<String> Rust type and the same SerializedBytes deserialization pattern.


2. Coordinator guard

Expose is_progenitor as an hdk_extern for the UI, and guard admin functions with check_if_progenitor():

#![allow(unused)]
fn main() {
#[hdk_extern]
pub fn is_progenitor(_: ()) -> ExternResult<bool> {
    check_if_progenitor()
}

#[hdk_extern]
pub fn add_administrator(input: EntityAgent) -> ExternResult<bool> {
    let is_prog = check_if_progenitor()?;
    let is_admin = check_if_agent_is_administrator(agent_info()?.agent_initial_pubkey)?;
    let progenitor_configured = DnaProperties::get_progenitor_pubkey()?.is_some();
    let is_bootstrap = !progenitor_configured
        && get_all_administrators_links(input.entity.clone())?.is_empty();

    if !is_prog && !is_admin && !is_bootstrap {
        return Err(wasm_error!(WasmErrorInner::Guest(
            "Only the progenitor or an existing administrator can add administrators".into()
        )));
    }
    register_administrator(input)?;
    Ok(true)
}
}

The is_bootstrap branch handles dev mode: when no progenitor is configured and no admins exist yet, the first caller of add_administrator is allowed through.


3. Integrity enforcement (Moss pattern — optional hardening)

R&O enforces the progenitor check only in the coordinator. Moss additionally enforces it in validate() so that a malicious peer cannot bypass the coordinator by calling zome functions directly:

#![allow(unused)]
fn main() {
// In integrity validate() — dna_info() is safe here: deterministic, reads own DNA metadata
fn validate_create_admin_entry(
    action: Create,
    _entry: AdminEntry,
) -> ExternResult<ValidateCallbackResult> {
    match DnaProperties::get()?.progenitor_pubkey {
        None => Ok(ValidateCallbackResult::Valid),  // bootstrap mode: no restriction
        Some(progenitor_str) => {
            let progenitor = AgentPubKey::try_from(progenitor_str)
                .map_err(|e| wasm_error!(WasmErrorInner::Guest(format!("{e}"))))?;
            if action.author != progenitor {
                return Ok(ValidateCallbackResult::Invalid(
                    "Only the progenitor can author this entry".into(),
                ));
            }
            Ok(ValidateCallbackResult::Valid)
        }
    }
}
}

Rules for validation:

  • dna_info() is safe — reads the DNA’s own metadata, fully deterministic
  • Use action.author — you are validating someone else’s action, not checking yourself
  • get() (DHT read) is forbidden in validation — breaks determinism; inspect only the op itself

Tradeoff: Coordinator-only (R&O) is simpler and sufficient for most apps. Integrity enforcement (Moss) is defense-in-depth for higher-security entries where you cannot trust peers to follow coordinator rules.


4. Bootstrap and auto-registration

init() runs on every agent at install time and has no DHT state to query. It is NOT the place to auto-register the progenitor. Instead, put auto-registration inside your first entity creation function (e.g. create_user, create_profile):

#![allow(unused)]
fn main() {
// In coordinator create_user / create_profile — after creating the entry:
let is_prog = check_if_progenitor()?;
let progenitor_configured = DnaProperties::get_progenitor_pubkey()?.is_some();

let should_auto_register = if progenitor_configured {
    is_prog  // production: only the progenitor auto-gets admin
} else {
    // dev / bootstrap: first agent whose profile creation finds no existing admins
    let existing_admins: Vec<Link> = external_local_call(
        "get_all_administrators_links",
        "administration",
        "network".to_string(),
    )?;
    existing_admins.is_empty()
};

if should_auto_register {
    external_local_call(
        "add_administrator",
        "administration",
        EntityActionHashAgents {
            entity: "network".to_string(),
            entity_original_action_hash: OriginalActionHash(profile_hash.clone()),
            agent_pubkeys: vec![agent_info()?.agent_initial_pubkey],
        },
    )?;
}
}

init() itself should only set up the unrestricted signal cap grant and return Pass:

#![allow(unused)]
fn main() {
#[hdk_extern]
pub fn init(_: ()) -> ExternResult<InitCallbackResult> {
    let mut functions = HashSet::new();
    functions.insert((zome_info()?.name, "recv_remote_signal".into()));
    create_cap_grant(ZomeCallCapGrant {
        tag: "recv_remote_signal".into(),
        access: CapAccess::Unrestricted,
        functions: GrantedFunctions::Listed(functions),
    })?;
    Ok(InitCallbackResult::Pass)
}
}

5. Setting properties at deploy time

Dev / CI — dna.yaml

Leave null for local development; the first-user bootstrap handles the admin seed:

# workdir/dna.yaml
integrity:
  properties:
    progenitor_pubkey: null    # bootstrap mode; set a key for production tests
  zomes:
    - name: my_domain_integrity
      bundled: "./zomes/integrity/my_domain_integrity.wasm"

Get an agent pubkey from a running sandbox:

hc sandbox call --running my-app my_zome get_agent_info '{}' \
  | jq -r '.agent_initial_pubkey'

Sweettest

#![allow(unused)]
fn main() {
let props = DnaProperties { progenitor_pubkey: Some(alice_pubkey.to_string()) };
let props_bytes = SerializedBytes::try_from(props).unwrap();
let dna = SweetDnaFile::from_bundle_with_overrides(
    Path::new(DNA_PATH),
    DnaModifiersOpt::default().with_properties(props_bytes),
).await?;
}

Kangaroo / custom Electron

Make the installing agent the progenitor at runtime:

import { encode } from "@msgpack/msgpack";
import { encodeHashToBase64 } from "@holochain/client";

const agentPubKey = await adminWs.generateAgentPubKey();

await adminWs.installApp({
  installed_app_id: "my-app",
  agent_key: agentPubKey,
  // `source` is a tagged union: { type: "path", value } or { type: "bytes", value }.
  source: { type: "path", value: "./workdir/my-app.happ" },
  roles_settings: {
    my_dna: {
      type: "provisioned",
      value: {
        modifiers: {
          properties: encode({ progenitor_pubkey: encodeHashToBase64(agentPubKey) }),
        },
      },
    },
  },
});

Note value wraps modifiers: this is required by the client’s RoleSettings type. See client.md for the full admin surface.

Moss (group DNA)

Moss treats progenitor as an opt-in per-group choice via a withProgenitor boolean in the group creation UI. Joiners receive the creator’s key via invite-link and install with it verbatim — they never substitute their own key, so all peers derive the same DNA hash:

// Creator (src/main/index.ts in lightningrodlabs/moss)
const properties = withProgenitor
  ? { progenitor: encodeHashToBase64(agentPubKey) }
  : { progenitor: null };

await adminWebsocket.installApp({
  ...
  roles_settings: {
    group: {
      type: "provisioned",
      value: { modifiers: { properties } },
    },
  },
});

// Joiner: properties come verbatim from the invite-link (&progenitor=uhCAk... or "null")
// Joiners NEVER substitute their own key — DNA hashes must converge across all peers

Moss-specific conventions:

  • Field name is progenitor (not progenitor_pubkey)
  • Progenitor injection is only for the group DNA — Moss applets must inject their own if needed
  • The invite-link carries networkSeed + progenitor together; validation confirms the key starts with uhCAk and decodes to 39 bytes

Common Pitfalls

PitfallFix
Registering progenitor as admin inside init()Put auto-registration in your first entity creation fn (e.g. create_user)
Coordinator-only guard for high-security entriesAdd integrity enforcement (Moss pattern) if peers must not bypass the coordinator
agent_info()?.agent_initial_pubkey used in validate()Use action.author — you are checking the action author, not yourself
get() (DHT read) inside validate()Forbidden — only dna_info(), zome_info(), and the op itself are safe
Missing SerializedBytes derive on DnaPropertiesThe .try_into() deserialization will fail at runtime without it
Missing value wrapper in roles_settings TypeScript{ type: "provisioned", value: { modifiers: { ... } } }value is required
Joiner substituting their own key as progenitorCopy the creator’s key verbatim (invite-link / config); joiners must match DNA hash
Hardcoding a pubkey in sourceAlways read from dna_info().modifiers.properties
Progenitor key rotationThe pattern does not support it — use role-based access (access-control.md) for delegatable authority

Cross-ref: access-control.md — delegating admin authority beyond the progenitor | workflows/design-access-control.md — choosing the right access model for your app

Holochain Patterns

Entry Types (Integrity Crate)

What NOT to put in entry fields — already in action headers:

Every committed action carries free metadata in its header. Never duplicate these as entry fields:

Already in headerHow to access (coordinator)
Author (agent pubkey)record.action().author()
Timestamprecord.action().timestamp()
Entry hashrecord.action().entry_hash()
Previous action hashavailable on Update/Delete actions

If you find yourself adding created_by: AgentPubKey or created_at: Timestamp to an entry struct, remove them — they’re already there.

#![allow(unused)]
fn main() {
use hdi::prelude::*;

// Entry struct — always derive these
#[hdk_entry_helper]
#[derive(Clone, PartialEq)]
pub struct MyEntry {
    pub title: String,
    pub description: String,
    pub status: MyEntryStatus,
    // Use #[serde(default)] for fields added after initial deployment
    #[serde(default)]
    pub tags: Vec<String>,
    // DO NOT add: author, created_at, updated_at — those are in the action header
}

// Status enum for soft-delete pattern
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum MyEntryStatus {
    Active,
    Archived,
    Deleted,
}

// Register all entry types in one enum (integrity crate)
#[hdk_entry_types]
#[unit_enum(UnitEntryTypes)]
pub enum EntryTypes {
    MyEntry(MyEntry),
    AnotherEntry(AnotherEntry),
}
}

#![allow(unused)]
fn main() {
// Register all link types in one enum (integrity crate)
#[hdk_link_types]
pub enum LinkTypes {
    // Naming convention: BaseToTarget (PascalCase)
    AgentToMyEntry,
    PathToMyEntry,
    MyEntryUpdates,       // Update chain tracking
    MyEntryToRelated,     // Bidirectional: also RelatedToMyEntry
    RelatedToMyEntry,
}
}

Naming convention: {Base}To{Target} — always PascalCase, always directional.


Holochain has two layers of navigable relationships. Understanding the distinction prevents over-engineering and redundant data.

1. Action metadata — fields baked into every action header:

FieldTypeHow to access
authorAgentPubKeyrecord.action().author()
timestampTimestamprecord.action().timestamp()
original_action_addressActionHashonly on Action::Update — the original creation action
deletes_addressActionHashonly on Action::Delete — the action being deleted

Walking backward through an update chain uses this — no links needed:

#![allow(unused)]
fn main() {
// From any update action hash → find the original
match record.action().clone() {
    Action::Update(u) => current_hash = u.original_action_address, // go back one step
    Action::Create(_) => return Ok(OriginalActionHash(current_hash)), // found it
    _ => ...
}
}

2. DHT metadata — aggregated by the DHT automatically, returned by get_details:

#![allow(unused)]
fn main() {
pub struct RecordDetails {
    pub record: Record,
    pub validation_status: ValidationStatus,
    pub updates: Vec<SignedHashed<Action>>, // all Update actions on this record
    pub deletes: Vec<SignedHashed<Action>>, // all Delete actions on this record
}

pub struct EntryDetails {
    pub entry: Entry,
    pub actions: Vec<SignedHashed<Action>>, // all Create/Update actions for this entry
    pub updates: Vec<SignedHashed<Action>>,
    pub deletes: Vec<SignedHashed<Action>>,
}
}

3. Embedded ActionHash in entry fields — a relationship baked INTO the entry content

#![allow(unused)]
fn main() {
#[hdk_entry_helper]
#[derive(Clone, PartialEq)]
pub struct Offer {
    pub title: String,
    pub organization_hash: ActionHash, // embedded relationship — no create_link needed
}
}

Critical tradeoff: If organization_hash changes, the content changes → new entry hash → requires update_entry. Use embedded hashes when the reference is intrinsic to the entry’s identity. Use explicit links when the relationship may change independently.

Link typePurpose
PathToMyEntryGlobal discovery — browse all entries from a known path string
AgentToMyEntryPer-agent listing — “show me this agent’s entries”
MyEntryUpdatesForward traversal — original hash → latest version
MyEntryToRelatedCross-domain relationship navigation

Decision rule

QuestionTool
“Who created this entry? When?”record.action().author() / .timestamp() — no links
“Has this record been updated or deleted?”get_details(action_hash).updates / .deletes
“What is the LATEST version of this entry?”get_links(original_hash, UpdatesLinkType) → max timestamp
“Find entries without knowing any hash”Explicit PathTo* or AgentTo* links
“Navigate from entry A to related entry B”Explicit AToB link
“Link is intrinsic to entry identity?”Embedded ActionHash field in entry struct
“Link may change independently of entry?”Explicit link — keeps entry hash stable

Create Pattern

#![allow(unused)]
fn main() {
pub fn create_my_entry(my_entry: MyEntry) -> ExternResult<Record> {
    let my_entry_hash = create_entry(&EntryTypes::MyEntry(my_entry.clone()))?;

    // 1. Discovery anchor (path)
    let path = Path::from("entries.active");
    create_link(
        path.path_entry_hash()?,
        my_entry_hash.clone(),
        LinkTypes::PathToMyEntry,
        (),
    )?;

    // 2. Agent index
    let agent_info = agent_info()?;
    create_link(
        agent_info.agent_initial_pubkey,
        my_entry_hash.clone(),
        LinkTypes::AgentToMyEntry,
        (),
    )?;

    // 3. Get and return the full record
    let record = get(my_entry_hash.clone(), GetOptions::default())?
        .ok_or(wasm_error!(WasmErrorInner::Guest("Entry not found after create".into())))?;

    Ok(record)
}
}

Read Latest Pattern (Walking Update Chain)

#![allow(unused)]
fn main() {
pub fn get_latest_my_entry(original_action_hash: ActionHash) -> ExternResult<Option<Record>> {
    let links = get_links(
        LinkQuery::try_new(original_action_hash.clone(), LinkTypes::MyEntryUpdates)?,
        GetStrategy::default(),
    )?;

    let latest_link = links
        .into_iter()
        .max_by(|a, b| a.timestamp.cmp(&b.timestamp));

    let latest_hash = match latest_link {
        Some(link) => {
            link.target
                .into_action_hash()
                .ok_or(wasm_error!(WasmErrorInner::Guest("Invalid target hash".into())))?
        }
        None => original_action_hash, // No updates — original is latest
    };

    get(latest_hash, GetOptions::default())
}
}

Read Collection Pattern

#![allow(unused)]
fn main() {
pub fn get_all_my_entries() -> ExternResult<Vec<Record>> {
    let path = Path::from("entries.active");
    let links = get_links(
        LinkQuery::try_new(path.path_entry_hash()?, LinkTypes::PathToMyEntry)?,
        GetStrategy::default(),
    )?;

    let get_inputs: Vec<GetInput> = links
        .into_iter()
        .filter_map(|link| link.target.into_action_hash())
        .map(|hash| GetInput::new(hash.into(), GetOptions::default()))
        .collect();

    let records = HDK.with(|hdk| hdk.borrow().get(get_inputs))?;
    Ok(records.into_iter().flatten().collect())
}
}

Update Pattern

#![allow(unused)]
fn main() {
pub fn update_my_entry(
    original_action_hash: ActionHash,
    previous_action_hash: ActionHash,
    updated_entry: MyEntry,
) -> ExternResult<Record> {
    // 1. Author check
    let original_record = get(original_action_hash.clone(), GetOptions::default())?
        .ok_or(wasm_error!(WasmErrorInner::Guest("Entry not found".into())))?;
    let action = original_record.action();
    let agent = agent_info()?.agent_initial_pubkey;
    if action.author() != &agent {
        return Err(wasm_error!(WasmErrorInner::Guest("Not authorized".into())));
    }

    // 2. Update entry
    let updated_action_hash = update_entry(previous_action_hash, &EntryTypes::MyEntry(updated_entry))?;

    // 3. Track update chain with link
    create_link(
        original_action_hash,
        updated_action_hash.clone(),
        LinkTypes::MyEntryUpdates,
        (),
    )?;

    let record = get(updated_action_hash, GetOptions::default())?
        .ok_or(wasm_error!(WasmErrorInner::Guest("Updated record not found".into())))?;
    Ok(record)
}
}

Delete Pattern

#![allow(unused)]
fn main() {
pub fn delete_my_entry(original_action_hash: ActionHash) -> ExternResult<ActionHash> {
    let path = Path::from("entries.active");
    let path_links = get_links(
        LinkQuery::try_new(path.path_entry_hash()?, LinkTypes::PathToMyEntry)?,
        GetStrategy::default(),
    )?;
    for link in path_links {
        if let Some(hash) = link.target.into_action_hash() {
            if hash == original_action_hash {
                delete_link(link.create_link_hash, GetOptions::default())?;
            }
        }
    }
    delete_entry(original_action_hash)
}
}

Status Transition (Soft Delete)

Prefer updating status over deleting for data that other agents may reference:

#![allow(unused)]
fn main() {
pub fn archive_my_entry(original_action_hash: ActionHash, previous_action_hash: ActionHash)
    -> ExternResult<Record> {
    let mut record = get_latest_my_entry(original_action_hash.clone())?
        .ok_or(wasm_error!(WasmErrorInner::Guest("Entry not found".into())))?;

    let mut entry: MyEntry = record.entry().to_app_option()?.ok_or(
        wasm_error!(WasmErrorInner::Guest("Expected MyEntry".into()))
    )?;

    if entry.status == MyEntryStatus::Deleted {
        return Err(wasm_error!(WasmErrorInner::Guest("Cannot archive deleted entry".into())));
    }

    entry.status = MyEntryStatus::Archived;
    update_my_entry(original_action_hash, previous_action_hash, entry)
}
}

Cross-Zome Calls

#![allow(unused)]
fn main() {
// In utils/src/cross_zome.rs
pub fn external_local_call<I, T>(zome_name: &str, fn_name: &str, input: I) -> ExternResult<T>
where
    I: serde::Serialize + std::fmt::Debug,
    T: serde::de::DeserializeOwned + std::fmt::Debug,
{
    let zome_call_response = call(
        CallTargetCell::Local,
        zome_name.into(),
        fn_name.into(),
        None,
        input,
    )?;

    match zome_call_response {
        ZomeCallResponse::Ok(result) => {
            let typed: T = result.decode().map_err(|e| {
                wasm_error!(WasmErrorInner::Guest(format!("Decode error: {:?}", e)))
            })?;
            Ok(typed)
        }
        ZomeCallResponse::Unauthorized(auth, _, zome, func) => Err(wasm_error!(
            WasmErrorInner::Guest(format!("Unauthorized: {zome}/{func} ({auth:?})"))
        )),
        ZomeCallResponse::AuthenticationFailed(_, agent) => Err(wasm_error!(
            WasmErrorInner::Guest(format!("Authentication failed for {agent:?}"))
        )),
        ZomeCallResponse::NetworkError(e) => Err(wasm_error!(WasmErrorInner::Guest(
            format!("Network error: {e}")
        ))),
        ZomeCallResponse::CountersigningSession(e) => Err(wasm_error!(WasmErrorInner::Guest(
            format!("Countersigning session failed to start: {e}")
        ))),
    }
}

// Usage:
let result: MyOtherEntry = external_local_call("other_zome", "get_entry", hash)?;
}

Signals (post_commit)

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
pub enum Signal {
    LinkCreated { action: SignedActionHashed, link_type: LinkTypes },
    LinkDeleted { action: SignedActionHashed, link_type: LinkTypes },
    EntryCreated { action: SignedActionHashed, app_entry: EntryTypes },
    EntryUpdated { action: SignedActionHashed, app_entry: EntryTypes, original_app_entry: EntryTypes },
    EntryDeleted { action: SignedActionHashed, original_app_entry: EntryTypes },
}

// NOTE: post_commit is infallible — use #[hdk_extern(infallible)] and log errors
#[hdk_extern(infallible)]
pub fn post_commit(committed_actions: Vec<SignedActionHashed>) {
    for action in committed_actions {
        if let Err(err) = signal_action(action) {
            error!("Error signaling new action: {:?}", err);
        }
    }
}
}

Remote signals — send signals to other agents:

#![allow(unused)]
fn main() {
// Sender:
// Signature is send_remote_signal(input, agents): payload FIRST, then a Vec of
// recipients. Passing a bare AgentPubKey, or the two the other way round, does
// not compile.
send_remote_signal(MySignal::Ping, vec![recipient_pubkey])?;

// Receiver callback:
#[hdk_extern]
pub fn recv_remote_signal(signal: SerializedBytes) -> ExternResult<()> {
    let sig: MySignal = signal.try_into()?;
    emit_signal(sig)?;
    Ok(())
}

// REQUIRED: cap grant in init() so any agent can call recv_remote_signal:
#[hdk_extern]
pub fn init(_: ()) -> ExternResult<InitCallbackResult> {
    let mut functions = HashSet::new();
    functions.insert((zome_info()?.name, "recv_remote_signal".into()));
    create_cap_grant(ZomeCallCapGrant {
        tag: "remote_signals".into(),
        access: CapAccess::Unrestricted,
        functions: GrantedFunctions::Listed(functions),
    })?;
    Ok(InitCallbackResult::Pass)
}
}

Note: send_remote_signal is fire-and-forget — it does not wait for confirmation and does not queue messages for offline agents.


HDK 0.7 API Changes (Breaking)

The action model rewrite is covered under Validation. These are the rest.

get_agent_activity() — takes a GetOptions, returns AgentActivityStatus

#![allow(unused)]
fn main() {
let activity: AgentActivityStatus = get_agent_activity(
    agent,
    ChainQueryFilter::new(),
    ActivityRequest::Full,
    GetOptions::default(),   // new fourth argument in 0.7
)?;
}

The return type was renamed from AgentActivity to resolve a collision with the unrelated AgentActivity op variant. It can now report ChainStatus::Closed when a source chain head is a CloseChain action, ranking above Valid but below Forked and Invalid. Match exhaustively and you need the new arm.

ChainFilter — constructors, not a builder chain

Each limit condition has its own constructor taking the chain top, so a filter carries exactly one condition:

#![allow(unused)]
fn main() {
// 0.6: ChainFilter::new(chain_top).until_hash(oldest_hash)
let filter = ChainFilter::until_hash(chain_top, oldest_hash);

// 0.6: ChainFilter::new(chain_top).take(10)
let filter = ChainFilter::take(chain_top, 10);
}

ChainFilter::new, until_timestamp and the include_cached_entries builder method remain. A filter with Take(0) is now rejected as invalid input rather than returning an empty result.

must_get_agent_activity now walks down from the chain_top you give it, excludes forked actions, and reports more precisely when it cannot answer deterministically. If you match on MustGetAgentActivityResponse, handle UntilHashMissing, UntilHashAfterChainHead, UntilTimestampIndeterminate and IncompleteChain.

Record::new() takes a RecordEntry

So that “there is no entry” and “the entry is hidden from you” are distinguishable:

#![allow(unused)]
fn main() {
Details::Entry(details) => Ok(Some(Record::new(
    details.actions[0].clone(),
    RecordEntry::Present(details.entry),   // 0.6 took Some(details.entry)
)))
}

block_agent() and unblock_agent() are gone

Removed from the HDK entirely, host functions included. WASM that still references them fails to instantiate. Blocking is now a system-level behaviour driven by warrants, not something an application decides. Application-level blocking built on these needs redesigning rather than porting.

What replaced them: warrants and chain status

A warrant is a notice, issued by an authority that validated a DHT operation, that a specific action by a specific agent was invalid. Warrants propagate to the neighborhood holding that agent’s activity. The system acts on them. Your zome reads them.

You get both the status and the warrants from one call:

#![allow(unused)]
fn main() {
let activity = get_agent_activity(
    agent.clone(),
    ChainQueryFilter::new(),
    ActivityRequest::Status,
    GetOptions::default(),
)?;

match activity.status {
    ChainStatus::Valid(head)   => { /* valid as far as THIS authority saw */ }
    ChainStatus::Forked(fork)  => { /* two conflicting actions at one sequence */ }
    ChainStatus::Invalid(head) => { /* invalid from this action forward */ }
    ChainStatus::Empty         => { /* this authority knows nothing yet */ }
    _ => {}
}

if !activity.warrants.is_empty() {
    // other authorities found invalidity that `status` does not reflect
}
}

Four things about this that catch people out, all stated in the 0.7 source:

  1. ChainStatus::Valid is one authority’s opinion, not a verdict. It means the authority you asked saw no invalid op. Another authority validating a different op for the same action may have found it invalid. Checking status without also checking warrants gives you a false clean bill of health.
  2. warrants is the field that carries the cross-authority picture. status, valid_activity and rejected_activity are all scoped to the responding authority. valid_activity can list actions that other authorities have warranted.
  3. Forked wins over Invalid. A chain that is both forked and has invalid records reports Forked. To see the invalid records too, read warrants, or re-query with ActivityRequest::Full and inspect rejected_activity.
  4. Closed wins over Valid. A chain whose head is a CloseChain action reports Closed, not Valid. Treat it as “this agent will append nothing further”, not as an error.

Design consequence. Since you can no longer block an agent from your zome, the honest pattern is: query activity before you act on an agent’s data where the stakes justify a round trip, and let your own application logic decide to ignore, quarantine, or flag. That is a coordinator-side decision. It cannot live in validate(), which has no network access.

#![allow(unused)]
fn main() {
// WRONG (pre-0.6):  legacy-ok
delete_link(link.create_link_hash)?;  // legacy-ok

// CORRECT (0.6+):
delete_link(link.create_link_hash, GetOptions::default())?;
}

LinkQuery::new() + GetStrategy

#![allow(unused)]
fn main() {
let links = get_links(
    LinkQuery::try_new(original_action_hash.clone(), LinkTypes::MyEntryUpdates)?,
    GetStrategy::Local,
)?;
}

GetStrategy decision rule:

StrategyWhen to use
GetStrategy::LocalSource chain only — use for get_my_* (own authored data, fast, no network)
GetStrategy::NetworkDHT — use for get_all_* (data authored by others, default behavior)

Additional LinkQuery features:

#![allow(unused)]
fn main() {
// Tag prefix filter:
let query = LinkQuery::try_new(base, LinkTypes::MyLink)?
    .tag_prefix(LinkTag::new(tag_bytes));

// Count without fetching records:
let count = count_links(query.clone())?;

// Include deleted links:
let details = get_links_details(query, GetStrategy::default())?;
}

HDK.with() Batch Gets

More efficient than N individual get() calls:

#![allow(unused)]
fn main() {
let get_inputs: Vec<GetInput> = links
    .into_iter()
    .filter_map(|link| link.target.into_action_hash())
    .map(|hash| GetInput::new(hash.into(), GetOptions::default()))
    .collect();
let records = HDK.with(|hdk| hdk.borrow().get(get_inputs))?;
let records: Vec<Record> = records.into_iter().flatten().collect();
}

must_get_* Family (Fail-Fast Gets)

Unlike get() which returns Option, these return an error immediately if the record is not found.

#![allow(unused)]
fn main() {
// In coordinator — authorship check before update:
let original_record = must_get_valid_record(input.original_action_hash.clone().into())?;
let author = original_record.action().author().clone();

// In integrity validation — authorship check:
let original_action_record = must_get_action(original_action_hash.clone())?;
if action.action().author() != original_action_record.action().author() {
    return Ok(ValidateCallbackResult::Invalid(
        "Only the original author can update this entry.".to_string(),
    ));
}
}

Full family:

  • must_get_valid_record(action_hash) — record that passed validation
  • must_get_action(action_hash) — raw action (use in validation)
  • must_get_entry(entry_hash) — entry content
  • must_get_agent_activity(agent, filter) — agent’s source chain slice

Validation (Integrity Crate)

The shape below is taken from hc scaffold 0.700.0 output on Holochain 0.7, trimmed to one entry type and one link type. hc scaffold generates the full dispatcher for you; hand-writing it is not the intended path.

#![allow(unused)]
fn main() {
#[hdk_extern]
pub fn validate(op: Op) -> ExternResult<ValidateCallbackResult> {
    match op.flattened::<EntryTypes, LinkTypes>()? {
        FlatOp::CreateEntry(create_entry) => match create_entry {
            OpEntry::CreateEntry { app_entry, action } => {
                let create_action: TypedAction<EntryCreationData> = action.into();
                match app_entry {
                    EntryTypes::MyEntry(entry) => validate_create_my_entry(create_action, entry),
                }
            }
            _ => Ok(ValidateCallbackResult::Valid),
        },
        FlatOp::Update(update_entry) => match update_entry {
            OpUpdate::Entry { app_entry, action } => {
                let original_action = TypedAction::<EntryCreationData>::try_from_action(
                    must_get_action(action.data.original_action_address.clone())?
                        .action()
                        .to_owned(),
                )?;
                match app_entry {
                    EntryTypes::MyEntry(entry) => {
                        let original_record =
                            must_get_valid_record(action.data.original_action_address.clone())?;
                        let original_entry = MyEntry::try_from(original_record)
                            .map_err(|e| wasm_error!(WasmErrorInner::Guest(format!("{e:?}"))))?;
                        validate_update_my_entry(action, entry, original_action, original_entry)
                    }
                }
            }
            _ => Ok(ValidateCallbackResult::Valid),
        },
        FlatOp::Delete(OpDelete { action }) => {
            let original_record = must_get_valid_record(action.data.deletes_address.clone())?;
            // ... narrow the original action and dispatch to validate_delete_my_entry
            Ok(ValidateCallbackResult::Valid)
        }
        FlatOp::Link(OpLink::CreateLink { link_type, action }) => match link_type {
            LinkTypes::MyEntryUpdates => validate_create_link_my_entry_updates(action),
            LinkTypes::AgentToMyEntry => validate_create_link_agent_to_my_entry(action),
        },
        FlatOp::Link(OpLink::DeleteLink { link_type, original_action, action }) => match link_type {
            LinkTypes::MyEntryUpdates =>
                validate_delete_link_my_entry_updates(action, original_action),
            LinkTypes::AgentToMyEntry =>
                validate_delete_link_agent_to_my_entry(action, original_action),
        },
        // CreateRecord mirrors the above per-entry validation at record level
        FlatOp::CreateRecord(_) => Ok(ValidateCallbackResult::Valid),
        FlatOp::AgentActivity(_) => Ok(ValidateCallbackResult::Valid),
    }
}
}

The validation function signatures it dispatches to:

#![allow(unused)]
fn main() {
pub fn validate_create_my_entry(
    _action: TypedAction<EntryCreationData>,
    _entry: MyEntry,
) -> ExternResult<ValidateCallbackResult> { Ok(ValidateCallbackResult::Valid) }

pub fn validate_update_my_entry(
    _action: TypedAction<UpdateData>,
    _entry: MyEntry,
    _original_action: TypedAction<EntryCreationData>,
    _original_entry: MyEntry,
) -> ExternResult<ValidateCallbackResult> { Ok(ValidateCallbackResult::Valid) }

pub fn validate_delete_my_entry(
    _action: TypedAction<DeleteData>,
    _original_action: TypedAction<EntryCreationData>,
    _original_entry: MyEntry,
) -> ExternResult<ValidateCallbackResult> { Ok(ValidateCallbackResult::Valid) }

pub fn validate_create_link_agent_to_my_entry(
    action: TypedAction<CreateLinkData>,
) -> ExternResult<ValidateCallbackResult> {
    let action_hash = action
        .data
        .target_address
        .into_action_hash()
        .ok_or(wasm_error!(WasmErrorInner::Guest(
            "No action hash associated with link".to_string()
        )))?;
    let _record = must_get_valid_record(action_hash)?;
    Ok(ValidateCallbackResult::Valid)
}

pub fn validate_delete_link_agent_to_my_entry(
    _action: TypedAction<DeleteLinkData>,
    _original_action: TypedAction<CreateLinkData>,
) -> ExternResult<ValidateCallbackResult> { Ok(ValidateCallbackResult::Valid) }
}

The 0.7 action model

An Action is no longer an enum of per-variant structs. It is a struct with two fields: a header carrying what every action shares, and a data enum carrying what is specific to the action type.

ActionHeader holds author, timestamp, action_seq and prev_action. Everything else lives on the ActionData variant, whose payload structs gained a Data suffix: CreateData, UpdateData, DeleteData, CreateLinkData, DeleteLinkData.

#![allow(unused)]
fn main() {
// Reading a common field: use the accessor, not the header directly.
// Both Action and TypedAction<D> have author(), timestamp(), action_seq(), prev_action().
if action.author() != record.action().author() {
    return Ok(ValidateCallbackResult::Invalid("Only the author may do this".into()));
}

// prev_action() is an Option: the genesis Dna action has no predecessor.
let prev = action
    .prev_action()
    .ok_or_else(|| wasm_error!(WasmErrorInner::Guest("expected a prior action".into())))?
    .clone();
}

FlatOp sub-types (OpEntry, OpUpdate, OpDelete, OpRecord, OpActivity, OpLink) now carry a TypedAction<D>: the header paired with exactly the data payload the matched variant guarantees. TypedAction<D> derefs to its data, so read payload fields straight off it. You only need .data to move a field out by value, because you cannot move out of a deref:

#![allow(unused)]
fn main() {
action.target_address                      // borrow: fine
action.data.target_address.into_action_hash()   // move: needs .data
}

FlatOp variant renames. The enum now describes what happened rather than what the DHT does about it, and the two link variants folded into one:

0.60.7
FlatOp::StoreEntry(..)FlatOp::CreateEntry(..)
FlatOp::StoreRecord(..)FlatOp::CreateRecord(..)
FlatOp::RegisterUpdate(..)FlatOp::Update(..)
FlatOp::RegisterDelete(..)FlatOp::Delete(OpDelete { action })
FlatOp::RegisterCreateLink { .. }FlatOp::Link(OpLink::CreateLink { link_type, action })
FlatOp::RegisterDeleteLink { .. }FlatOp::Link(OpLink::DeleteLink { link_type, action, original_action })
FlatOp::RegisterAgentActivity(..)FlatOp::AgentActivity(..)
EntryCreationActionTypedAction<EntryCreationData>

Widening and narrowing. Two directions, two different calls. Getting them the wrong way round is the most common 0.7 validation mistake.

Widening is infallible. In a CreateEntry or UpdateEntry arm you already hold a TypedAction<CreateData> or TypedAction<UpdateData>, and a validation function shared with the update path takes TypedAction<EntryCreationData>. The variant you matched already proves the shape, so this is a plain From:

#![allow(unused)]
fn main() {
let create_action: TypedAction<EntryCreationData> = action.into();
}

Narrowing a freshly-fetched action is fallible. When you pull an Action back out of must_get_action or must_get_valid_record, its shape is not statically known, so it has to be checked:

#![allow(unused)]
fn main() {
let original_action = TypedAction::<EntryCreationData>::try_from_action(
    must_get_action(action.data.original_action_address.clone())?
        .action()
        .to_owned(),
)?;
}

try_from_action returns ExternResult, so it drops straight into a ?-chain. There is also a TryFrom<Action> impl that yields WrongActionError instead, but it forces a map_err(|e| wasm_error!(...)) at every call site. Prefer try_from_action.

Propagate the failure, never return Invalid. Sys validation already guarantees that the original of an update is a Create or Update, and that a DeleteLink points at a CreateLink. A narrowing failure means that guarantee was violated, which is a fault in how the op reached your code, not bad data from its author. ValidateCallbackResult::Invalid blames the author. The ? is correct.

The same applies in a DeleteLink arm, where you need the CreateLink it deletes:

#![allow(unused)]
fn main() {
let create_link = TypedAction::<CreateLinkData>::try_from_action(record.action().clone())?;
}

Earlier 0.7 release candidates had no try_from_action, so generated code hand-rolled this as a match &record.action().data { ActionData::CreateLink(..) => TypedAction { header, data }, _ => Invalid }. If you inherit that shape from an rc-era scaffold, replace it: it is longer, and its fallback wrongly returns Invalid.

Link validation signatures collapsed. Base address, target address and tag are all reachable through the action, so they are no longer separate arguments:

#![allow(unused)]
fn main() {
pub fn validate_create_link_my_entry_updates(
    action: TypedAction<CreateLinkData>,
) -> ExternResult<ValidateCallbackResult> {
    let action_hash = action
        .data
        .target_address
        .into_action_hash()
        .ok_or(wasm_error!(WasmErrorInner::Guest(
            "No action hash associated with link".to_string()
        )))?;
    let _record = must_get_valid_record(action_hash)?;
    Ok(ValidateCallbackResult::Valid)
}
}

A DeleteLink action records only the link’s base address and the hash of the CreateLink it deletes, so target address and tag are not on it. Under FlatOp::Link you do not have to chase that yourself: OpLink exposes base_address(), target_address() and tag() getters that read through to the original_action.

Determinism rules for validation:

  • No get() or get_links(). Neither exists in hdi at all, so this is enforced by the crate rather than by discipline
  • No agent_info() (can vary by context)
  • No sys_time() comparisons against current time. Use the timestamp already on the action
  • No get_init_properties(). Init properties are conductor-local and never reach the DHT, so validation cannot see them. See progenitor.md
  • DHT reads are allowed, through the must_get_* family only: must_get_entry, must_get_action, must_get_valid_record, must_get_agent_activity. These are deterministic in the sense that matters: an unresolvable dependency makes the callback return early with UnresolvedDependencies, deferring the verdict rather than failing it, so every validator eventually agrees. The HDI describes them as “available in contexts such as validation where both determinism and network access is desirable”
  • Everything else must come from the op itself and its embedded data

See error-handling.md for wasm_error! and WasmErrorInner patterns, and testing.md for exercising validation in Sweettest.


Path Anchors

#![allow(unused)]
fn main() {
// Global discovery anchor
let path = Path::from("entries.active");
let path_hash = path.path_entry_hash()?;

// Hierarchical paths
let category_path = Path::from(format!("entries.{}.active", category));

// Ensure path exists (creates the path entry if not present)
path.ensure()?;
}

get_details() + Details::Record Deserialization

#![allow(unused)]
fn main() {
pub fn get_original_record(hash: ActionHash) -> ExternResult<Option<Record>> {
    let Some(details) = get_details(hash, GetOptions::default())? else {
        return Ok(None);
    };
    match details {
        Details::Record(d) => Ok(Some(d.record)),
        _ => Err(wasm_error!(WasmErrorInner::Guest("Expected record".into()))),
    }
}
}

In post_commit — extracting app entry type from a committed action:

#![allow(unused)]
fn main() {
let (zome_index, entry_index) = match record.action().entry_type() {
    Some(EntryType::App(AppEntryDef { zome_index, entry_index, .. })) => (zome_index, entry_index),
    _ => return Ok(None),
};
EntryTypes::deserialize_from_type(*zome_index, *entry_index, entry)
}

Update Chain Utilities

find_original_action_hash() — traverse backward to the Create action

Given any action hash in an update chain, loop back to the original Create:

#![allow(unused)]
fn main() {
pub fn find_original_action_hash(action_hash: ActionHash) -> ExternResult<OriginalActionHash> {
    let mut current_hash = action_hash;
    loop {
        let record = get(current_hash.clone(), GetOptions::default())?
            .ok_or(wasm_error!(WasmErrorInner::Guest("Record not found".into())))?;
        match record.action().clone() {
            Action::Create(_) => return Ok(OriginalActionHash(current_hash)),
            Action::Update(u) => { current_hash = u.original_action_address; }
            _ => return Err(wasm_error!(WasmErrorInner::Guest("Unexpected action type".into()))),
        }
    }
}
}

get_all_revisions_for_entry() — original + all updates chronologically

Use LinkQuery::new() + GetStrategy::Local over the {Entry}Updates link type, prepend the original record. Returns all versions in order from oldest to newest.


Path Status Hierarchies

For status-filtered global collections, use hierarchical path strings rather than a single path + runtime filtering:

#![allow(unused)]
fn main() {
const PENDING_PATH: &str = "entries.status.pending";
const APPROVED_PATH: &str = "entries.status.approved";
const REJECTED_PATH: &str = "entries.status.rejected";

// On creation — add link to pending path:
let pending_hash = Path::from(PENDING_PATH).path_entry_hash()?;
create_link(pending_hash, entry_hash.clone(), LinkTypes::AllEntries, ())?;

// On approval — move from pending to approved:
let approved_hash = Path::from(APPROVED_PATH).path_entry_hash()?;
create_link(approved_hash, entry_hash, LinkTypes::AllEntries, ())?;
// (delete the pending link separately)
}

Enables get_links filtered by status without fetching all entries — queries only the relevant path.


Type-Safe Hash Wrappers

Prevent passing wrong hash type to functions:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OriginalActionHash(pub ActionHash);

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreviousActionHash(pub ActionHash);

// Function signature is self-documenting and compile-time safe
pub fn update_my_entry(
    original: OriginalActionHash,
    previous: PreviousActionHash,
    entry: MyEntry,
) -> ExternResult<Record> { ... }
}

Holochain Access Control

Why Capability Grants Exist

Holochain zome functions are not open by default. When agent A wants to call a zome function on agent B’s cell (a “remote call”), B’s cell must have an explicit capability grant authorizing that call. Without a grant, the call is rejected.

This applies to:

  • call_remote() — calling a zome function on another agent’s cell
  • send_remote_signalrecv_remote_signal — the receiver needs a grant so the signal handler can be invoked

Calls from the same hApp’s UI (same agent, same cell) do not need grants.


Three CapAccess Tiers

1. CapAccess::Unrestricted — Any agent may call

#![allow(unused)]
fn main() {
use std::collections::HashSet;

#[hdk_extern]
pub fn init(_: ()) -> ExternResult<InitCallbackResult> {
    let mut functions = HashSet::new();
    functions.insert((zome_info()?.name, "recv_remote_signal".into()));

    create_cap_grant(ZomeCallCapGrant {
        tag: "open_to_all".into(),
        access: CapAccess::Unrestricted,
        functions: GrantedFunctions::Listed(functions),
    })?;

    Ok(InitCallbackResult::Pass)
}
}

Use when: the function should be callable by any agent (e.g., recv_remote_signal).

2. CapAccess::Transferable { secret } — Any agent with the secret may call

#![allow(unused)]
fn main() {
let secret = generate_cap_secret()?;

create_cap_grant(ZomeCallCapGrant {
    tag: "transferable_grant".into(),
    access: CapAccess::Transferable { secret },
    functions: GrantedFunctions::Listed(functions),
})?;

// Share `secret` with the grantee out-of-band (e.g., via a private entry or direct message)
}

Use when: you want to delegate access to anyone who holds the secret — like a token.

3. CapAccess::Assigned { secret, assignees } — Only specific agents with the secret

#![allow(unused)]
fn main() {
let secret = generate_cap_secret()?;
let mut assignees = BTreeSet::new();
assignees.insert(grantee_pubkey.clone());

create_cap_grant(ZomeCallCapGrant {
    tag: "assigned_grant".into(),
    access: CapAccess::Assigned { secret, assignees },
    functions: GrantedFunctions::Listed(functions),
})?;
}

Use when: access is explicitly scoped to one or more named agents.


Grant Lifecycle

Grantor side:                              Grantee side:
─────────────────────────────────          ──────────────────────────────────
1. generate_cap_secret()?              →   (receive secret out-of-band)
2. create_cap_grant(ZomeCallCapGrant   →   3. create_cap_claim(CapClaim {
      { tag, access, functions })              tag, grantor, secret,
                                            })?
                                       →   4. call_remote(
                                               grantor_pubkey,
                                               zome_name,
                                               fn_name,
                                               Some(secret),
                                               payload,
                                           )?

Step-by-step:

  1. Grantor generates a secret: let secret = generate_cap_secret()?;
  2. Grantor creates grant on their source chain (stored locally, not DHT)
  3. Grantee receives the secret via private entry, signal, or other channel
  4. Grantee stores it as a cap claim: create_cap_claim(CapClaim { tag, grantor, secret })?;
  5. Grantee calls with secret: call_remote(grantor, zome, fn_name, Some(secret), payload)?;

Decision Table

ScenarioPattern
recv_remote_signal open to all agentsCapAccess::Unrestricted in init()
Delegate a specific function to one agentCapAccess::Assigned + share secret via private entry
UI calling own zome (same agent, same cell)No grant needed
Admin-only zome functionProgenitor check in coordinator (see architecture.md § DNA Properties)
Public API any agent can callCapAccess::Unrestricted in init() for that function

Notes

  • Cap grants are stored on the grantor’s source chain — they are private, not shared to the DHT
  • Cap claims are stored on the grantee’s source chain
  • Revoking a grant: use delete_cap_grant(grant_action_hash)?;
  • GrantedFunctions::All grants access to ALL functions in the zome — use with extreme caution

Reference: developer.holochain.org/build/capabilities/

Membranes: Gating Who Can Join a Network

A membrane is the check that runs when an agent joins a DNA’s network, before that agent can write anything. It is the only mechanism Holochain gives you for “not everyone may participate”, and it is enforced by validation like everything else.

Types read from holochain_integrity_types 0.7.0 (src/genesis.rs, src/action.rs), hdi 0.8.0 (src/map_extern.rs, src/flat_op/flat_op_record.rs), hdk_derive 0.7.0, and the published @holochain/client 0.21.0 type declarations.

The three places a membrane is enforced

PlaceRuns onCan it see the network?Blocks what
genesis_self_check callbackThe joining agent’s own machine, before joiningNo. Deterministic, local onlyA bad proof, early, with a clear error
Validation of the AgentValidationPkg opEvery validating authorityNo. Same determinism rules as all validationThe join, for real
Conductor install flow (membrane_proof / provideMemproofs)The installerYes, it is ordinary application codeNothing. It supplies the proof

Only the second one actually enforces anything against a hostile agent. The self-check is a courtesy to honest agents, and the install flow is plumbing. Design accordingly.

What a membrane proof is

#![allow(unused)]
fn main() {
pub type MembraneProof = std::sync::Arc<SerializedBytes>;
}

Arbitrary bytes, chosen by you, carried in the second record of every agent’s source chain:

#![allow(unused)]
fn main() {
/// Per-variant data for [`ActionType::AgentValidationPkg`].
pub struct AgentValidationPkgData {
    /// Optional membrane proof provided when joining the network.
    pub membrane_proof: Option<MembraneProof>,
}
}

Option, so “no membrane” is the default and a DNA that ignores membrane proofs is the normal case.

On the TypeScript side it is Uint8Array, keyed by role name:

export type MembraneProof = Uint8Array;
export type MemproofMap = { [key: RoleName]: MembraneProof };

genesis_self_check

The proof format is yours. This example uses an invite signed by the progenitor, which is the common shape, and it is written out in full because the signature check is the part you cannot derive from the type signatures.

#![allow(unused)]
fn main() {
use hdi::prelude::*;

/// The membrane proof payload. Yours to define; nothing in Holochain knows this type.
/// `SerializedBytes` is what lets it round-trip through `MembraneProof`.
#[derive(Serialize, Deserialize, Debug, SerializedBytes)]
pub struct InviteCode {
    /// The agent this invite was issued to. Must match the joining key.
    pub issued_to: AgentPubKey,
    /// The issuer's signature over `issued_to`.
    pub signature: Signature,
}

/// DNA properties carrying the key allowed to issue invites.
/// Same mechanism as `progenitor.md`; see there for deploy-time injection.
#[derive(Serialize, Deserialize, Debug, SerializedBytes)]
pub struct DnaProperties {
    pub progenitor_pubkey: Option<String>,
}

#[hdk_extern]
pub fn genesis_self_check(data: GenesisSelfCheckData) -> ExternResult<ValidateCallbackResult> {
    let Some(proof) = data.membrane_proof else {
        return Ok(ValidateCallbackResult::Invalid(
            "this network requires an invite code".into(),
        ));
    };

    let invite: InviteCode = SerializedBytes::from(proof.as_ref().clone())
        .try_into()
        .map_err(|e| wasm_error!(WasmErrorInner::Guest(
            format!("membrane proof is not an InviteCode: {e:?}")
        )))?;

    // The invite must name the agent presenting it, or it is someone else's.
    if invite.issued_to != data.agent_key {
        return Ok(ValidateCallbackResult::Invalid(
            "invite was issued to a different agent".into(),
        ));
    }

    // GenesisSelfCheckDataV2 drops dna_info, so read it here.
    let props: DnaProperties = dna_info()?
        .modifiers
        .properties
        .try_into()
        .map_err(|e| wasm_error!(WasmErrorInner::Guest(
            format!("failed to deserialize DnaProperties: {e:?}")
        )))?;

    let Some(issuer_b64) = props.progenitor_pubkey else {
        // No issuer configured: bootstrap or dev mode, nothing to check against.
        return Ok(ValidateCallbackResult::Valid);
    };
    let issuer = AgentPubKey::try_from(issuer_b64)
        .map_err(|e| wasm_error!(WasmErrorInner::Guest(format!("bad progenitor key: {e:?}"))))?;

    // Signature verification is deterministic, so it is legal here and in validate().
    if !verify_signature(issuer, invite.signature.clone(), invite.issued_to.clone())? {
        return Ok(ValidateCallbackResult::Invalid(
            "invite signature does not verify against the issuer".into(),
        ));
    }

    Ok(ValidateCallbackResult::Valid)
}
}

Put this rule in a function both genesis_self_check and validate_agent_joining call, rather than writing it twice. The two callbacks receive the same two inputs, an AgentPubKey and an Option<MembraneProof>, precisely so the rule can be shared.

verify_signature(key, signature, data) serializes data canonically before verifying, so the issuer must have signed the AgentPubKey itself, not its bytes. Use verify_signature_raw if you signed raw bytes. See cryptography.md.

Three things worth knowing.

The data struct is deliberately small. The current version is V2, and it dropped the dna_info field the V1 version carried:

#![allow(unused)]
fn main() {
/// DnaInfo can be read with a call to `dna_info` within the self check
/// callback, it is elided here to minimise/stabilise the callback signature.
pub struct GenesisSelfCheckDataV2 {
    pub membrane_proof: Option<MembraneProof>,
    pub agent_key: AgentPubKey,
}
}

GenesisSelfCheckData is a type alias for V2. Call dna_info() inside the callback when you need DNA properties, which is the usual way to reach a progenitor key or a network policy. See progenitor.md.

The extern name on the wire is genesis_self_check_2. hdi’s map_extern! rewrites genesis_self_check to genesis_self_check_2 for you. You write the plain name; do not hand-roll the mangled one.

The return type is checked at compile time. hdk_derive 0.7.0 treats genesis_self_check exactly like validate: it must return ExternResult<ValidateCallbackResult>, or ValidateCallbackResult when marked #[hdk_extern(infallible)]. Any other return type aborts the macro expansion with an error naming the required type.

Enforcing it in validation

The self-check runs on the joiner’s own machine, so a modified conductor skips it. The check that binds runs in validate(), on other people’s machines.

There are two places to put it, and a scaffolded project already has one of them wired.

The generated hook, which you probably already have

hc scaffold emits a validate_agent_joining stub and calls it from the agent-activity arm. Look before you write a new one:

#![allow(unused)]
fn main() {
// Generated by hc scaffold, returning Valid until you fill it in.
pub fn validate_agent_joining(
    _agent_pub_key: AgentPubKey,
    _membrane_proof: &Option<MembraneProof>,
) -> ExternResult<ValidateCallbackResult> {
    Ok(ValidateCallbackResult::Valid)
}
}

and, inside validate():

#![allow(unused)]
fn main() {
FlatOp::AgentActivity(OpActivity::CreateAgent { agent, action }) => {
    let prev = action
        .prev_action()
        .ok_or_else(|| wasm_error!(WasmErrorInner::Guest("expected a prior action".into())))?
        .clone();
    let previous_action = must_get_action(prev)?;
    match &previous_action.action().data {
        ActionData::AgentValidationPkg(AgentValidationPkgData { membrane_proof, .. }) => {
            validate_agent_joining(agent, membrane_proof)
        }
        _ => Ok(ValidateCallbackResult::Invalid(
            "The previous action for a `CreateAgent` action must be an `AgentValidationPkg`".to_string(),
        )),
    }
}
}

This hangs the check on CreateAgent, the record that actually adds the agent key, and walks backwards one action with must_get_action to reach the proof. On a scaffolded project, filling in validate_agent_joining is the whole job.

The direct hook

The AgentValidationPkg record itself carries the proof with no back-reference:

#![allow(unused)]
fn main() {
FlatOp::CreateRecord(OpRecord::AgentValidationPkg { membrane_proof, action }) => {
    validate_membrane(membrane_proof, action.author())
}
}
#![allow(unused)]
fn main() {
AgentValidationPkg {
    /// The membrane proof proving that the agent is allowed to participate in this DNA.
    membrane_proof: Option<MembraneProof>,
    action: TypedAction<AgentValidationPkgData>,
}
}

TypedAction<D> is declared at hdi-0.8.0/src/flat_op/typed_action.rs:17 as { header: ActionHeader, data: D }, and re-exported by hdi::flat_op and so by the prelude. It is hdi’s own type, not one borrowed from holochain_integrity_types. Four accessors read the header directly: author(), timestamp(), action_seq() and prev_action(), so action.author() above is &AgentPubKey and needs no unwrapping. TypedAction<D> also derefs to D, so the per-variant fields are reachable without going through .data. The AgentValidationPkg variant itself is at hdi-0.8.0/src/flat_op/flat_op_record.rs:149.

Which to use

They are validated by different authorities, which is the whole difference:

Runs onReaching the proof
AgentActivity(OpActivity::CreateAgent)The agent-activity authority for the joining agentOne must_get_action back to the previous action
CreateRecord(OpRecord::AgentValidationPkg)The record authority for the validation-package actionHanded to you directly

Default to the generated one. It is what the scaffolder wires, it is where reviewers will look, and the agent-activity authority is the one already tracking that agent’s chain. Reach for the direct hook when you want the check to also run on the record authority, and put the shared rule in one function called from both rather than writing it twice.

Do not fill in the direct hook while leaving validate_agent_joining returning Valid. That reads like a membrane and is one enforcement point short of where a reviewer will look for it.

“There is access to network calls”: what the generated comment means

The scaffolder puts this directly above the stub, and read alone it sounds like validation can do anything:

#![allow(unused)]
fn main() {
// Validation the network performs when you try to join, you can't perform this
// validation yourself as you are not a member yet.
// There *is* access to network calls in this function
}

It is true, and it does not mean what it looks like. The contrast it is drawing is with genesis_self_check, which runs before the agent has joined and where nothing can be fetched, not with determinism.

The precise fact, checkable in one command: hdi 0.8.0 exports no get() and no get_links() at all. The entire network surface available to an integrity crate is the must_get_* family: must_get_entry, must_get_action, must_get_valid_record, must_get_agent_activity. There is nothing non-deterministic to reach for, which is why the comment can promise network access without qualifying it.

Those functions are deterministic in the sense that matters, and the HDI says why in must_get_entry’s own documentation: it “is available in contexts such as validation where both determinism and network access is desirable”, and when a dependency cannot be found, “callbacks will return early with UnresolvedDependencies”. A missing dependency defers the validation rather than failing it. That is what makes a network read safe here: every validator eventually sees the same data and reaches the same verdict, or none of them decides yet.

So, concretely:

Want toAllowed
Verify a signature over the proofYes. Pure computation
Compare against a key in dna_info().modifiers.propertiesYes. Identical for every agent, part of the DNA hash
must_get_valid_record an invite the proof names by hashYes. This is the sanctioned way to reach DHT state
Check the proof against a list of issued codes you get_links()No. The function does not exist in hdi
Expire an invite using sys_time()No. Compare against the timestamp already in the action instead

If you want revocable invites, the honest design is a signed capability with a short expiry, reissued out of band, not a lookup at genesis. Revocation by DHT state is the thing this model does not give you.

Testing a membrane: the Sweettest wall

Read this before you add a membrane requirement to a project that has tests. The moment validate_agent_joining starts rejecting a None proof, every existing Sweettest that calls setup_app fails, and the obvious fix does not exist.

SweetConductor::install_app hardcodes the proof to None:

#![allow(unused)]
fn main() {
let dnas_with_proof: Vec<_> = dnas_with_roles.iter().map(|dr| (dr.to_owned(), None)).collect();
}

with an upstream comment sitting directly above it that says exactly what is missing:

#![allow(unused)]
fn main() {
// TODO: make this take a more flexible config for specifying things like
//       membrane proofs
}

install_app_with_manifest does the same. The one function that accepts Option<MembraneProof>, Conductor::install_app_minimal, is pub(crate) and feature-gated, so a downstream test crate cannot call it. Searching the entire sweettest module of holochain 0.7.0 for MembraneProof returns nothing.

So there is no direct way to hand a proof to setup_app. Two things that do work:

1. Keep the rule in a plain function and unit-test that. validate_agent_joining takes an AgentPubKey and an Option<MembraneProof> and returns an ExternResult<ValidateCallbackResult>. It needs no conductor. Most of the value is in testing that function directly with hand-built proofs, and it costs nothing.

2. Deferred memproofs, for a genuine end-to-end test. Every piece of this path is public in 0.7.0: app_manifest_from_dnas(dnas, clone_limit, memproofs_deferred, network_seed) is pub, SweetConductor::raw_handle() is pub, and Conductor::provide_memproofs(installed_app_id, MemproofMap) is pub. Build the manifest with deferred memproofs allowed, install it, then supply the map, then enable.

provide_memproofs does not start the app. It runs genesis and then sets the status to Disabled(NotStartedAfterProvidingMemproofs), verified at holochain-0.7.0/src/conductor/conductor.rs:1608. So the sequence is install, provide, then enable. Skip the enable and you get an installed app with a genesised chain that never runs, and the status is the only thing that tells you why.

Verified at the type level, not compiled. The three visibilities and signatures above were read from holochain 0.7.0 sources. The exact call sequence for wiring them together has not been compiled in this skill’s example hApp, unlike every other Rust example here. Treat it as a starting point and expect to adjust, and prefer approach 1 for routine coverage.

This gap is the reason to decide early whether a DNA has a membrane. Retrofitting one onto a project with an established test suite costs more than the validation rule suggests.

Supplying the proof at install time

Two routes, both from the admin API.

Straight away, per role. InstallAppRequest.roles_settings takes a RoleSettingsMap:

await admin.installApp({
  source: { type: "path", value: "./my-app.happ" },
  installed_app_id: "my-app",
  roles_settings: {
    my_role: {
      type: "provisioned",
      value: {
        membrane_proof: myProofBytes,   // Uint8Array
        modifiers: { network_seed: "cohort-2026" },
      },
    },
  },
});

RoleSettings is a two-variant union: { type: "provisioned", value: { membrane_proof?, modifiers? } } or { type: "use_existing", value: { cell_id } }.

Deferred, after install. Install without proofs, then let the UI collect them. The app sits in a distinct status until it gets them:

export type AppStatus =
  | { type: "disabled"; value: DisabledAppReason }
  | { type: "enabled" }
  | { type: "awaiting_memproofs" };

and the app websocket takes them:

await appClient.provideMemproofs({ my_role: proofBytes });

Then enable it. provideMemproofs genesises the cells and then disables the app, every time, by design. It is not an error path. Conductor::provide_memproofs sets AppStatus::Disabled(DisabledAppReason::NotStartedAfterProvidingMemproofs) unconditionally (holochain-0.7.0/src/conductor/conductor.rs:1608), and waits for an explicit enable:

await appClient.provideMemproofs({ my_role: proofBytes });
await admin.enableApp({ installed_app_id: "my-app" });   // required, not optional

The name not_started_after_providing_memproofs reads like a failure, and it is the normal outcome. It is distinct from { type: "user" } and { type: "never_started" }, and all three are worth distinguishing in a launcher UI, but only this one means “the conductor did its part and is waiting for you”.

ignore_genesis_failure on InstallAppRequest leaves an app installed with empty cells when genesis fails, instead of uninstalling it immediately. That is a diagnostic tool for exactly this class of bug, not a production setting.

Membranes are not access control

A membrane decides who may join the network at all. Once inside, every agent can read everything they are an authority for and write anything validation allows. Per-function permissions are capability grants, a different mechanism entirely: see access-control.md.

Nor is a membrane a secret. The proof is written to the joiner’s public source chain, where every validating authority reads it. Never put a shared secret in a membrane proof and expect it to stay secret.

  • progenitor.md for putting the issuing key in DNA properties
  • access-control.md for capability grants, which govern calls rather than joins
  • client.md for the admin API surface used above
  • patterns.md for the FlatOp and TypedAction<D> model these examples use

Cell Cloning

What Is Cell Cloning?

Cell cloning creates new network instances from the same DNA code by varying the DNA hash modifier (network seed or properties). Each clone is a separate DHT network — agents in clone A cannot directly see data in clone B even though they run identical code.

This is distinct from having multiple roles in a happ — cloning is for partitioning data within a single role.

When to Use Cloning

Use casePattern
Private group spaces (each group gets its own DHT)Clone per group
Time-bounded archives (one clone per year)Clone per time period
Community partitions (separate networks per community)Clone per community
Single shared network for all usersNo cloning — single provisioned cell

happ.yaml Setup

roles:
  - name: group_spaces
    provisioning:
      strategy: create
      deferred: false      # provisioned on install; clones created on demand
    dna:
      bundled: "./group_spaces.dna"
      modifiers:
        network_seed: ~
      clone_limit: 50      # allow up to 50 clones of this role

clone_limit must be set to enable cloning. If clone_limit: 0 (default), cloning is not permitted.

TypeScript Client — Creating a Clone

import { AppClient } from '@holochain/client';

// Create a new clone cell with a unique network seed:
const cloneCell = await appClient.createCloneCell({
  role_name: 'group_spaces',
  modifiers: {
    network_seed: `group-${groupId}`,  // unique seed = unique network
    properties: encode({ group_name: groupName }),
  },
  name: `Group: ${groupName}`,
});

const clonedCellId = cloneCell.cell_id;

Addressing Clone Cells

Clone cells use a composite role name format: "{role_name}.{clone_index}"

// First clone:   "group_spaces.0"
// Second clone:  "group_spaces.1"
// etc.

// Call a function on a specific clone:
const result = await appClient.callZome({
  cell_id: clonedCellId,   // or use role_name: "group_spaces.0"
  zome_name: 'group_spaces',
  fn_name: 'create_post',
  payload: { content: 'Hello group!' },
});

Enabling / Disabling Clones

// Disable a clone (data preserved, cell not running):
await appClient.disableCloneCell({ clone_cell_id: clonedCellId });

// Re-enable a previously disabled clone:
await appClient.enableCloneCell({ clone_cell_id: clonedCellId });

Key Constraints

  • The maximum number of clones is set by clone_limit in happ.yaml — plan capacity upfront
  • Each clone’s network seed must be unique — using the same seed creates the same network
  • Cloned cells share the same WASM binary but have separate source chains and DHTs
  • deferred: true is not required for clonable roles: a provisioned role can be cloned, as Volla Messages does in production with clone_limit: 100
  • deferred is in fact ignored on install. AppBundle::resolve_cell destructures Create { .. } without reading it, so the role is provisioned either way (holochain_types-0.7.0/src/app/app_bundle.rs)
  • Avoid strategy: clone_only. It is the one setting that leaves a role unprovisioned, and assembling AppInfo then reaches unimplemented!() in holochain_conductor_api-0.7.0/src/app_interface.rs (line 548), panicking the conductor

Reference: developer.holochain.org/build/cell-cloning/

Scheduled Functions

Running work on a timer inside a coordinator zome. Every shape below is taken from the shipped hdk 0.7.0 and holochain_zome_types 0.7.0 sources, not from recall.

The API

#![allow(unused)]
fn main() {
pub fn schedule(scheduled_fn: &str) -> ExternResult<()>
}

The only argument is the name of a schedulable function in the current zome. There is no cross-zome scheduling.

#![allow(unused)]
fn main() {
pub enum Schedule {
    /// Crontab syntax string. Survives a conductor reboot.
    Persisted(String),
    /// Runs once after this duration. Does not survive a reboot.
    Ephemeral(Duration),
}
}

Writing a scheduled function

A scheduled function is infallible. Its only input is the schedule that triggered it, and its only output is its next trigger. Use #[hdk_extern(infallible)].

#![allow(unused)]
fn main() {
use hdk::prelude::*;

#[hdk_extern(infallible)]
fn cleanup_expired(_previous: Option<Schedule>) -> Option<Schedule> {
    // do the work, swallow every error

    // ask to run again in five minutes, best effort
    Some(Schedule::Ephemeral(std::time::Duration::from_secs(300)))
}

#[hdk_extern]
pub fn start_cleanup(_: ()) -> ExternResult<()> {
    schedule("cleanup_expired")
}
}

The signature is fixed at Option<Schedule> -> Option<Schedule>. You cannot pass arguments in and you cannot return data out. That is deliberate: it removes the chance of a caller who merely holds a cap grant smuggling in data that the chain author would then execute as themselves.

The first invocation always receives None. Every later invocation receives whatever the previous invocation returned.

Persisted versus ephemeral

Persisted(crontab)Ephemeral(duration)
Survives conductor rebootYesNo
Survives an irrecoverable errorYesNo
RepeatsYes, per the crontabNo, one shot per return value
To keep the scheduleReturn the same crontab every timeReturn a new Ephemeral every time

A persisted function must keep returning the same crontab if it wants to keep its schedule. It may change it by returning a different crontab, an Ephemeral, or None to stop.

An invalid crontab, for example "*/0 * * * * * *", unschedules the function. So does a failed call.

A missed persisted trigger, because the conductor or the host was down, does not fire late. It is skipped and rescheduled for the next intended run time.

Ephemeral(Duration::ZERO) means “next scheduler tick”, not “immediately”.

Five rules that bite

  1. Scheduled functions always run as the author of the chain they run for. The provenance of whoever called schedule() is gone the moment that zome call returns. Put your cap grant check in front of the schedule() call, never inside the scheduled function, because by then there is no caller to check.

  2. Scheduling is idempotent. Calling schedule() on an already-scheduled function is a noop, and the existing schedule wins. If the function is not currently scheduled, it is queued for the next scheduler iteration even if it recently returned None.

  3. Do not depend on the loop frequency. The conductor’s scheduler loop has historically ranged from 100ms to 10s and may become configurable. Anything that needs precise timing does not belong in the scheduler.

  4. Assume a malicious agent can trigger your function at the wrong time. Write the body defensively: check the current time window yourself, and noop, delay, or terminate if you were triggered outside it.

  5. init is lazy. It does not run until some other zome call runs for the first time after installation. Scheduling from init is allowed, but the schedule may start late or never if nobody calls the cell.

Errors

#![allow(unused)]
fn main() {
pub enum ScheduleError {
    Cron(String),
    Timestamp(TimestampError),
}
}

Cron almost always means a malformed crontab string. Parse failures unschedule the function rather than retrying.

When not to use the scheduler

Scheduled functions write to the calling agent’s own source chain as that agent. If the work is “react to something another agent did”, a remote signal plus post_commit is usually the better shape. See patterns.md for signals and access-control.md for the cap grant that recv_remote_signal needs.

Countersigning

Two or more agents committing the same entry atomically, each to their own source chain. Every type and signature below is taken from the shipped hdk 0.7.0 and holochain_integrity_types 0.7.0 sources.

Countersigning is still unstable in 0.7

It sits behind a Cargo feature that is off by default:

# in your coordinator zome
hdk = { version = "=0.7.0", features = ["unstable-countersigning"] }

The conductor must be built with it too. holochain 0.7.0 declares:

unstable-countersigning = [
    "hdk/unstable-countersigning",
    "holochain_zome_types/unstable-countersigning",
    "holochain_conductor_api/unstable-countersigning",
]

Default features on holochain 0.7.0 are encryption, schema, wasmer-sys-cranelift. Countersigning is not among them.

What this means in practice. A stock conductor binary, including the one holonix installs and the one Kangaroo bundles, does not have countersigning compiled in. If your hApp needs it you are building and shipping your own conductor. Treat that as an architectural commitment, not a feature flag you flip late. Design an alternative first and reach for countersigning only when nothing else gives you the atomicity you need.

The session shape

A countersigning session has one initiator and N signers. It runs in three beats.

  1. The initiator builds a PreflightRequest and distributes it to every signer, usually by remote call.
  2. Each signer calls accept_countersigning_preflight_request, which freezes that signer’s source chain until the session ends. Each returns a PreflightRequestAcceptance to the initiator.
  3. With every acceptance in hand, the initiator builds the entry and everyone commits it.

The chain freeze in step 2 is the whole point and the whole danger. Between accepting and resolving, that agent can commit nothing else.

PreflightRequest

#![allow(unused)]
fn main() {
pub struct PreflightRequest {
    /// Hash of the app entry as if it were not countersigned.
    /// The final entry hash will include the countersigning session.
    pub app_entry_hash: EntryHash,
    /// The agents participating in this session.
    pub signing_agents: CounterSigningAgents,
    /// Optional additional M of N signers.
    pub optional_signing_agents: CounterSigningAgents,
    /// The M in M of N. Must be strictly greater than N / 2 and not larger than N.
    pub minimum_optional_signing_agents: u8,
    /// If true, the first signing agent (index 0) acts as an enzyme.
    pub enzymatic: bool,
    /// Bounds the session in time. All session actions share one timestamp.
    pub session_times: CounterSigningSessionTimes,
    /// Action information shared by all agents. Depends on the action type.
    pub action_base: ActionBase,
    /// Arbitrary application bytes carried through the preflight.
    pub preflight_bytes: PreflightBytes,
}
}

Build it with the fallible constructor, never by struct literal. try_new runs check_integrity() for you and returns Result<Self, CounterSigningError>:

#![allow(unused)]
fn main() {
PreflightRequest::try_new(
    app_entry_hash,
    signing_agents,
    optional_signing_agents,
    minimum_optional_signing_agents,
    enzymatic,
    session_times,
    action_base,
    preflight_bytes,
)?
}

Two constraints the constructor enforces and that are easy to get wrong:

  • If there are optional signers, M must be the majority of N: strictly greater than N / 2, and not larger than N.
  • If there are optional signers, the enzyme must be used, and it must be the first agent in both signing_agents and optional_signing_agents.

Session times

#![allow(unused)]
fn main() {
pub fn session_times_from_millis(ms: u64) -> ExternResult<CounterSigningSessionTimes>
}

Starts the session at the initiator’s “now” and ends it ms milliseconds later. Every signer checks these times while accepting, so:

  • System clocks across participants must be roughly aligned.
  • The window must comfortably exceed the ambient network round trip for the whole group.

Too short and honest signers get UnacceptableFutureStart or simply miss the window. Too long and every participant’s chain stays frozen for that long.

Accepting

#![allow(unused)]
fn main() {
pub fn accept_countersigning_preflight_request(
    preflight_request: PreflightRequest,
) -> ExternResult<PreflightRequestAcceptance>
}

This must be called by every signer. How you distribute the request is up to you; concurrent remote calls are the simplest mechanism that fits inside a session timeout.

Handle all five outcomes:

#![allow(unused)]
fn main() {
pub enum PreflightRequestAcceptance {
    /// Accepted. Send the response back to the initiator.
    Accepted(PreflightResponse),
    /// Start time is too far in the future for this agent.
    UnacceptableFutureStart,
    /// The request does not include this agent.
    UnacceptableAgentNotFound,
    /// Not checked: another session is already in progress on this chain.
    AnotherSessionIsInProgress,
    /// Failed an integrity check.
    Invalid(String),
}
}

AnotherSessionIsInProgress is the one people forget. An agent can only be in one countersigning session at a time, so any design where a single agent is a hot spot, for example one marketplace operator countersigning every trade, will serialize and then fail under load.

Failure surface

ZomeCallResponse carries a dedicated variant for this:

#![allow(unused)]
fn main() {
ZomeCallResponse::CountersigningSession(String)
}

Match it explicitly rather than folding it into a wildcard, since it means “the session failed to start”, which is operationally different from a network error.

Design checklist before you commit to countersigning

  • Can the invariant be expressed as validation on two independent entries instead? That needs no feature flag and no chain freeze.
  • Are you prepared to build and distribute a custom conductor with unstable-countersigning enabled?
  • Is any single agent a participant in a high proportion of sessions? If so, AnotherSessionIsInProgress is your throughput ceiling.
  • Is the session window long enough for your worst realistic latency and short enough that a stalled peer does not freeze chains for minutes?
  • Does every signer handle all five PreflightRequestAcceptance variants, including the two that mean “retry later” rather than “fail”?
  • If you use optional signers, does M satisfy the strict majority rule, and is the enzyme first in both agent lists?
  • access-control.md for the cap grants the remote calls that distribute preflight requests will need.
  • patterns.md for ordinary entry commit and validation, which is where most “atomic” requirements should land instead.

Signing and Encryption

App-level cryptography in a zome. Every signature below is taken from the shipped hdk 0.7.0 and hdi 0.8.0 sources, not from recall.

Holochain already signs every action for you. This page is about the cases where that is not enough: proving authorship of something that is not an action, and keeping content confidential from the DHT.

Signing (coordinator: hdk::ed25519)

#![allow(unused)]
fn main() {
pub fn sign<K, D>(key: K, data: D) -> ExternResult<Signature>
pub fn sign_raw<K>(key: K, data: Vec<u8>) -> ExternResult<Signature>
pub fn sign_ephemeral<D>(datas: Vec<D>) -> ExternResult<EphemeralSignatures>
pub fn sign_ephemeral_raw(datas: Vec<Vec<u8>>) -> ExternResult<EphemeralSignatures>
}

sign is a serde convenience over sign_raw. Both sign with the private key held in lair for the public key you pass, so they only work for a key this conductor actually holds. If you do not have the private half, you cannot sign.

#![allow(unused)]
fn main() {
#[hdk_extern]
pub fn sign_offer(offer: Offer) -> ExternResult<Signature> {
    let me = agent_info()?.agent_initial_pubkey;
    sign(me, offer)
}
}

sign_ephemeral signs N items with a freshly generated key whose private half is discarded immediately after signing. Signatures come back pairwise ordered with the inputs. This is a primitive, not a pattern: it only means something inside a cryptographic scheme you have designed. If you cannot say what the discarded key proves, you do not want this function.

Verifying (integrity: hdi::ed25519)

#![allow(unused)]
fn main() {
pub fn verify_signature<K, S, D>(key: K, signature: S, data: D) -> ExternResult<bool>
pub fn verify_signature_raw<K, S>(key: K, signature: S, data: Vec<u8>) -> ExternResult<bool>
}

These live in hdi, so they are callable from validate(). They are pure: no DHT read, no clock. That makes “this entry carries a valid signature from the agent it names” one of the few cross-agent claims you can actually enforce in validation. See patterns.md for why most other cross-agent checks cannot be.

Encryption (coordinator: hdk::x_salsa20_poly1305)

Anything you commit as a public entry is readable by every DHT authority that holds it. Private entries stay on your own source chain but are not encrypted for anyone else. If you need a specific other agent to read something and nobody else, you encrypt it yourself.

Two shapes, matching libsodium’s two:

secretbox, one shared key. Anyone holding the shared secret can decrypt.

#![allow(unused)]
fn main() {
pub fn x_salsa20_poly1305_shared_secret_create_random(
    /* ... */
) -> ExternResult<XSalsa20Poly1305KeyRef>
pub fn x_salsa20_poly1305_shared_secret_export( /* ... */ )
pub fn x_salsa20_poly1305_shared_secret_ingest( /* ... */ )
pub fn x_salsa20_poly1305_encrypt(
    key_ref: XSalsa20Poly1305KeyRef,
    data: XSalsa20Poly1305Data,
) -> ExternResult<XSalsa20Poly1305EncryptedData>
}

The secret never leaves the keystore. You hold a XSalsa20Poly1305KeyRef, not key bytes. export wraps the secret with the box algorithm so you can hand it to a specific peer, and that peer calls ingest to store it in their own keystore.

box, two keypairs. Only the named recipient can decrypt.

#![allow(unused)]
fn main() {
pub fn create_x25519_keypair() -> ExternResult<X25519PubKey>
pub fn x_25519_x_salsa20_poly1305_encrypt(
    sender: X25519PubKey,
    recipient: X25519PubKey,
    data: XSalsa20Poly1305Data,
) -> ExternResult<XSalsa20Poly1305EncryptedData>
}

create_x25519_keypair generates in lair and returns only the public half. The secret never leaves lair.

There is also a convenience that converts ed25519 signing keys into x25519 encryption keys, so you can encrypt straight to an AgentPubKey:

#![allow(unused)]
fn main() {
pub fn ed_25519_x_salsa20_poly1305_encrypt(
    sender: AgentPubKey,
    recipient: AgentPubKey,
    data: XSalsa20Poly1305Data,
) -> ExternResult<XSalsa20Poly1305EncryptedData>
}

Its own doc comment carries a warning: understand the downsides of reusing a signing key for encryption before you use it. See https://doc.libsodium.org/advanced/ed25519-curve25519. Prefer a dedicated x25519 keypair unless you have a specific reason not to.

On decryption. The hdk 0.7.0 x_salsa20_poly1305 module exports the encrypt side and the shared-secret lifecycle listed above. Its doc comments reference decryption, but no x_salsa20_poly1305_decrypt appears in the module’s public surface at that version. Check the crate you are actually building against before designing a round trip, and if you need it and it is missing, decrypt client-side instead of in the zome.

What none of this gives you

  • Encryption is not access control. A DHT authority still stores and gossips your ciphertext, sees its size, sees who authored it and when, and sees the link graph around it. Metadata leaks even when content does not.
  • Signing is not identity. A signature proves control of a key at some moment. Binding a key to a person is a separate problem, and 0.7 does not solve it in the SDK. See the DeepKey note in architecture.md.
  • Validation cannot decrypt. validate() is pure and has no keystore access, so it can check a signature but never inspect encrypted content. Design your invariants around what stays in the clear.
  • access-control.md for capability grants, which govern who may call your functions rather than who may read your data.
  • patterns.md for private entries, which keep data off the DHT entirely and are usually the simpler answer.

Holochain Error Handling

The Pattern: thiserror + WasmError

Every domain should have a typed error enum in the utils (or domain-specific) crate using thiserror. This prevents stringly-typed errors and gives callsites exhaustive match coverage.


Error Enum Definition (utils/src/errors.rs)

#![allow(unused)]
fn main() {
use hdk::prelude::*;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum MyDomainError {
    #[error("Entry not found: {0}")]
    NotFound(String),

    #[error("Agent is not authorized to perform this action")]
    NotAuthorized,

    #[error("Cannot update entry with status: {0}")]
    CannotUpdateArchived(String),

    #[error("Cannot delete entry with status: {0}")]
    CannotDeleteNonActive(String),

    #[error("Serialization error: {0}")]
    SerializationError(String),

    #[error("Cross-zome call failed: {0}")]
    CrossZomeCallFailed(String),

    #[error("Invalid input: {0}")]
    InvalidInput(String),
}

// THE critical conversion — maps your typed error to WasmError
impl From<MyDomainError> for WasmError {
    fn from(err: MyDomainError) -> WasmError {
        wasm_error!(WasmErrorInner::Guest(err.to_string()))
    }
}
}

ExternResult and the ? Operator

All public zome functions return ExternResult<T>. The ? operator works throughout because:

  1. From<MyDomainError> for WasmError is implemented (above)
  2. WasmError implements Into<ExternResult> via HDK
#![allow(unused)]
fn main() {
pub fn update_my_entry(
    original_hash: ActionHash,
    previous_hash: ActionHash,
    entry: MyEntry,
) -> ExternResult<Record> {
    // ? works on both MyDomainError and other ExternResult operations
    let record = get(original_hash.clone(), GetOptions::default())?
        .ok_or(MyDomainError::NotFound(original_hash.to_string()))?;

    let agent = agent_info()?.agent_initial_pubkey;
    if record.action().author() != &agent {
        return Err(MyDomainError::NotAuthorized.into());
    }

    let updated = update_entry(previous_hash, &EntryTypes::MyEntry(entry))?;
    let result = get(updated, GetOptions::default())?
        .ok_or(MyDomainError::NotFound("Updated entry".into()))?;

    Ok(result)
}
}

Ad-Hoc Errors (without thiserror)

For simple one-off error cases, use wasm_error! directly:

#![allow(unused)]
fn main() {
// Simple guest error — no dedicated type needed
return Err(wasm_error!(WasmErrorInner::Guest("Expected app entry type".into())));

// Wrapping serialization failures
let entry: MyEntry = record.entry()
    .to_app_option()
    .map_err(|e| wasm_error!(WasmErrorInner::Guest(format!("Deserialization failed: {e}"))))?
    .ok_or(wasm_error!(WasmErrorInner::Guest("Entry is not MyEntry type".into())))?;
}

When to use ad-hoc vs. typed:

  • Ad-hoc: one-off cases in coordinators, unlikely to be matched by callers
  • Typed enum: domain errors that cross-zome callers need to inspect or that validators need

Common Error Variants Checklist

When defining a domain error enum, cover these cases:

VariantWhen to use
NotFound(String)DHT get returns None after expected create
NotAuthorizedAuthor check fails — agent is not the entry creator
CannotUpdateArchived(String)Status guard on update — entry is archived/deleted
CannotDeleteNonActive(String)Status guard on delete
SerializationError(String)to_app_option() or decode() failure
CrossZomeCallFailed(String)external_local_call returns error variant
InvalidInput(String)Validation-style check in coordinator (before HDK calls)
EntryTypeMismatchRetrieved entry is wrong type

Validation Error Handling (Integrity)

Validation functions return ValidateCallbackResult, not ExternResult:

#![allow(unused)]
fn main() {
fn validate_create_my_entry(entry: MyEntry) -> ExternResult<ValidateCallbackResult> {
    if entry.title.trim().is_empty() {
        // Invalid — data is rejected, not a runtime error
        return Ok(ValidateCallbackResult::Invalid(
            "MyEntry title cannot be empty".into()
        ));
    }

    if entry.title.len() > 200 {
        return Ok(ValidateCallbackResult::Invalid(
            "MyEntry title exceeds 200 characters".into()
        ));
    }

    Ok(ValidateCallbackResult::Valid)
}
}

Cargo.toml Setup for thiserror

In utils/Cargo.toml:

[dependencies]
hdk = { workspace = true }
thiserror = { workspace = true }

In workspace Cargo.toml:

[workspace.dependencies]
thiserror = "1"

Source Chain, Introspection and Utility Host Functions

Read from hdk 0.7.0 (src/chain.rs, src/info.rs, src/time.rs, src/random.rs, src/validation_receipt.rs), hdi 0.8.0 (src/info.rs) and holochain_zome_types 0.7.0 (src/query.rs, src/info.rs, src/validate.rs).

This file covers reading your own chain and the ambient facts a zome call can see. Reading another agent’s chain activity is get_agent_activity(), which lives in patterns.md alongside must_get_agent_activity and chain forks.

query(): your own chain

#![allow(unused)]
fn main() {
pub fn query(filter: ChainQueryFilter) -> ExternResult<Vec<Record>>
}

Coordinator zomes only, and it reads the calling agent’s source chain, never anyone else’s. This is how you answer “what have I written” without maintaining an agent-to-entry link index for your own data.

#![allow(unused)]
fn main() {
use hdk::prelude::*;

#[hdk_extern]
pub fn my_recent_posts(_: ()) -> ExternResult<Vec<Record>> {
    query(
        ChainQueryFilter::new()
            .entry_type(UnitEntryTypes::Post.try_into()?)
            .include_entries(true)
            .descending(),
    )
}
}

ChainQueryFilter

Six fields, all defaulted, so ChainQueryFilter::new() means “every record, ascending, no entries”.

FieldTypeMeaning
sequence_rangeChainQueryFilterRangeWhich slice of the chain. Default Unbounded
entry_typeOption<Vec<EntryType>>Keep only these entry types
entry_hashesOption<HashSet<EntryHash>>Keep only these entry hashes
action_typeOption<Vec<ActionType>>Keep only these action types
include_entriesboolLoad entry content, not just actions. Default false
order_descendingboolDefault is ascending. .descending() flips it

Building the entry_type argument. #[hdk_entry_types] expands to include hdk_entry_types_name_registration, which generates both impl TryFrom<UnitEntryTypes> for EntryType and impl TryFrom<UnitEntryTypes> for AppEntryDef. Both impls are emitted by hdk_derive, in hdk_derive-0.7.0/src/entry_types_name_registration.rs (the EntryType one at line 184), not by hdi. They exist only in expanded code, so grepping hdi for them finds nothing. Either form works, and the target type decides which impl is used:

#![allow(unused)]
fn main() {
.entry_type(UnitEntryTypes::Post.try_into()?)                    // -> EntryType
.entry_type(EntryType::App(UnitEntryTypes::Post.try_into()?))    // -> AppEntryDef, then wrapped
}

Prefer the first. Both compile; the second only spells out what the first infers.

Builder methods: sequence_range, entry_type, entry_hashes, action_type, include_entries, ascending, descending. There are also in-memory helpers for when you already hold records: filter_records, filter_actions and disambiguate_forks.

Opening a Record

query() hands back Vec<Record>, and a Record is a signed action plus its entry slot. To report what kind of thing each record is, you go through the action, not the entry.

#![allow(unused)]
fn main() {
pub struct Record {
    pub signed_action: SignedHashed<Action>,
    pub entry: RecordEntry<Entry>,
}
}

Four accessors cover nearly every use (holochain_integrity_types-0.7.0/src/record.rs:252-290):

CallReturnsUse
record.action()&ActionThe action content, { header, data }
record.action_address()&ActionHashThis record’s own hash
record.action_hashed()&HoloHashed<Action>Content and hash together
record.entry()&RecordEntry<Entry>Present, Hidden, NA or NotStored

record.action() returns &Action, not a SignedHashed. The .hashed.content path belongs to SignedHashed<Action>, so it applies to record.signed_action, never to the result of record.action(). record.action().hashed does not compile. That mistake is easy to make by analogy with Sweettest code, which reaches for record.signed_action.hashed.hash because it holds the whole record rather than an accessor.

An Action in 0.7 is { header: ActionHeader, data: ActionData } (src/action.rs:532). The header carries author, timestamp, action_seq and prev_action for every variant; data carries the per-variant payload. Match on data to discriminate:

#![allow(unused)]
fn main() {
use hdk::prelude::*;

#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "kind")]
pub enum MyActivity {
    Wrote { action_hash: ActionHash, timestamp: Timestamp },
    Revised { action_hash: ActionHash, replaces: ActionHash },
    Removed { deletes: ActionHash },
    Linked { base: AnyLinkableHash, target: AnyLinkableHash },
}

#[hdk_extern]
pub fn my_activity(_: ()) -> ExternResult<Vec<MyActivity>> {
    let records = query(ChainQueryFilter::new().descending())?;

    Ok(records
        .iter()
        .filter_map(|record| {
            let action = record.action();
            let hash = record.action_address().clone();
            match &action.data {
                ActionData::Create(_) => Some(MyActivity::Wrote {
                    action_hash: hash,
                    timestamp: action.header.timestamp,
                }),
                ActionData::Update(update) => Some(MyActivity::Revised {
                    action_hash: hash,
                    replaces: update.original_action_address.clone(),
                }),
                ActionData::Delete(delete) => Some(MyActivity::Removed {
                    deletes: delete.deletes_address.clone(),
                }),
                ActionData::CreateLink(link) => Some(MyActivity::Linked {
                    base: link.base_address.clone(),
                    target: link.target_address.clone(),
                }),
                _ => None,
            }
        })
        .collect())
}
}

ActionData has ten variants (src/action.rs:476): Dna, AgentValidationPkg, InitZomesComplete, Create, Update, Delete, CreateLink, DeleteLink, CloseChain, OpenChain. When a label is all you need, action.data.action_type() returns the ActionType discriminant without a match.

Field names, read from src/action.rs rather than inferred:

VariantFields
CreateDataentry_type, entry_hash
UpdateDataoriginal_action_address, original_entry_address, entry_type, entry_hash
DeleteDatadeletes_address, deletes_entry_address
CreateLinkDatabase_address, target_address, zome_index, link_type, tag
DeleteLinkDatabase_address, link_add_address

Link addresses are AnyLinkableHash, not EntryHash or ActionHash. Both ends of a CreateLink use it (holo_hash-0.7.0/src/aliases.rs), because a link may point at an entry or at an action. DeleteLinkData names only the CreateLink action it removes, so recovering which link type was deleted means matching its link_add_address against the CreateLink records in the same query result. Do not reach for get() to resolve it: that leaves the source chain and hits the network, which defeats the point of query().

query() returns history, not current state

This is the trap that makes query() look like a cheap replacement for link-following, and it is not one. The source chain is a log of what you wrote, in the order you wrote it. Nothing in it resolves an update chain or hides a deletion.

Updates. UpdateData carries its own entry_type and an entry_hash pointing at the new content, so an Update matches the same entry_type filter its Create did. A query filtered only by entry type therefore returns the original and every revision as separate records, in chain order, with nothing marking which supersedes which.

#![allow(unused)]
fn main() {
// "Posts I authored", original content only. An edited post returns its FIRST version.
.entry_type(UnitEntryTypes::Post.try_into()?)
.action_type(ActionType::Create)

// Every version of every post, original and revisions, as separate records.
.entry_type(UnitEntryTypes::Post.try_into()?)
}

Pick deliberately, and know which one you asked for. Neither is “my posts as they stand now”.

Deletions are worse, because they are silent. DeleteData holds only deletes_address and deletes_entry_address. It has no entry_type field, so a Delete action can never match an entry-type filter. A deleted post’s Create record still comes back, and the result carries no signal that a later action removed it.

So a chain query cannot, on its own, tell you that something was deleted. You would have to query ActionType::Delete separately and reconcile the addresses yourself.

If you need current state, follow the update links, as patterns.md describes. Use query() when you genuinely want authorship history: what this agent wrote, when, in what order. The source chain is a history, not a view.

include_entries(false) is the default for a reason. Actions are small and entries are not. If you only need hashes or timestamps, leave entries off.

ChainQueryFilterRange and the efficiency cliff

#![allow(unused)]
fn main() {
pub enum ChainQueryFilterRange {
    Unbounded,                              // default
    ActionSeqRange(u32, u32),               // inclusive start, inclusive end
    ActionHashRange(ActionHash, ActionHash),
    ActionHashTerminated(ActionHash, u32),  // this hash and N preceding records
}
}

The choice matters more than it looks, and the source spells out why:

  • Unbounded is equivalent to ActionSeqRange(0, u32::MAX).
  • ActionSeqRange is ambiguous over forked histories. If the chain forked, more than one record can share a sequence number and the filter has no way to pick one, so all matching records come back. The entry_type and action_type filters are applied inside the database query, which makes this variant reasonably efficient.
  • The hash-bounded variants resolve forks correctly, because naming a specific action hash names a specific branch. The cost: to do that, all relevant records must be loaded and the chain reconstructed in memory before any other filter is applied. The source says this “may be significantly less efficient than other query types”.

So: sequence ranges are fast and fork-ambiguous, hash ranges are fork-correct and slow. Pick deliberately. ActionHashTerminated(hash, 0) returns just that one record.

Cell introspection

Four host functions, each answering a different question. Two live in hdi (available in integrity code) and two in hdk (coordinator only).

FunctionCrateAnswers
dna_info()hdiWhich DNA am I, with what modifiers
zome_info()hdiWhich zome am I, with what types in scope
agent_info()hdkWho am I, and where is my chain head
call_info()hdkWho called me, how, and under what grant

dna_info() and zome_info()

#![allow(unused)]
fn main() {
pub struct DnaInfoV2 {
    pub name: String,
    pub hash: DnaHash,
    pub modifiers: DnaModifiers,   // network_seed, properties, origin_time...
    pub zome_names: Vec<ZomeName>,
}
pub type DnaInfo = DnaInfoV2;

pub struct ZomeInfo {
    pub name: ZomeName,
    pub id: ZomeIndex,
    pub properties: SerializedBytes,
    pub entry_defs: EntryDefs,
    pub extern_fns: Vec<FunctionName>,
    pub zome_types: ScopedZomeTypesSet,
}
}

dna_info().modifiers.properties is the deploy-time configuration channel: it is part of the DNA hash, identical for every agent, and readable from validation. That is what makes it the right home for a progenitor key. See progenitor.md, and migration.md for why init_properties is a different and non-interchangeable mechanism.

Both are callable from validate() and from genesis_self_check, because both are deterministic per DNA.

agent_info() and the scratch space trap

#![allow(unused)]
fn main() {
pub struct AgentInfo {
    /// The current agent's pubkey at genesis.
    /// Always found at index 2 in the source chain.
    pub agent_initial_pubkey: AgentPubKey,
    pub chain_head: (ActionHash, u32, Timestamp),
}
}

chain_head reflects the chain including uncommitted writes made earlier in this same zome call, because those live in the call’s scratch space. Call create_entry then agent_info() and the head has already moved, even though nothing has been persisted or published yet, and even though the call may still fail and roll everything back.

Never use agent_info()?.chain_head as a stable “where was I when this call started” marker. Use call_info()?.as_at for that.

agent_info() is a coordinator function. It is not callable from validate(): validation runs on other agents’ machines, where “who am I” is a different question with a non-deterministic answer.

call_info()

#![allow(unused)]
fn main() {
pub struct CallInfo {
    /// The provenance identifies the agent who made the call.
    /// This is the author of the chain for local calls, and the assignee of a capability for remote calls.
    pub provenance: AgentPubKey,
    pub function_name: FunctionName,
    /// Chain head as at the call start.
    /// This will not change within a call even if the chain is written to.
    pub as_at: (ActionHash, u32, Timestamp),
    pub cap_grant: CapGrant,
}
}

Two things here that nothing else gives you:

  • provenance is the caller, which for a call_remote is the remote agent, not you. This is the field to check when a function should behave differently for remote callers. Comparing provenance against agent_info()?.agent_initial_pubkey is the “was this called locally” test.
  • cap_grant is the grant that authorized this call. A function can inspect how it was reached, which is how you distinguish an unrestricted call from one that presented a specific secret. See access-control.md.

as_at is the honest chain head for the call: fixed at entry, unmoved by writes during the call.

Utility host functions

sys_time()

#![allow(unused)]
fn main() {
pub fn sys_time() -> ExternResult<Timestamp>
}

The host’s wall clock, in a coordinator zome. Forbidden in validation, along with everything else non-deterministic: a validator running your validate() next week must reach the same verdict as one running it now.

When an entry needs a timestamp that validation can check, do not put sys_time() in the entry. The action header already carries a timestamp, and validation can read it from action.header().timestamp.

random_bytes()

#![allow(unused)]
fn main() {
pub fn random_bytes(number_of_bytes: u32) -> ExternResult<Bytes>
}

Randomness from the host. Two caveats worth stating out loud: it is not seedable or repeatable, so nothing that consumes it can be replayed deterministically, and it is not usable in validation for the same reason as sys_time(). It is the right tool for a nonce or a cap secret, and the wrong tool for anything a validator must reproduce.

Tracing

hdk wires the standard tracing macros through to the host, so trace!, debug!, warn! and error! work inside wasm. Two limits from the HDK documentation:

  • Spans do not work. #[instrument] will likely panic your wasm.
  • Filtering is by the WASM_LOG environment variable, which behaves exactly like RUST_LOG does for the conductor.

See debugging.md for how to actually read that output.

Validation receipts

#![allow(unused)]
fn main() {
pub fn get_validation_receipts(
    input: GetValidationReceiptsInput,
) -> ExternResult<Vec<ValidationReceiptSet>>
}

After you author an action it becomes several DHT ops, each validated by other agents, each returning a signed receipt. This function reports what came back, grouped by op:

#![allow(unused)]
fn main() {
pub struct ValidationReceiptSet {
    pub op_hash: DhtOpHash,
    pub op_type: String,          // informational only
    pub receipts_complete: bool,  // did this op reach the required receipt count
    pub receipts: Vec<ValidationReceiptInfo>,
}

pub struct ValidationReceiptInfo {
    pub validation_status: ValidationStatus,
    pub validators: Vec<AgentPubKey>,
}
}

Usage, from the HDK’s own example:

#![allow(unused)]
fn main() {
let receipts = get_validation_receipts(GetValidationReceiptsInput::new(action_hash))?;
let count = receipts
    .into_iter()
    .filter(|set| set.op_type == "AgentActivity")
    .flat_map(|set| set.receipts)
    .count();
}

The constraint that decides whether you can use this: receipts only exist for actions authored on the same conductor. Not necessarily the same agent, but the same conductor. Asking about someone else’s action returns nothing, and that nothing is indistinguishable from “not validated yet”.

Practical use is a “your post has been seen by N validators” indicator, or a test that waits for receipts_complete instead of sleeping. It is the closest thing to a per-action propagation signal that a zome can get.

  • patterns.md for get_agent_activity, must_get_* and chain forks
  • debugging.md for reading trace output and conductor state
  • progenitor.md for the DNA-properties pattern that dna_info() serves
  • access-control.md for CapGrant and what call_info().cap_grant tells you

Networking and Conductor Configuration (0.7)

Everything here is read from holochain_conductor_api 0.7.0 (src/config/conductor.rs), holochain_p2p 0.7.0 (Cargo.toml) and kitsune2_bootstrap_srv 0.5.0. If a field is not listed here, check those sources rather than guessing: ConductorConfig is #[serde(deny_unknown_fields)], so an invented key is a startup error, not a silent no-op.

What changed in 0.7

The whole transport layer was replaced.

0.60.7
tx5 / WebRTC transport, optional transport-iroh featureiroh over QUIC, unconditional. grep tx5 holochain_p2p/Cargo.toml returns nothing
signal_url pointing at an SBD signal serverrelay_url pointing at an iroh relay
webrtc_configgone. Use advanced for direct Kitsune2 tuning
transport-iroh cargo feature on the holochain crateremoved. holochain_p2p enables kitsune2/transport-iroh and iroh-relay itself

Kitsune2 is at 0.5.0 for the whole 0.7.0 line: kitsune2_api, kitsune2_core and kitsune2_transport_iroh all pin 0.5.0.

Client-side, the per-connection is_webrtc flag became is_direct. See client.md.

Minimal conductor config

This is the example from the holochain_conductor_api module documentation, unedited:

---
## Configure the keystore to be used.
keystore:
  ## Use an in-process keystore with default database location.
  type: lair_server_in_proc

## Configure an admin WebSocket interface at a specific port.
admin_interfaces:
  - driver:
      type: websocket
      port: 1234
      allowed_origins: "*"

## Configure the network.
network:
  ## Use the Holochain-provided dev-test bootstrap server.
  bootstrap_url: https://dev-test-bootstrap2.holochain.org

  ## Use the iroh relay server.
  relay_url: https://use1-1.relay.n0.iroh-canary.iroh.link./

allowed_origins is not optional and not defaulted. A browser UI that gets no response from the admin or app port is usually being rejected on origin, not on port.

NetworkConfig, field by field

Serialized under network: in conductor-config.yaml, snake_case, unknown fields rejected.

FieldTypeDefaultWhat it does
bootstrap_urlURLhttps://dev-test-bootstrap2.holochain.orgThe Kitsune2 bootstrap server used for WAN peer discovery
relay_urlURLhttps://use1-1.relay.n0.iroh-canary.iroh.link./The iroh relay used when a direct connection cannot be established
base64_auth_material_bootstrapOption<String>noneAuth material if your bootstrap service requires it. Base64 url-safe, no padding
base64_auth_material_relayOption<String>noneSame, for the relay service
request_timeout_su6460Request/response roundtrip timeout, in seconds
target_arc_factoru321Multiplier applied to arc-size hints from Kitsune2
reportReportConfigNoneKitsune2 reporting output
advancedOption<JSON>noneRaw Kitsune2 module config. Every field above is sugar over a key in here

“Default” here means the Rust Default impl, not a serde default

bootstrap_url and relay_url are the two fields in the table with no #[serde(default)]. The distinction matters when you hand-write conductor-config.yaml:

  • Omit the whole network: block and you get every value in the table, because ConductorConfig marks the field #[serde(default)] and falls back to NetworkConfig::default().
  • Write a partial network: block that sets, say, only request_timeout_s, and startup fails with missing field bootstrap_url. Once the key is present, both URLs are required.

So a network: block is all-or-nothing on those two. Every other field in the table is individually optional.

The two defaults you should not ship with

dev-test-bootstrap2.holochain.org is named “dev-test” for a reason, and the default relay is an iroh canary host. Both are fine for development and neither is a production commitment by anyone. A hApp you distribute should point at infrastructure you or your community controls. See “Running your own bootstrap server” below.

request_timeout_s sets three timeouts, not one

Setting it derives two more values, per the field’s own documentation:

request_timeout_s              = 60   (what you set)
single transport message       = 30   floor(1/2 of request_timeout_s)
direct-connection attempt      = 22   floor(3/8 of request_timeout_s), before falling back to relay

So halving the request timeout also halves how long a peer waits before giving up on a direct connection and paying the relay’s latency. Tune it as one number with three consequences.

The 0.7 source still calls the third value the “webrtc connection” timeout in its doc comment. That is leftover wording from the tx5 era. The mechanism is iroh’s direct-path attempt before relay fallback.

target_arc_factor and leecher nodes

1 is normal operation: honour Kitsune2’s arc-size hints as given. Set it to 0 for a node that should not contribute to gossip at all, which the field documentation calls a “leecher node”.

A zero-arc node still reads from the DHT, but it stores nothing on behalf of the network. That is a legitimate configuration for a mobile or ephemeral client, and a bad one for the majority of a network’s nodes: if everyone leeches, nobody holds the data.

report

network:
  report:
    type: json_lines
    days_retained: 7
    fetched_op_interval_s: 60

Two variants only: None (the default, no reporting) and JsonLines { days_retained, fetched_op_interval_s }. Useful when diagnosing gossip behaviour over hours rather than seconds.

advanced

Direct Kitsune2 module configuration, as raw JSON. The named fields above are merged into it at startup: bootstrap_url becomes the serverUrl key of the core_bootstrap module, and so on. Anything you set directly in advanced for the same key is what the named field overwrites, so do not set both.

Use it only when you know which Kitsune2 module you are configuring. Module names seen in the 0.7 sources include k2Gossip (with keys like initiateIntervalMs) and the bootstrap module’s serverUrl.

Test-only network switches

disable_bootstrap, disable_publish and disable_gossip exist on NetworkConfig but are gated behind the test-utils cargo feature. They are how Sweettest builds isolated networks. They are not available on a stock conductor binary, and a config file naming them will fail to parse.

Running your own bootstrap server

The server is a standalone binary, kitsune2-bootstrap-srv, shipped by the kitsune2_bootstrap_srv crate (0.5.0 for the 0.7 line).

cargo install kitsune2_bootstrap_srv --version 0.5.0 --locked
kitsune2-bootstrap-srv --production --listen 0.0.0.0:443 --tls-cert cert.pem --tls-key key.pem

It runs in a testing configuration by default, with deliberately light resource settings, and switches to production settings only with --production:

SettingTestingProduction
--listen127.0.0.1:00.0.0.0:443 and [::]:443
--worker-thread-count24 x cpu count
--request-listen-duration-ms10ms2s
--prune-interval-ms10s60s
--max-entries-per-space3232

--tls-cert and --tls-key require each other, both PEM encoded. --allowed-origins defaults to allowing any origin. --json switches tracing output to JSON.

A bootstrap server holds nothing but agent info: it is how peers find each other, not where data lives. Losing it partitions new joiners, not existing peers who already know each other.

The relay is a separate concern. Holochain uses iroh’s relay protocol, so any iroh relay will do, including one you run yourself.

Other conductor config fields worth knowing

From ConductorConfig in the same file:

FieldNote
data_root_pathDatabases and compiled wasm live under here. Required by the time the config builds a conductor
keystorelair_server_in_proc for a self-contained conductor, or a separate lair server
admin_interfacesA list of { driver: { type: websocket, port, allowed_origins } }. Omit in production if nothing should manage the conductor remotely
wasm_backendOnly needed when more than one wasm backend is compiled in
db_sync_levelSQLite PRAGMA synchronous. Leave alone unless you have measured a reason
db_max_readersDefaults to twice the CPU count, minimum 8. Related to the authority-response concurrency limit; change both or neither
tracing_overrideOverrides the environment tracing config. See debugging.md
  • debugging.md for reading what the network is actually doing at runtime
  • client.md for dumpNetworkStats and dumpNetworkMetrics from TypeScript
  • deployment.md for wiring these values into a Kangaroo build

Holochain Scaffold

Prerequisites

Holochain development requires Nix for a reproducible development environment. All tooling (Rust, hc CLI, holochain, lair-keystore) is managed through Holonix.

Install Nix

# Official Nix installer (recommended)
sh <(curl -L https://nixos.org/nix/install) --no-daemon

# Or with Determinate Systems installer (more reliable, adds uninstaller)
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install

Enable flakes (required for Holonix):

# Add to ~/.config/nix/nix.conf (or /etc/nix/nix.conf):
experimental-features = nix-command flakes

Standard flake.nix (Holonix)

Pin to main-0.7 for HDK 0.7.x stability. The full template below matches what hc scaffold happ generates — it includes bun, nodejs_24, and binaryen which are needed for the JS test suite and WASM optimisation:

{
  description = "Flake for Holochain app development";

  inputs = {
    holonix.url = "github:holochain/holonix?ref=main-0.7";
    nixpkgs.follows = "holonix/nixpkgs";
    flake-parts.follows = "holonix/flake-parts";
  };

  outputs = inputs@{ flake-parts, ... }: flake-parts.lib.mkFlake { inherit inputs; } {
    systems = builtins.attrNames inputs.holonix.devShells;
    perSystem = { inputs', pkgs, ... }: {
      formatter = pkgs.nixpkgs-fmt;
      devShells.default = pkgs.mkShell {
        inputsFrom = [ inputs'.holonix.devShells.default ];
        packages = (with pkgs; [
          nodejs_24
          binaryen
          bun
        ]);
        shellHook = ''
          export PS1='\[\033[1;34m\][holonix:\w]\$\[\033[0m\] '
        '';
      };
    };
  };
}

Enter the dev shell:

nix develop
# hc, cargo, rustc, bun, and all Holochain tooling are now available

Why pin the branch? Holonix main now tracks the 0.8 dev line. main-0.7 pins all tooling to HDK 0.7.x compatibility. Mixing versions causes compilation failures.


hc Scaffold Commands

The hc scaffold CLI generates boilerplate that follows Holochain conventions. Always use it before writing by hand.

New hApp

# Create a complete new hApp project
hc scaffold happ
# Prompts for: app name, DNA name, coordinator zome name
# Generates: flake.nix, happ.yaml, dna.yaml, Cargo workspace, first zome pair

New DNA (for multi-DNA hApps)

# Add a new DNA to an existing hApp
hc scaffold dna
# Prompts for: DNA name
# Generates: dna.yaml, new zome pair stubs

New Zome Pair

# Add a coordinator/integrity zome pair to an existing DNA
hc scaffold zome
# Prompts for: zome name, DNA to add it to
# Generates: integrity crate + coordinator crate with Cargo.toml

Entry Type

# Add an entry type to an existing zome pair
hc scaffold entry-type MyEntry
# Generates: entry struct in integrity, create/get/update/delete stubs in coordinator
# Also generates a Sweettest test in the DNA's tests crate
# Add a link type
hc scaffold link-type AgentToMyEntry
# Generates: link type variant in integrity, create/get/delete helpers in coordinator

Collection

# Add a collection (global path anchor) for an entry type
hc scaffold collection
# Prompts for: entry type to index, collection type (global or by-agent)

Verify Compilation

After any scaffold operation, always verify the project compiles:

# Generate and verify WASM compilation
hc s sandbox generate workdir/

# Or using the build alias (if package.json scripts are set up)
bun run build

First build is slow (WASM compilation + wasm-opt). Subsequent builds use the Rust cache. Expect 2-5 minutes for a fresh build.


Project Structure After Scaffolding

This is the exact tree hc scaffold happ + hc scaffold entry-type produces:

my-happ/
├── flake.nix                     # Nix dev environment (Holonix + bun + nodejs)
├── Cargo.toml                    # Workspace root — glob members, exact version pins
├── Cargo.lock
├── package.json                  # Root workspace: build:zomes, build:happ, test scripts
├── .gitignore
├── workdir/
│   └── happ.yaml                 # hApp manifest (roles, DNA paths)
├── dnas/
│   └── my_dna/
│       ├── workdir/
│       │   └── dna.yaml          # DNA manifest (zome WASM paths)
│       └── zomes/
│           ├── integrity/
│           │   └── my_zome_integrity/
│           │       ├── Cargo.toml
│           │       └── src/
│           │           ├── lib.rs        # EntryTypes enum, LinkTypes enum, validate()
│           │           └── my_entry.rs   # Entry struct + per-op validation fns
│           └── coordinator/
│               └── my_zome/
│                   ├── Cargo.toml
│                   └── src/
│                       ├── lib.rs        # init(), Signal enum, post_commit, signal_action
│                       └── my_entry.rs   # create/get/update/delete + revision history
├── tests/                        # Sweettest suite (Rust)
│   ├── package.json
│   ├── vitest.config.ts
│   ├── tsconfig.json
│   └── src/my_dna/my_zome/
│       ├── common.ts
│       └── my_entry.test.ts
└── ui/                           # UI scaffold (svelte/vue/react depending on template)

Cargo Workspace Version Pins

Root Cargo.tomlhc scaffold happ generates glob members so new zome crates are picked up automatically. Always use exact version pins (=):

[profile.dev]
opt-level = "z"

[profile.release]
opt-level = "z"

[workspace]
members = ["dnas/*/zomes/coordinator/*", "dnas/*/zomes/integrity/*"]
resolver = "2"

[workspace.dependencies]
hdi = "=0.8.0"
hdk = "=0.7.0"
holochain_serialized_bytes = "*"
serde = "1.0"

# Per-crate workspace deps (one pair per zome):
[workspace.dependencies.my_zome]
path = "dnas/my_dna/zomes/coordinator/my_zome"

[workspace.dependencies.my_zome_integrity]
path = "dnas/my_dna/zomes/integrity/my_zome_integrity"

Why exact pins (=)? Holochain zome compilation is extremely sensitive to minor version differences. Range deps (^) can silently pull in incompatible patch releases.

Sweettest test crate is NOT picked up by the glob — add it explicitly:

[workspace]
members = [
    "dnas/*/zomes/coordinator/*",
    "dnas/*/zomes/integrity/*",
    "dnas/my_dna/tests",   # ← explicit
]

Add Domain to Existing Project

When adding a new feature domain to an existing hApp:

# 1. Enter Nix dev shell if not already in it
nix develop

# 2. Scaffold a new zome pair
hc scaffold zome
# Enter: domain name (e.g., "profiles"), select existing DNA

# 3. Scaffold entry types for the domain
hc scaffold entry-type Profile
hc scaffold link-type AgentToProfile
hc scaffold link-type PathToProfile
hc scaffold link-type ProfileUpdates

# 4. Add the new crates to workspace Cargo.toml members list

# 5. Verify compilation
hc s sandbox generate workdir/

Proceed to workflows/implement-zome.md to fill in the implementation.


Common Setup Issues

ProblemCauseFix
nix: command not foundNix not installed or not in PATHRestart shell after install; check ~/.nix-profile/bin in PATH
flakes not enabledMissing experimental-features configAdd experimental-features = nix-command flakes to ~/.config/nix/nix.conf
hc: command not found inside nix developWrong holonix branchCheck flake.nix ref — must be main-0.7, not main
wasm32 target not foundRust toolchain outside NixUse nix develop; don’t use system Rust for Holochain builds
First build hangs at wasm-optwasm-opt is slow on first runNormal — wait 5-10 min; subsequent builds are fast

Reference: developer.holochain.org/get-started/

Holochain Testing

Four-Layer Testing Strategy

┌──────────────────────────────────────────────────────────────────┐
│  Layer 4 — Performance (Wind-Tunnel, load testing)               │
│  "How fast, scalable, and resilient is this under load?"         │
├──────────────────────────────────────────────────────────────────┤
│  Layer 3 — E2E UI (Playwright + real conductor)                  │
│  "Does the UI render real data and journeys work?"               │
├──────────────────────────────────────────────────────────────────┤
│  Layer 2 — Integration (Sweettest, cargo test)                   │
│  "Do zomes, DHT sync, and validation work?"                      │
├──────────────────────────────────────────────────────────────────┤
│  Layer 1 — Unit (Vitest, stores/services/mappers)                │
│  "Do computed values and business logic work?"                   │
└──────────────────────────────────────────────────────────────────┘
LayerToolOutputWhat it catches
UnitVitestpass/failStore logic, mappers, computed values
IntegrationSweettestpass/failZome logic, validation, DHT sync, auth
E2E UIPlaywright + @holochain/clientpass/failFull user journeys, real data display
PerformanceWind-Tunnelmetrics (latency/throughput)Regressions under load, DHT sync lag, soak issues

Gap: Browser-side signal handling (recv_remote_signal) is not well covered by any layer — it requires a running UI receiving WebSocket push events from a real conductor.


Framework Overview

  • Sweettest (holochain::sweettest) — Rust-native, in-process conductor. Official Holochain team recommendation. Run with cargo test.
  • Playwright + @holochain/client — Browser automation against a real conductor. No mocks.
  • Wind-Tunnel (holochain_wind_tunnel_runner) — Rust load testing. Separate repo. Measures latency, throughput, DHT sync lag. Used for Holochain core CI performance regression. See wind-tunnel.md.

Note on Tryorama: The scaffolding tool no longer generates it, and Holochain no longer maintains it. It now lives at holochain-open-dev/tryorama as a community project. This skill targets Sweettest only. If you are maintaining a legacy suite, the community fork tracks 0.7; everything else here assumes you are not.


When to Use Which

Use CaseSweettestPlaywrightWind-Tunnel
Zome logic, validation, CRUD✅ PreferredNoNo
DHT propagation, consistency✅ PreferredNoNo
Multi-agent scenarios✅ PreferredNoNo
Inline zomes (no WASM compile)✅ YesNoNo
Direct DHT database inspection✅ YesNoNo
Full UI user journeysNo✅ YesNo
Real data rendered in browserNo✅ YesNo
Latency / throughput metricsNoNo✅ Yes
DHT sync lag measurementNoNo✅ Yes
Soak / sustained load testingNoNo✅ Yes
LanguageRustTypeScriptRust

Sweettest (Rust-Native)

Setup (Cargo.toml)

[dev-dependencies]
holochain = { version = "=0.7.0", default-features = false, features = ["encryption", "wasmer-sys-cranelift", "test_utils"] }
tokio = { version = "1", features = ["full"] }

Core Types

TypePurpose
SweetConductorSingle conductor instance
SweetConductorBatchMultiple conductors for multi-agent scenarios
SweetAppInstalled app with pre-built cells
SweetCellCell reference — access agent key, DNA hash, zome handles
SweetZome(CellId, ZomeName) handle passed to conductor.call()
SweetAgentsAgent key generation utilities
SweetDnaFileDNA construction helpers
SweetInlineZomesDefine zome functions directly in test code

Standard Two-Agent Test

#![allow(unused)]
fn main() {
use holochain::sweettest::*;
use std::path::Path;

#[tokio::test(flavor = "multi_thread")]
async fn two_agents_can_share_entries() {
    // 1. Create two conductors
    let mut conductors = SweetConductorBatch::standard(2).await;

    // 2. Load DNA bundle
    let dna = SweetDnaFile::from_bundle(Path::new("workdir/my.dna")).await.unwrap();

    // 3. Install app on both conductors
    let apps = conductors.setup_app("my-app", &[dna]).await.unwrap();
    let ((alice_cell,), (bob_cell,)) = apps.into_tuples();

    // 4. Exchange peer info so conductors can gossip
    conductors.exchange_peer_info().await;

    // 5. Alice creates an entry
    let alice_zome = alice_cell.zome("my_coordinator");
    let hash: ActionHash = conductors[0]
        .call(&alice_zome, "create_my_entry", my_payload)
        .await;

    // 6. Wait for DHT consistency before any cross-agent read
    await_consistency([&alice_cell, &bob_cell]).await.unwrap();

    // 7. Bob reads the entry
    let bob_zome = bob_cell.zome("my_coordinator");
    let record: Option<Record> = conductors[1]
        .call(&bob_zome, "get_my_entry", hash)
        .await;

    assert!(record.is_some());
}
}

CRITICAL: await_consistency is MANDATORY Before Cross-Agent Reads

Every cross-agent read must be preceded by it:

#![allow(unused)]
fn main() {
// After any write, before cross-agent reads:
await_consistency([&alice_cell, &bob_cell]).await.unwrap();

// Custom timeout in seconds (default is 60s):
await_consistency_s(30, [&alice_cell, &bob_cell]).await.unwrap();

// Instant non-waiting check:
check_consistency([&alice_cell, &bob_cell]).await.unwrap();
}

await_consistency polls every 500ms, comparing all peers’ DHT databases at the op level until every op is integrated across all nodes.

Calling Zome Functions

#![allow(unused)]
fn main() {
// Standard call — panics on error, uses authorship cap automatically:
let result: MyOutputType = conductor.call(&cell.zome("my_zome"), "fn_name", payload).await;

// Fallible call — returns ConductorApiResult:
let result = conductor.call_fallible(&cell.zome("my_zome"), "fn_name", payload).await?;

// Cross-agent call — simulate another agent calling with a cap secret:
let result: MyOutputType = conductor.call_from(
    &other_agent_key,
    Some(cap_secret),
    &cell.zome("my_zome"),
    "restricted_fn",
    payload,
).await;
}

Agent Key Generation

#![allow(unused)]
fn main() {
// Named deterministic keys (same every run — useful for debugging):
let (alice, bob) = SweetAgents::alice_and_bob();
let alice = SweetAgents::alice();

// Random keys:
let agent = SweetAgents::one(conductor.keystore()).await;
let (a, b, c) = SweetAgents::three(conductor.keystore()).await;
let agents: Vec<AgentPubKey> = SweetAgents::get(conductor.keystore(), 5).await;
}

Inline Zomes (Quick Isolated Tests, No WASM Compile)

#![allow(unused)]
fn main() {
let mut zomes = SweetInlineZomes::new();
zomes.function("create_thing", |api, input: MyInput| {
    let hash = api.create(CreateInput::new(
        EntryDefLocation::app(0, 0),
        EntryVisibility::Public,
        Entry::app(SerializedBytes::try_from(input)?)?,
        ChainTopOrdering::default(),
    ))?;
    Ok(hash)
});
let dna = SweetDnaFile::unique_from_inline_zomes(zomes).await.unwrap();
}

Single-Conductor Pattern (Validation and Unit Tests)

#![allow(unused)]
fn main() {
#[tokio::test(flavor = "multi_thread")]
async fn validate_entry_on_create() {
    let conductor = SweetConductor::from_config(SweetConductorConfig::standard()).await;
    let dna = SweetDnaFile::from_bundle(Path::new("workdir/my.dna")).await.unwrap();
    let app = conductor.setup_app("my-app", &[dna]).await.unwrap();
    let (cell,) = app.into_tuple();
    let zome = cell.zome("my_coordinator");

    // Test validation rejection
    let result = conductor.call_fallible(&zome, "create_my_entry", invalid_payload).await;
    assert!(result.is_err());
}
}

SweetConductorConfig — Network Tuning

Most tests use SweetConductorConfig::standard(). Override for stress tests or timing-sensitive scenarios:

#![allow(unused)]
fn main() {
let mut config = SweetConductorConfig::standard();

// Tune gossip frequency (default: 1000ms)
config.tune_network_config(|net| {
    net.gossip_initiate_interval_ms = 500;        // More frequent gossip
    net.gossip_round_timeout_ms = 20_000;          // Longer timeout
    net.gossip_min_initiate_interval_ms = 500;
    net.gossip_initiate_jitter_ms = 50;
});

// Tune validation and countersigning
config.tune_conductor(|tune| {
    tune.sys_validation_retry_delay = Some(Duration::from_secs(3));
    tune.countersigning_resolution_retry_delay = Some(Duration::from_secs(5));
    tune.countersigning_resolution_retry_limit = Some(10);
});

let conductor = SweetConductor::from_config_rendezvous(
    config,
    SweetLocalRendezvous::new().await,
).await;
}

Installation Patterns

Single Conductor, Multiple Agents

#![allow(unused)]
fn main() {
// Install same app for N generated agents (app IDs: "{prefix}0", "{prefix}1", ...)
let apps: SweetAppBatch = conductor
    .setup_apps("my-app", 3, &[dna_file])
    .await.unwrap();
let cells: Vec<SweetCell> = apps.cells_flattened();

// Install for a specific pre-generated agent
let agent = SweetAgents::one(conductor.keystore()).await;
let app: SweetApp = conductor
    .setup_app_for_agent("my-app", agent.clone(), &[dna_file])
    .await.unwrap();

// Install for multiple pre-generated agents
let agents = SweetAgents::get(conductor.keystore(), 3).await;
let apps: SweetAppBatch = conductor
    .setup_app_for_agents("my-app", &agents, &[dna_file])
    .await.unwrap();
}

Explicit DNA Role Binding

#![allow(unused)]
fn main() {
// Bind DNA to a named role (required when role name differs from DNA hash)
let dna_with_role: (RoleName, DnaFile) = ("my_role".into(), dna_file);
let app = conductor.setup_app("my-app", &[dna_with_role]).await.unwrap();
}

Multi-Cell App (Multiple DNA Roles)

#![allow(unused)]
fn main() {
let role_a = ("role_a", dna_a);
let role_b = ("role_b", dna_b);
let app = conductor.setup_app("my-app", &[role_a, role_b]).await.unwrap();

// Destructure cells by role order
let (cell_a, cell_b) = app.into_tuple();
}

SweetAppBatch Destructuring

#![allow(unused)]
fn main() {
// Two apps, one cell each
let ((alice,), (bob,)) = conductors
    .setup_app("my-app", &[dna_file])
    .await.unwrap()
    .into_tuples();

// Two apps, two cells each
let ((alice_a, alice_b), (bob_a, bob_b)) = conductors
    .setup_app("my-app", &[dna_a, dna_b])
    .await.unwrap()
    .into_tuples();
}

SweetConductorBatch — Advanced Patterns

#![allow(unused)]
fn main() {
// From custom config applied to all conductors
let conductors = SweetConductorBatch::from_config_rendezvous(
    3,
    SweetConductorConfig::standard(),
).await;

// From different configs per conductor
let configs = vec![config_a, config_b, config_c];
let conductors = SweetConductorBatch::from_configs_rendezvous(configs).await;

// Force peer visibility between two specific conductors (unidirectional)
conductors.reveal_peer_info(0, 1).await;  // conductor 0 sees conductor 1

// Persist databases for debugging
conductors[0].persist_dbs();  // must call BEFORE shutdown
}

App Lifecycle Management

#![allow(unused)]
fn main() {
// Disable then re-enable an app
conductor.disable_app("my-app".to_string(), DisabledAppReason::User).await.unwrap();
conductor.enable_app("my-app".to_string()).await.unwrap();

// Hot-reload coordinator zomes without restarting conductor
conductor.update_coordinators(
    cell.cell_id().clone(),
    updated_coordinator_zomes,
    vec![new_wasm],
).await.unwrap();

// Create a clone cell of an existing role
let cloned = conductor.create_clone_cell(
    &"my-app".to_string(),
    CreateCloneCellPayload {
        role_name: "clonable_role".into(),
        modifiers: DnaModifiersOpt::default().with_network_seed("clone-1"),
        membrane_proof: None,
        name: Some("My Clone".to_string()),
    },
).await.unwrap();

// Restart conductor
conductor.shutdown().await;
conductor.startup(false).await;
}

Database Access and Inspection

Use these for debugging or asserting internal state without going through zome calls:

#![allow(unused)]
fn main() {
// Access authored and DHT databases directly
let authored_db = cell.authored_db();
let dht_db = cell.dht_db();
let dht_db_from_conductor = conductor.get_dht_db(cell.dna_hash()).unwrap();

// Read the full source chain for an agent
let chain = conductor
    .get_agent_source_chain(&agent_key, cell.dna_hash())
    .await;

// Get invalid / rejected ops (validates your validation logic)
let invalid_ops = conductor.get_invalid_integrated_ops(&dht_db).await.unwrap();
assert!(invalid_ops.is_empty(), "Found invalid ops: {invalid_ops:?}");

// Persist databases to disk before shutdown (for debugging)
let path = conductor.persist_dbs();
println!("DB saved to: {}", path.display());
conductor.shutdown().await;
}

Network and Gossip Testing

#![allow(unused)]
fn main() {
// Wait for specific peers to become visible on this conductor
conductor.wait_for_peer_visible(
    vec![alice_pubkey.clone(), bob_pubkey.clone()],
    Some(cell.cell_id().clone()),
    Duration::from_secs(30),
).await.unwrap();

// Require at least N peers before gossip starts (avoids false positives)
conductor
    .require_initial_gossip_activity_for_cell(&cell, 2, Duration::from_secs(30))
    .await.unwrap();

// Declare this node holds the full DHT arc (affects peer routing)
conductor.declare_full_storage_arcs(cell.dna_hash()).await;

// Check consistency without blocking (instant snapshot)
check_consistency([&alice_cell, &bob_cell]).await.unwrap();

// Drop and restart signaling server (simulates network partition)
let rendezvous = SweetLocalRendezvous::new_raw().await;
rendezvous.drop_sig().await;   // kill signal channel
// ... test behavior during outage ...
rendezvous.start_sig().await;  // restore
}

Op Integration Verification

Assert that ops are fully integrated without using await_consistency:

#![allow(unused)]
fn main() {
// All ops in the DHT for this DNA are integrated
let integrated = conductor.all_ops_integrated(cell.dna_hash()).unwrap();
assert!(integrated, "Ops not yet integrated");

// All ops authored by a specific agent are integrated
let author_integrated = conductor
    .all_ops_of_author_integrated(cell.dna_hash(), cell.agent_pubkey())
    .unwrap();
}

Time-Based Testing (Scheduled Functions)

#![allow(unused)]
fn main() {
// Start scheduler with custom interval
conductor.start_scheduler(Duration::from_millis(100)).await.unwrap();

// Manually fire scheduled functions at a specific timestamp
let target_time = Timestamp::now() + Duration::from_secs(3600); // 1 hour in future
conductor.dispatch_scheduled_fns(target_time).await;

// Verify effects after scheduler fires
let result: Vec<Record> = conductor.call(&zome, "get_scheduled_entries", ()).await;
assert!(!result.is_empty());
}

SweetInlineZomes — Integrity and Coordinator Separation

The full pattern separates integrity (validation) from coordinator (business logic):

#![allow(unused)]
fn main() {
use holochain::sweettest::{SweetInlineZomes, SweetDnaFile};
use holochain_zome_types::{EntryDef, EntryVisibility};

let entry_def = EntryDef {
    id: "my_entry".into(),
    visibility: EntryVisibility::Public,
    required_validations: RequiredValidations::default(),
    cache_at_agent_activity: false,
    required_validation_type: Default::default(),
};

let zomes = SweetInlineZomes::new(vec![entry_def], /* num_link_types */ 0)
    // Integrity zome: validation callbacks
    .integrity_function("validate", |_api, _op: Op| {
        Ok(ValidateCallbackResult::Valid)
    })
    // Coordinator zome: zome functions
    .function("create_entry", |api, input: MyInput| {
        let hash = api.create(CreateInput::new(
            EntryDefLocation::app(0, 0),
            EntryVisibility::Public,
            Entry::app(SerializedBytes::try_from(input)?)?,
            ChainTopOrdering::default(),
        ))?;
        Ok(hash)
    })
    .function("get_entry", |api, hash: ActionHash| {
        api.get(vec![GetInput::new(hash.into(), GetOptions::default())])
            .map(|gets| gets.into_iter().next().flatten())
    });

let (dna, _, _) = SweetDnaFile::unique_from_inline_zomes(zomes).await;
}

Zome name constants: SweetInlineZomes::INTEGRITY = "integrity", SweetInlineZomes::COORDINATOR = "coordinator".


WebSocket Interface Testing

For tests that need to verify WebSocket behavior (signals, app interface):

#![allow(unused)]
fn main() {
// Get admin WebSocket client
let (admin_sender, _admin_recv) = conductor.admin_ws_client::<AdminResponse>().await;

// Get app WebSocket client (auto-authenticated)
let (app_sender, mut app_recv) = conductor
    .app_ws_client::<AppResponse>("my-app".to_string())
    .await;

// Or authenticate manually for custom setup
let (app_sender, _) = websocket_client_by_port(app_port).await.unwrap();
authenticate_app_ws_client(app_sender.clone(), admin_port, "my-app".to_string()).await;
}

Common Sweettest Failures (Extended)

SymptomRoot CauseFix
Bob can’t find Alice’s entryMissing await_consistencyAdd await_consistency([&alice_cell, &bob_cell]).await.unwrap()
Compilation error on call()Missing feature flagAdd features = ["test_utils"] to holochain dev-dep
Timeout in await_consistencyConductors not networkedCall conductors.exchange_peer_info().await after setup_app
Wrong type on call()Type annotation missingAdd explicit type: let result: MyType = conductor.call(...)
into_tuple() failsWrong number of cells destructuredMatch tuple arity to number of DNA roles
Invalid ops present unexpectedlyValidation logic accepting bad dataUse get_invalid_integrated_ops() to inspect rejected ops
reveal_peer_info / gossip never startsFull arc not declaredCall declare_full_storage_arcs() on test conductors
Scheduled fn never firesScheduler not startedCall start_scheduler() or dispatch_scheduled_fns(timestamp)
WebSocket auth fails in testUsing wrong portUse admin_ws_client() for admin, app_ws_client() for app calls

E2E UI Testing (Playwright + Real Conductor)

For full end-to-end tests that drive the UI against a real Holochain backend — no mocks. Use @holochain/client directly against a conductor you start yourself.

Setup (package.json)

{
  "devDependencies": {
    "@playwright/test": "^1.40.0",
    "@holochain/client": "^0.21.0"
  }
}

Conductor Setup Pattern (globalSetup)

The critical pattern: use AdminWebsocket to install the app and get a proper auth token, then keep the conductor alive for all Playwright tests.

// tests/e2e/setup/global-setup.ts
import { AdminWebsocket, AppWebsocket } from '@holochain/client';
import { execSync, spawn } from 'child_process';

export default async function globalSetup() {
  // 1. Start conductor via hc sandbox
  const conductor = spawn('hc', ['sandbox', 'run', '--root', './test-workdir'], {
    stdio: ['ignore', 'pipe', 'pipe']
  });

  // 2. Wait for conductor ready signal in stdout (not polling)
  await new Promise<void>((resolve, reject) => {
    conductor.stdout?.on('data', (data: Buffer) => {
      if (data.toString().includes('Conductor ready')) resolve();
    });
    setTimeout(() => reject(new Error('Conductor startup timeout')), 30000);
  });

  // 3. Connect admin client (use admin port, not app port)
  const admin = await AdminWebsocket.connect({
    url: new URL('ws://localhost:8888')
  });

  // 4. Install and enable the happ properly
  const agentKey = await admin.generateAgentPubKey();
  await admin.installApp({
    installed_app_id: 'my_happ',
    agent_key: agentKey,
    // 0.21: the bundle location is a tagged union under `source`, not a top-level `path`.
    source: { type: 'path', value: './workdir/my_happ.happ' },
  });
  await admin.enableApp({ installed_app_id: 'my_happ' });

  // 5. Open app interface on a free port.
  // `allowed_origins` is required and has no default. Omit it and the browser
  // connection is rejected on origin, which looks exactly like a dead port.
  const { port } = await admin.attachAppInterface({
    port: 0,
    allowed_origins: '*',
  });

  // 6. Issue auth token
  const { token } = await admin.issueAppAuthenticationToken({
    installed_app_id: 'my_happ'
  });

  // 7. Connect app client and seed test data
  const client = await AppWebsocket.connect({
    url: new URL(`ws://localhost:${port}`),
    token,
  });

  await seedTestData(client);

  // 8. Store conductor process for teardown
  process.env.E2E_CONDUCTOR_PID = String(conductor.pid);
  process.env.E2E_APP_PORT = String(port);
}

Data Seeding

Seed data directly via AppWebsocket.callZome before Playwright opens the browser:

async function seedTestData(client: AppWebsocket) {
  // Seed in dependency order
  await client.callZome({
    role_name: 'my_dna',
    zome_name: 'my_coordinator',
    fn_name: 'create_service_type',
    payload: { name: 'Web Development', description: '...' },
  });

  await client.callZome({
    role_name: 'my_dna',
    zome_name: 'my_coordinator',
    fn_name: 'create_offer',
    payload: { title: 'Seed Offer', description: '...' },
  });
}

Playwright Test Pattern

// tests/e2e/specs/offers.spec.ts
import { test, expect } from '@playwright/test';

test('user sees seeded offers on load', async ({ page }) => {
  await page.goto('/offers');

  // Wait for Holochain connection (not a mock — real loading time)
  await expect(page.locator('[data-testid="offer-card"]'))
    .toHaveCount(1, { timeout: 15000 });

  await expect(page.locator('text=Seed Offer')).toBeVisible();
});

playwright.config.ts Key Settings

export default defineConfig({
  globalSetup: './tests/e2e/setup/global-setup.ts',
  globalTeardown: './tests/e2e/setup/global-teardown.ts',
  workers: 1,           // Single worker — one conductor, no conflicts
  fullyParallel: false, // Holochain state is shared across tests
  timeout: 60000,       // Holochain operations are slow
  use: {
    baseURL: 'http://localhost:5173',
  },
  webServer: {
    command: 'bun run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: true,
  },
});

Common E2E Failures

SymptomRoot CauseFix
Conductor never readyPolling instead of stdoutListen for "Conductor ready" in stdout
callZome rejectedUsing AppWebsocket on admin portUse AdminWebsocket on admin port (8888), AppWebsocket on app port
Auth error on connectMissing tokenCall admin.issueAppAuthenticationToken() and pass token to AppWebsocket.connect
Tests interfere with each otherShared conductor stateRun with workers: 1, reset data in beforeEach if needed
UI shows no dataRace — browser loads before seedingSeed in globalSetup (runs before browser opens), not in beforeAll

Wind-Tunnel — Performance and Load Testing

Wind-Tunnel is Holochain’s load testing framework. It applies user-defined load to running Holochain conductors and measures system response: latency, throughput, DHT sync lag, resource usage. It is completely separate from Sweettest (integration/correctness) and Playwright (E2E UI).

Repo: https://github.com/holochain/wind-tunnel

Version trap: use main, not the latest tag. Wind Tunnel’s own release numbering is independent of the Holochain version it targets, and the two currently disagree. Verified 2026-08-17 by reading the workspace Cargo.toml on each ref:

RefPinsHolochain generation
tag v0.7.1 (2026-07-21, latest release)hdk 0.6.3, hdi 0.7.3, holochain_client 0.8.3, kitsune2 0.4.10.6
branch mainhdk 0.7.0, hdi 0.8.0, holochain_client 0.9.0, kitsune2 0.5.00.7

The “0.7.1” in the tag name is Wind Tunnel’s version, not Holochain’s. Checking out that tag against a 0.7 hApp gives you a dependency conflict, not a load test. Track main until a release ships with 0.7 pins. Used for: Performance regression CI (every merge to holochain main), soak testing, benchmarking


Testing Layers Compared

LayerToolPurposeOutput
1Sweettest (Rust)Correctness — does the hApp work right?pass/fail
2Playwright (TypeScript)E2E functional — does the UI+zome flow work?pass/fail
3Wind-Tunnel (Rust)Performance — how fast/scalable is this?metrics (latency, throughput)

Rule: Use Wind-Tunnel when you need time-series performance data, not when you need correctness assertions.


Published Crates

CratePurpose
wind_tunnel_runnerCore: ScenarioDefinitionBuilder, run(), AgentContext, RunnerContext, Executor
wind_tunnel_instrumentsMetrics: Reporter, ReportMetric, OperationRecord
wind_tunnel_instruments_deriveProc macro: #[wind_tunnel_instrument]
wind_tunnel_coreCore types: AgentBailError, ShutdownHandle
holochain_wind_tunnel_runnerHolochain bindings: call_zome(), install_app(), HolochainAgentContext
holochain_client_instrumentedAuto-instrumented AdminWebsocket / AppWebsocket

Add to Cargo.toml:

[dev-dependencies]
holochain_wind_tunnel_runner = "0.6"

Core API

ScenarioDefinitionBuilder

#![allow(unused)]
fn main() {
use holochain_wind_tunnel_runner::prelude::*;
use holochain_wind_tunnel_runner::happ_path;

ScenarioDefinitionBuilder::<HolochainRunnerContext, HolochainAgentContext>::new_with_init(
    env!("CARGO_PKG_NAME")
)
    .with_default_duration_s(60)          // seconds to run
    .use_build_info(conductor_build_info) // attach conductor metadata to reports
    .use_agent_setup(fn)                  // called once per agent before loop
    .use_agent_behaviour(fn)              // called repeatedly per agent for entire duration
    .use_agent_teardown(fn)               // called once per agent after duration ends
    .use_named_agent_behaviour("write", fn)       // named role for multi-behavior scenarios
    .use_named_agent_behaviour("read", fn)        // multiple roles assigned via CLI
    .use_setup(fn)                        // global setup (before any agents)
    .use_teardown(fn)                     // global teardown (after all agents)
    .add_capture_env("MY_ENV_VAR")        // include env vars in report metadata
}

Lifecycle Order

Global Setup
  → Agent Setup (each agent, once)
    → Agent Behaviour loop (each agent, repeated until duration/shutdown)
  → Agent Teardown (each agent, once)
Global Teardown

Contexts

#![allow(unused)]
fn main() {
// Per-agent context — available inside every hook
impl AgentContext<HolochainRunnerContext, HolochainAgentContext<SV>> {
    fn agent_index(&self) -> usize;
    fn agent_name(&self) -> &str;
    fn runner_context(&self) -> &RunnerContext;
    fn get(&self) -> &HolochainAgentContext<SV>;        // read agent state
    fn get_mut(&mut self) -> &mut HolochainAgentContext<SV>; // write agent state
}

// Shared runner context
impl RunnerContext {
    fn reporter(&self) -> Arc<Reporter>;       // metrics sink
    fn executor(&self) -> &Executor;           // async runtime
    fn get_connection_string(&self) -> Option<&str>;
    fn force_stop_scenario(&self);
}

// Async code inside sync hooks
ctx.runner_context().executor().execute_in_place(async {
    // Holochain client calls (async) go here
})?;
}

Holochain Conductor Helpers

#![allow(unused)]
fn main() {
// Setup helpers (call in agent_setup)
start_conductor_and_configure_urls(ctx)?;       // start conductor + bind ports
install_app(ctx, happ_path!("my_happ"), &"my_happ".to_string())?;
use_installed_app(ctx, app_id)?;                // connect to already-installed app

// Teardown helpers (call in agent_teardown)
uninstall_app(ctx, None).ok();                  // None = use current app_id

// Peer coordination
try_wait_for_min_agents(ctx, 3, Duration::from_secs(30))?;
try_wait_until_full_arc_peer_discovered(ctx)?;
get_peer_list_randomized(ctx)?;  // -> Vec<AgentPubKey>

// Zome calls
let result: MyType = call_zome(ctx, "zome_name", "fn_name", payload)?;
}

Custom Metrics (ReportMetric)

#![allow(unused)]
fn main() {
use wind_tunnel_runner::prelude::ReportMetric;

let metric = ReportMetric::new("sync_lag")          // auto-prefixed: wt.custom.sync_lag
    .with_tag("agent", agent_pubkey.to_string())
    .with_field("value", lag_seconds);              // f64

ctx.runner_context().reporter().clone().add_custom(metric);
}

Custom Per-Agent State

#![allow(unused)]
fn main() {
#[derive(Debug, Default)]
struct ScenarioValues {
    sent_count: u32,
    seen_hashes: HashSet<ActionHash>,
}

impl UserValuesConstraint for ScenarioValues {}

// Use HolochainAgentContext<ScenarioValues> everywhere
// Access via: ctx.get().scenario_values and ctx.get_mut().scenario_values
}

Scenario Patterns

Pattern 1: Simple Zome Call Benchmark

use holochain_wind_tunnel_runner::prelude::*;
use holochain_wind_tunnel_runner::happ_path;

fn agent_setup(
    ctx: &mut AgentContext<HolochainRunnerContext, HolochainAgentContext>,
) -> HookResult {
    start_conductor_and_configure_urls(ctx)?;
    install_app(ctx, happ_path!("my_happ"), &"my_happ".to_string())?;
    Ok(())
}

fn agent_behaviour(
    ctx: &mut AgentContext<HolochainRunnerContext, HolochainAgentContext>,
) -> HookResult {
    // Runs repeatedly. Zome call latency auto-captured.
    let _: MyReturn = call_zome(ctx, "my_zome", "my_fn", ())?;
    Ok(())
}

fn main() -> WindTunnelResult<()> {
    let builder =
        ScenarioDefinitionBuilder::<HolochainRunnerContext, HolochainAgentContext>::new_with_init(
            env!("CARGO_PKG_NAME"),
        )
        .with_default_duration_s(60)
        .use_build_info(conductor_build_info)
        .use_agent_setup(agent_setup)
        .use_agent_behaviour(agent_behaviour)
        .use_agent_teardown(|ctx| { uninstall_app(ctx, None).ok(); Ok(()) });

    run(builder)?;
    Ok(())
}

Pattern 2: Write/Read CRUD Performance

#![allow(unused)]
fn main() {
fn agent_behaviour(
    ctx: &mut AgentContext<HolochainRunnerContext, HolochainAgentContext>,
) -> HookResult {
    let action_hash: ActionHash = call_zome(
        ctx, "my_zome", "create_entry", MyEntry { value: "test".to_string() },
    )?;
    let record: Option<Record> = call_zome(
        ctx, "my_zome", "get_entry", action_hash,
    )?;
    assert!(record.is_some(), "Entry must be readable immediately after create");
    Ok(())
}
}

Pattern 3: DHT Sync Lag (Multi-Role)

#[derive(Debug, Default)]
struct ScenarioValues {
    sent_actions: u32,
    seen_actions: HashSet<ActionHash>,
}
impl UserValuesConstraint for ScenarioValues {}

// Writer: creates timestamped entries, records sent_count metric
fn agent_behaviour_write(
    ctx: &mut AgentContext<HolochainRunnerContext, HolochainAgentContext<ScenarioValues>>,
) -> HookResult {
    call_zome(ctx, "timed", "create_timed_entry", Timestamp::now())?;
    ctx.get_mut().scenario_values.sent_actions += 1;
    let metric = ReportMetric::new("sent_count")
        .with_field("value", ctx.get().scenario_values.sent_actions);
    ctx.runner_context().reporter().clone().add_custom(metric);
    Ok(())
}

// Reader: queries locally, computes lag since creation, records sync_lag metric
fn agent_behaviour_record_lag(
    ctx: &mut AgentContext<HolochainRunnerContext, HolochainAgentContext<ScenarioValues>>,
) -> HookResult {
    let found: Vec<(ActionHash, Timestamp)> =
        call_zome(ctx, "timed", "get_timed_entries_local", ())?;
    let reporter = ctx.runner_context().reporter().clone();
    for (hash, created_at) in found {
        if !ctx.get().scenario_values.seen_actions.contains(&hash) {
            let lag_s = (Timestamp::now().as_micros() - created_at.as_micros()) as f64 / 1e6;
            reporter.add_custom(ReportMetric::new("sync_lag").with_field("value", lag_s));
            ctx.get_mut().scenario_values.seen_actions.insert(hash);
        }
    }
    Ok(())
}

fn main() -> WindTunnelResult<()> {
    let builder = ScenarioDefinitionBuilder::<
        HolochainRunnerContext, HolochainAgentContext<ScenarioValues>,
    >::new_with_init(env!("CARGO_PKG_NAME"))
        .with_default_duration_s(60)
        .use_build_info(conductor_build_info)
        .use_agent_setup(agent_setup)
        .use_named_agent_behaviour("write", agent_behaviour_write)
        .use_named_agent_behaviour("record_lag", agent_behaviour_record_lag)
        .use_agent_teardown(|ctx| { uninstall_app(ctx, None).ok(); Ok(()) });
    run(builder)?;
    Ok(())
}

Run with: cargo run -- --behaviour write:2 --behaviour record_lag:2 --duration 120


Running Wind-Tunnel Tests

# Minimal run (in-memory reporter, single agent)
RUST_LOG=info cargo run -p my_scenario -- --duration 60

# Multiple agents, named roles
cargo run -p my_scenario -- --agents 4 --duration 120
cargo run -p my_scenario -- --behaviour write:2 --behaviour read:2 --duration 120

# Against external conductor (pre-running)
cargo run -p my_scenario -- --connection-string ws://localhost:8888 --duration 60

# With InfluxDB file reporter (for analysis)
cargo run -p my_scenario -- --reporter=influx-file --duration 300

# Soak test (no time limit)
cargo run -p my_scenario -- --soak --reporter=influx-file

Environment Variables

VariablePurpose
WT_HOLOCHAIN_PATHPath to custom Holochain binary
HOLOCHAIN_INFLUXIVE_FILEEnable conductor-level metrics to file
WT_METRICS_DIRDirectory for metrics output (set by Nix)
RUST_LOGLog level (e.g. RUST_LOG=info)

Metrics Architecture

Wind-Tunnel collects three simultaneous metric layers, enabling correlation:

LayerSourceWhat
OSTelegraf (systemd)CPU, memory, disk I/O, network, swap
ConductorHolochain influxiveInternal conductor performance
ScenarioReportMetricCustom application metrics

Reporter backends:

FlagUse
--reporter=in-memoryConsole output (default, local dev)
--reporter=influx-fileWrite InfluxDB line protocol for upload
--reporter=noopDisable all metrics

Cargo.toml Metadata for hApp Packaging

[package.metadata.required-dna]
name = "my_zome"
zomes = ["my_zome"]

[package.metadata.required-happ]
name = "my_happ"
dnas = ["my_zome"]

Use happ_path!("my_happ") macro in code to resolve the built hApp path. A shared build.rs (build = "../scenario_build.rs") packages zomes into DNAs/hApps automatically.


Pre-Built Scenarios (Reference)

25 scenarios in the wind-tunnel repo cover common Holochain performance patterns:

ScenarioTests
zome_call_single_valueBaseline zome call latency
write_readCreate + immediate get throughput
dht_sync_lagDHT propagation delay between agents
app_installApp installation latency (minimal vs large)
remote_signalsRemote signal round-trip latency
remote_call_rateRemote zome call throughput
two_party_countersigningFull countersigning session lifecycle
single_write_many_readWrite amplification pattern
validation_receiptsValidation receipt delivery timing
local_signalsLocal signal handling performance
full_arc_create_validated_zero_arc_readMixed-arc topology
zero_arc_create_dataZero-arc creation throughput

Published results: https://holochain.github.io/wind-tunnel/


When to Write a Wind-Tunnel Scenario

Write a Wind-Tunnel scenario (not a Sweettest test) when you need:

  • Continuous latency monitoring — track how long zome calls take under load over time
  • Throughput measurement — ops/sec for entry creation, linking, querying
  • DHT propagation timing — how long until another agent sees your entries
  • Regression detection — catch performance regressions between Holochain versions
  • Soak testing — sustained load over hours to detect memory leaks or degradation
  • Multi-node topology testing — full-arc vs zero-arc behavior at scale

Do NOT use Wind-Tunnel for:

  • Checking correctness (use Sweettest)
  • Testing UI flows (use Playwright)
  • Testing specific HDK behaviors (use Sweettest inline zomes)

Holochain TypeScript Client

Package Version

@holochain/client   ^0.21.0   (Holochain 0.7 / hdk 0.7.x / hdi 0.8.x)

Holochain 0.7 changed the client surface. If you are porting from 0.20.x, read Migrating from 0.20.x first.


Connection Setup

import { AppWebsocket, AdminWebsocket } from "@holochain/client";

// App connection. The client is bound to the app it connects to, so there is
// no installed-app-id argument and no separate agent-aware class.
const client = await AppWebsocket.connect();

// Explicit URL and token, e.g. when the conductor is not discovered from env:
const client = await AppWebsocket.connect({
  url: new URL(`ws://localhost:${process.env.HC_PORT}`),
  token: authToken,
});

// Admin connection (test harnesses, installers, tooling — not app code):
const admin = await AdminWebsocket.connect({
  url: new URL(`ws://localhost:${process.env.HC_ADMIN_PORT}`),
});

AppAgentWebsocket no longer exists. It was merged into AppWebsocket, which now carries the cell context itself.


App Authentication Tokens

A conductor does not hand out app connections to whoever asks. An app websocket connection is authenticated with a token that the admin interface issues, so the flow has two steps and two sockets.

import { AdminWebsocket, AppWebsocket } from "@holochain/client";

const admin = await AdminWebsocket.connect({
  url: new URL(`ws://localhost:${adminPort}`),
});

// 1. Mint a token for one installed app.
const { token } = await admin.issueAppAuthenticationToken({
  installed_app_id: "my-app",
  expiry_seconds: 30,   // optional; omit for the conductor default
  single_use: true,     // optional; a token good for exactly one connection
});

// 2. Connect the app socket with it.
const client = await AppWebsocket.connect({
  url: new URL(`ws://localhost:${appPort}`),
  token,
});

The types:

export type AppAuthenticationToken = number[];

export interface IssueAppAuthenticationTokenRequest {
  installed_app_id: InstalledAppId;
  expiry_seconds?: number;
  single_use?: boolean;
}

export interface IssueAppAuthenticationTokenResponse {
  token: AppAuthenticationToken;
  expires_at?: Timestamp;
}

single_use: true plus a short expiry_seconds is the right default for a launcher handing a token to a browser window: the token is spent on connection and useless if it leaks afterwards.

In a dev sandbox where the environment already provides a connection, AppWebsocket.connect() with no arguments discovers what it needs and you never see a token. In a packaged app you are the launcher, so you issue it yourself. Kangaroo does this for you; see deployment.md.

Attaching an app interface

A token is useless without a port to spend it on. Admin attaches app interfaces:

const { port } = await admin.attachAppInterface({
  allowed_origins: "*",              // required, not defaulted
  installed_app_id: "my-app",        // optional: restrict this interface to one app
});

allowed_origins takes a comma separated list or *. A browser UI silently failing to connect is usually an origin rejection, not a port problem.

Passing installed_app_id binds the interface: only tokens issued for that same app may connect to it. That is the isolation boundary when one conductor runs several apps.


Admin API: Install and Manage

App code should never touch the admin socket. Test harnesses, launchers and installers do.

// Install
await admin.installApp({
  source: { type: "path", value: "./workdir/my-app.happ" },
  installed_app_id: "my-app",
  network_seed: "my-network",        // optional, overrides every DNA in the bundle
  roles_settings: { /* see membranes.md */ },
});

await admin.enableApp({ installed_app_id: "my-app" });

agent_key is optional on install; omit it and the conductor generates one. ignore_genesis_failure: true leaves a failed app installed with empty cells instead of uninstalling it, which is a diagnostic setting rather than a production one.

Signing credentials for direct zome calls

An admin-driven client that calls zome functions without going through an app interface needs its own capability grant:

await admin.authorizeSigningCredentials(cellId);

// or narrow it. `GrantedFunctions` is a tagged union in 0.21, not the old
// `{ [GrantedFunctionsType.Listed]: ... }` object:
await admin.authorizeSigningCredentials(cellId, {
  type: "listed",
  value: [["my_zome", "my_fn"]],
});

This is what Playwright E2E setups and test harnesses use. See testing.md.

Inspection

const apps    = await admin.listApps({});
const cells   = await admin.listCellIds();
const grants  = await admin.listCapabilityGrants({
  installed_app_id: "my-app",
  include_revoked: false,        // required, not optional
});
const stats   = await admin.dumpNetworkStats();

include_revoked has no default: leave it out and the call does not typecheck. Set it to false when you are asking “what can this agent do right now”, and true only when you are auditing history, because a revoked grant in the list looks exactly like a live one at a glance.

The same calls are available from the command line as hc-client call .... See debugging.md.

App status

appInfo() returns a status that a UI has to branch on:

export type AppStatus =
  | { type: "disabled"; value: DisabledAppReason }
  | { type: "enabled" }
  | { type: "awaiting_memproofs" };

awaiting_memproofs means the app is installed but waiting for a membrane proof before it can join. Handle it, or your app appears to install and then do nothing. See membranes.md.


callZome Pattern

const record = await client.callZome({
  role_name: "my_dna",
  zome_name: "my_zome",
  fn_name: "create_my_entry",
  payload: { title: "New Entry", status: "Active" },
});

role_name resolves the cell from the app manifest, which is what app code should use. Pass cell_id instead only when addressing a specific cloned cell.


Actions: the header / data split

Holochain 0.7 split every action into a header holding the fields all actions share and a data payload holding the rest. The same split applies on the JavaScript side, and SignedActionHashed is no longer generic, because the per-variant action types no longer exist.

import type { SignedActionHashed, ActionHash, AgentPubKey, Timestamp } from "@holochain/client";
import { encodeHashToBase64 } from "@holochain/client";

// Common fields live under .header
const author = encodeHashToBase64(action.hashed.content.header.author);
const createdAt = action.hashed.content.header.timestamp;

// Action-specific fields live under .data
const entryHash = action.hashed.content.data.entry_hash;

The Create, Update, Delete, CreateLink and DeleteLink types are no longer exported, so signal types lose their type parameter:

export type MyAppSignal =
  | { type: "EntryCreated"; action: SignedActionHashed; app_entry: EntryTypes }
  | { type: "EntryUpdated"; action: SignedActionHashed; original_action_hash: ActionHash }
  | { type: "LinkCreated"; action: SignedActionHashed; link_type: string };

Signal Subscription

client.on("signal", (signal) => {
  if (signal.type !== "App") return;   // ignore system signals

  const { zome_name, payload } = signal.value;
  if (zome_name === "my_zome") handleMyZomeSignal(payload as MyAppSignal);
});

function handleMyZomeSignal(payload: MyAppSignal) {
  switch (payload.type) {
    case "EntryCreated":
      // refresh list
      break;
    case "EntryUpdated":
      // update one item in the store
      break;
  }
}

The payload shape mirrors the Rust Signal enum, which uses #[serde(tag = "type")]. See patterns.md for the emitting side.


Type Utilities

import { decodeHashFromBase64, encodeHashToBase64, type Record } from "@holochain/client";
import { decode } from "@msgpack/msgpack";

// Hash serialisation, for URLs and localStorage
const hashString = encodeHashToBase64(actionHash);
const hashBack = decodeHashFromBase64(hashString);

// Decode an entry out of a Record
function decodeEntry<T>(record: Record): T {
  if (!("Present" in record.entry)) {
    throw new Error("Expected a Present entry");
  }
  return decode(record.entry.Present.entry) as T;
}

// The action hash of a Record
function getActionHash(record: Record) {
  return record.signed_action.hashed.hash;
}

Network Stats

import { type ApiTransportStats } from "@holochain/client";

const stats: ApiTransportStats = await client.dumpNetworkStats();
const connected = stats.transport_stats.connections.length;
const direct = stats.transport_stats.connections.filter((c) => c.is_direct).length;

Both the app and admin clients return the same ApiTransportStats type now, nesting the transport figures under transport_stats and adding blocked_message_counts. The per-connection is_webrtc flag is now is_direct, since WebRTC is gone and iroh over QUIC is the only transport.


Migrating from 0.20.x

0.20.x0.21.0
AppAgentWebsocket.connect(url, appId)AppWebsocket.connect()
SignedActionHashed<Create>SignedActionHashed
action.hashed.content.authoraction.hashed.content.header.author
action.hashed.content.timestampaction.hashed.content.header.timestamp
import { Create, CreateLink, ... }no longer exported; remove them
TransportStatsApiTransportStats
stats.connectionsstats.transport_stats.connections
connection.is_webrtcconnection.is_direct
signalingServerUrl in ConnectionServicesrelayServerUrl

Environment Variables

HC_PORT=8888           # conductor app WebSocket port
HC_ADMIN_PORT=9000     # admin port (conductor management)
VITE_HC_PORT=8888      # Vite prefix, for browser access

Framework Integration

The patterns above are framework-neutral. For stack-specific wiring:

  • Svelte 5 runes stores — frameworks/svelte.md
  • Effect-TS typed errors and timeouts — frameworks/effect.md

Neither is required. A React, Vue or vanilla app uses the client exactly as shown above.

Svelte 5 Integration

Optional. The framework-neutral client surface is in ../client.md; nothing here is required to use Holochain from TypeScript.

Assumes Svelte 5 runes and @holochain/client 0.21.0.


Reactive store

// stores/myEntry.svelte.ts
import type { AppWebsocket } from "@holochain/client";
import type { MyEntry, MyAppSignal } from "$lib/types";

export class MyEntryStore {
  entries = $state<MyEntry[]>([]);
  loading = $state(false);
  error = $state<string | null>(null);

  constructor(private client: AppWebsocket) {
    client.on("signal", (signal) => {
      if (signal.type !== "App") return;
      const { zome_name, payload } = signal.value;
      if (zome_name === "my_zome") this.handleSignal(payload as MyAppSignal);
    });
  }

  async loadAll() {
    this.loading = true;
    this.error = null;
    try {
      const records = await this.client.callZome({
        role_name: "my_dna",
        zome_name: "my_zome",
        fn_name: "get_all_my_entries",
        payload: null,
      });
      this.entries = records.map(decodeEntry<MyEntry>);
    } catch (e) {
      this.error = String(e);
    } finally {
      this.loading = false;
    }
  }

  private handleSignal(signal: MyAppSignal) {
    switch (signal.type) {
      case "EntryCreated":
        this.loadAll();
        break;
      case "EntryDeleted":
        // action fields live under .header in 0.7
        this.entries = this.entries.filter(
          (e) => e.originalHash !== signal.original_action_hash,
        );
        break;
    }
  }
}

$state on a class field makes the whole array reactive. Reassign it rather than mutating in place when you want the UI to update.


Connection context (SvelteKit)

// src/lib/holochainClient.ts
import { AppWebsocket } from "@holochain/client";
import { getContext, setContext } from "svelte";

const CLIENT_KEY = Symbol("holochain-client");

export function setHolochainClient(client: AppWebsocket) {
  setContext(CLIENT_KEY, client);
}

export function getHolochainClient(): AppWebsocket {
  const client = getContext<AppWebsocket>(CLIENT_KEY);
  if (!client) throw new Error("Holochain client not initialised");
  return client;
}
<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { AppWebsocket } from "@holochain/client";
  import { setHolochainClient } from "$lib/holochainClient";

  let ready = $state(false);

  $effect(() => {
    AppWebsocket.connect().then((client) => {
      setHolochainClient(client);
      ready = true;
    });
  });
</script>

{#if ready}
  {@render children?.()}
{:else}
  <p>Connecting to conductor…</p>
{/if}

Connect once in the root layout and pass the client down through context. Opening a websocket per component wastes connections and makes signal handling ambiguous.


Environment

VITE_HC_PORT=8888

hc-spin injects the port when it launches your UI, so in development you rarely set this by hand.

Effect-TS Integration

Optional. The framework-neutral client surface is in ../client.md; nothing here is required to use Holochain from TypeScript.

Effect earns its place when zome calls need typed errors, timeouts and retries as data rather than as try/catch scattered through the UI. On a small app it is overhead.


Typed zome call

import { Effect, pipe, Data } from "effect";
import type { AppWebsocket, CallZomeRequest } from "@holochain/client";

export class ZomeCallError extends Data.TaggedError("ZomeCallError")<{
  readonly fnName: string;
  readonly cause: unknown;
}> {}

export class ZomeTimeoutError extends Data.TaggedError("ZomeTimeoutError")<{
  readonly fnName: string;
}> {}

export function callZome<T>(client: AppWebsocket, req: CallZomeRequest) {
  return pipe(
    Effect.tryPromise({
      try: () => client.callZome(req) as Promise<T>,
      catch: (cause) => new ZomeCallError({ fnName: req.fn_name, cause }),
    }),
    Effect.timeoutFail({
      duration: "10 seconds",
      onTimeout: () => new ZomeTimeoutError({ fnName: req.fn_name }),
    }),
  );
}

timeoutFail gives a distinct error type rather than Effect’s generic TimeoutException, so callers can discriminate on _tag without unwrapping a cause chain.


Using it

const program = pipe(
  callZome<Record[]>(client, {
    role_name: "my_dna",
    zome_name: "my_zome",
    fn_name: "get_all_my_entries",
    payload: null,
  }),
  Effect.map((records) => records.map(decodeEntry<MyEntry>)),
  Effect.catchTag("ZomeTimeoutError", () => Effect.succeed([])),
);

const entries = await Effect.runPromise(program);

Retrying transient network failures

A zome call can fail because a peer was briefly unreachable. That is worth retrying; a validation failure is not.

import { Schedule } from "effect";

const withRetry = pipe(
  callZome<Record>(client, req),
  Effect.retry(
    Schedule.exponential("200 millis").pipe(
      Schedule.compose(Schedule.recurs(3)),
    ),
  ),
);

Retry only what is genuinely transient. Retrying a call that failed validation just fails four times more slowly, and retrying a create can double-write if the first attempt actually landed.


Where this fits

Keep Effect at the service boundary. Stores and components are easier to read when they receive plain values, so run the Effect at the edge and hand the result on.

Holochain Deployment — Kangaroo-Electron

Reference for packaging and distributing Holochain hApps as standalone desktop applications using Kangaroo-Electron.


What is Kangaroo-Electron

Kangaroo-Electron (holochain/kangaroo-electron) is Holochain’s official framework for bundling a complete hApp into a standalone cross-platform desktop application. It packages together:

  • The Holochain conductor
  • lair-keystore (key management)
  • Your hApp (.webhapp bundle with DNA + UI)
  • An Electron shell

Users receive a single installer (.exe / .dmg / .AppImage) with no Holochain tooling required.

Official repo: https://github.com/holochain/kangaroo-electron

Multi-branch strategy: One branch per supported Holochain version. Always work from the branch matching your hApp’s Holochain version.

Platforms: Windows, macOS, Linux


Branch Selection

BranchHolochain versionStatusUse when
main0.7.0DevelopmentTracks the latest line; moves without warning
main-0.70.7.0RecommendedNew production projects
main-0.60.6.3LegacyExisting 0.6.x apps not yet ported
main-0.50.5.xArchivedOld apps only

Default choice: main-0.7 unless you have a specific reason to use another.

Pin to a versioned branch rather than main. main follows whatever core is current, so a clone taken a month apart can produce two different conductors.


Prerequisites

All platforms

  • Rust toolchain (stable)
  • Node.js + npm

Linux

  • webkit2gtk (for Electron WebView)
  • libssl-dev
# Ubuntu/Debian
sudo apt install libwebkit2gtk-4.1-dev libssl-dev

macOS

  • Xcode Command Line Tools
xcode-select --install

Windows

  • Visual Studio 2019+ with “Desktop development with C++” workload

Repository Setup

# Clone and checkout the right branch
git clone https://github.com/holochain/kangaroo-electron
cd kangaroo-electron
git checkout main-0.7

# Install dependencies (auto-fetches conductor binaries with SHA256 validation)
npm install

No manual compilation needed. Binaries (conductor, lair-keystore) are automatically fetched and verified via SHA256 checksums during npm install.


Artifact Structure

Production: .webhapp bundle in pouch/

A .webhapp is a single archive containing:

  1. Your .happ (conductor + DNA + zomes)
  2. Your UI assets (HTML/JS/CSS)

Key distinction: .webhapp.happ. The .happ is backend only. The .webhapp bundles both backend and frontend.

Your hApp build pipeline produces the .webhapp outside Kangaroo. Place the built file here:

pouch/
  your-app.webhapp     ← place your built bundle here

UI icon requirement: Include icon.png (≥ 256×256 px) at the UI root. Missing icon will cause build warnings or failures on some platforms.

Development mode

For dev mode you don’t need a .webhapp. Instead, kangaroo.config.ts accepts:

  • happPath — path to your .happ file
  • uiPort — port where your UI dev server is running

Configuration

package.json

{
  "name": "your-app-name",
  "version": "0.1.0"
}

electron/config.ts

export const HOLOCHAIN_VERSION = "holochain-0.7.0"; // match your branch
export const APP_ID = "com.yourorg.yourapp";             // reverse-domain identifier
export const PRODUCT_NAME = "Your App Name";              // alphanumeric + hyphens on Windows
export const HAPP_PATH = "pouch/your-app.webhapp";

Windows MSI warning: PRODUCT_NAME must use only alphanumeric characters and hyphens. Spaces and special characters cause MSI packaging failures.


Critical Versioning Semantics

Kangaroo uses a versioning convention that controls user data isolation:

Version changeData folderEffect
Patch (0.1.0 → 0.1.1)SharedSafe upgrade — user keeps data
Minor (0.1.0 → 0.2.0)IsolatedBreaking — user starts fresh
Major (0.1.0 → 1.0.0)IsolatedBreaking — user starts fresh
Pre-release tag (any)IsolatedAlways isolated

Rule of thumb: Only use patch bumps for backward-compatible updates. Reserve minor/major bumps for intentional breaking changes where data migration is not required (or is handled in-app).


Network Transport (0.7)

Holochain 0.6 introduced iroh alongside tx5. 0.7 removed tx5 and WebRTC entirely — iroh over QUIC is the only transport, and it is compiled in unconditionally, so the transport-iroh Cargo feature no longer exists.

Your conductor config needs a relayUrl and must not carry signalUrl or webrtcConfig:

// kangaroo.config.ts on main-0.7
bootstrapUrl: 'https://dev-test-bootstrap2.holochain.org/',
relayUrl: 'https://dev-test-bootstrap2.holochain.org/',
// signalUrl: removed in 0.7 — see below

This is not a field that gets ignored. NetworkConfig rejects unknown fields, so a config left over from 0.6 that still sets signal_url or webrtc_config makes the conductor fail to start rather than warn.


CLI Commands

npm install           # install dependencies + auto-fetch conductor binaries
npm run start         # launch in development mode (hot reload, DevTools available)
npm run kangaroo      # production build for all configured platforms

CI/CD via GitHub Actions

Kangaroo-Electron includes GitHub Actions workflows. Control builds via branch naming:

BranchBuild typeCode signing
releaseCross-platform executablesUnsigned
release-codesignedCross-platform executablesSigned (requires secrets)

Required secrets for code-signed builds:

  • macOS: APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_ID, etc.
  • Windows: WINDOWS_CERTIFICATE, WINDOWS_CERTIFICATE_PASSWORD

Auto-Update

Kangaroo uses @matthme/electron-updater — a semver-aware fork of electron-updater.

  • Checks GitHub releases on app startup
  • Respects versioning semantics: patch updates install silently; minor/major present a breaking-change notice
  • Requires your GitHub repo to have releases with attached installers (produced by CI)

Other Deployment Options

OptionDescriptionWhen to use
p2p ShipyardCommunity-maintained Tauri + Nix approachNeed Android support
MossHolochain groupware frameworkApp integrates into a shared workspace

What NOT to Use

ToolReason
Holochain LauncherOfficially deprecated; development paused. Do not build new projects for it.
Kangaroo-TauriFrozen at Holochain 0.3.2 (last update Aug 2024). Not maintained.

Troubleshooting

Error / SymptomCauseFix
App won’t start after version bumpMinor/major bump → new isolated data folderExpected behavior. User data not migrated automatically — implement migration if needed.
Network connectivity failsiroh relay not configuredAdd relayUrl to conductor config in electron/config.ts
Conductor exits immediately on startConfig still sets signal_url / webrtc_config / chc_url / db_sync_strategyRemove them. 0.7 rejects unknown NetworkConfig fields instead of ignoring them. db_sync_strategy is now db_sync_level (Full/Normal/Off)
Conductor can’t read existing data after upgrade0.7 renamed its databases and changed DNA hashesExpected. There is no migration path; clear conductor data (hc sandbox clean). You are joining a new network
Binary checksum mismatchCorrupted or incomplete downloadrm -rf node_modules/.cache && npm install
Windows MSI build failsSpecial characters in PRODUCT_NAMEUse only alphanumeric characters and hyphens in PRODUCT_NAME

Reference: developer.holochain.org/get-started/4-packaging-and-distribution/

DNA Migration and Init Properties

Holochain 0.7 added the first piece of first-class DNA migration support: a way to seed a freshly installed chain with state carried over from a previous one.

Set expectations first. This is a building block, not a migration system. The developer experience around it is incomplete, and the surrounding pieces (getting data out of the old install, agent key continuity) are still the hard part.


The 0.7 mechanism: InitProperties

A new InitProperties type can be set on RoleSettings::Provisioned at install time, through the InstallApp admin endpoint. The zome reads it back from the init callback with the get_init_properties() host function.

Its four defining properties, all of which matter:

  1. Opaque to the conductor. The bytes mean nothing to Holochain; you define the encoding.
  2. Never written to the DHT. They stay in the conductor database.
  3. Readable only from init. No other callback or zome function can see them.
  4. Cleared once init succeeds, or when the app is uninstalled.

The intended shape is: extract state from the old install, pass it at install time to the new DNA, and have init write it onto the new source chain as ordinary entries. From that point on it is normal DHT data.


init_properties is not modifiers.properties

These are different mechanisms and confusing them produces a bug that passes every local test.

modifiers.propertiesinit_properties
Part of the DNA hashYesNo
Visible to other peersYes, every peer agrees on itNo, conductor-local
Readable from validationYes, via dna_info()No
Readable from any zome fnYesNo, init only
LifetimePermanentCleared after init
Use forNetwork-wide config: progenitor key, membrane settingsPer-install seed data for a migrated chain

The trap: a progenitor check reads naturally as “configuration passed at install time”, so init_properties looks like the modern replacement. It is not. Integrity validation runs on every peer against data that must be identical network-wide, and a validating peer cannot see your init properties. A check written against them passes for the installer and is unverifiable for everyone else.

Anything validation must agree on goes in modifiers.properties. See progenitor.md.


What 0.7 does not give you

There is no data migration path between 0.6 and 0.7 itself. DNA hashes change even for otherwise-identical DNAs, because ZomeDef no longer uses its custom untagged serialization and the DNA hash derives from the serialized integrity zomes. Holochain’s databases were also renamed, and existing conductor installs must have their data cleared. Every 0.7 network is a new network.

Agent key continuity is not solved. If your migration depends on users keeping their existing agent key, getting the private key out of Lair is not straightforward. Budget for this separately rather than assuming it.

Your packaging tool may not expose the admin API. Init properties are set through InstallApp. If you ship through Kangaroo or a similar wrapper, check that it lets you reach that call before designing around it.


Choosing what to carry across

Options, cheapest first:

ApproachWhat it costsWhen it fits
Clean restartNothing technical; users lose historyPre-production, or data that is genuinely disposable
User-facing export / importUI work in both versions, no admin API neededMost apps. Often cheaper than the “proper” path and easier to explain to users
Hash pointersSmall payload; the old data must stay reachable somewhereArchival references where content need not move
Notary-signed rollupsA trusted signer, and a scheme for itBalances or aggregates where per-event history is not needed
Whole source chainLargest payload; needs the most validation thoughtFull-fidelity migration

Before reaching for init properties, price the export/import route honestly. For many apps it is less work, needs no admin access, and gives users something they can understand and verify.


Sequence

  1. Decide what must survive, using the table above.
  2. Extract it from the 0.6 install, while it still runs.
  3. Encode it and pass it as init_properties on RoleSettings::Provisioned when installing the 0.7 app.
  4. In init, read get_init_properties(), decode, and write entries to the new source chain.
  5. Validate those entries like any others. Remember validation cannot see the init properties themselves, only the entries you wrote from them.

See workflows/upgrade-holochain-0.7.md for the code port, and deployment.md for where roles_settings is set in a packaged app.

Troubleshooting

Keyed on the literal text the compiler, conductor or CLI prints. Search this page for the error you actually saw.

Entries marked (verified) were reproduced on Holochain 0.7.0 while building this skill’s example hApp. The rest are drawn from the official 0.6 to 0.7 upgrade guide and the release CHANGELOG.


Build and compile

Error textCauseFix
The wasm32-unknown-unknown targets are not supported by default; you may need to enable the "wasm_js" configuration flag (verified)Building zomes without the getrandom backend flag. The error names getrandom, not Holochain, so it reads like a dependency problemRUSTFLAGS='--cfg getrandom_backend="custom"' cargo build --release --target wasm32-unknown-unknown. npm run build:zomes sets this for you
cannot find type EntryCreationAction in this scope0.6 code on 0.7TypedAction<EntryCreationData>. See patterns.md
no variant named StoreEntry found for enum FlatOp0.6 FlatOp namesCreateEntry. Full rename table in patterns.md
no variant named RegisterCreateLink / RegisterDeleteLinkBoth folded into one variant in 0.7FlatOp::Link(OpLink::CreateLink { .. })
no method named author found on an action structThe per-variant action structs are goneaction.author(), or action.header.author. Accessors exist on both Action and TypedAction<D>
cannot move out of dereference on a TypedAction fieldTypedAction<D> derefs to its data; you cannot move through a derefUse .data explicitly: action.data.target_address.into_action_hash()
unresolved import holochain_zome_types::action::ActionCrate-root and module re-exports were removed in 0.7Import from a prelude: holochain_zome_types::prelude::Action. Zomes importing only hdi::prelude / hdk::prelude are unaffected
feature sqlite-encrypted does not existRenamed in 0.7encryption
feature wasmer_sys does not existRenamed in 0.7wasmer-sys-cranelift
feature transport-iroh does not existRemoved; iroh is compiled in unconditionallyDrop it from the feature list
Build fails looking for perlA holochain build dependency needs it on PATH, usually surfacing only when building a Sweettest suiteAdd perl to the packages list in flake.nix
non-exhaustive patterns: AdminRequest::DumpOpTimings { .. } not covered, raised inside the holochain crate itself (verified)Version skew: the holochain crate is pinned to a 0.7.0-rc while holochain_conductor_api resolves to stable 0.7.0, which added the DumpOpTimings variant the rc does not handle. hc scaffold 0.700.0-rc emits exactly this combinationPin holochain to stable 0.7.0 in [workspace.dependencies], then cargo update -p holochain. Never leave the scaffolder’s rc pins in place
no method named agent found for &OpActivity<..>, or missing field agent on OpActivity::CreateAgent (verified)Scaffolder output does not compile against stable 0.7.0. hc scaffold 0.700.0-rc generates ref create @ OpActivity::CreateAgent { ref action } plus create.agent(), which matches the rc crates. Stable hdi 0.8.0 carries agent as a plain field, as the official upgrade guide documentsMatch the field directly: OpActivity::CreateAgent { agent, action } => { ... } and drop the create.agent() block. assets/templates/integrity-lib.rs is already corrected

Scaffolding

First, check which scaffolder you are running

hc scaffold --version

Holonix main-0.7 bundles holochain_scaffolding_cli 0.700.0-rc.0 (verified 2026-08-17 against the holonix main-0.7 tip, rev ffcc7c6). The stable v0.700.0 released 2026-07-31 and fixes every row in the rc block below. The bundled binary is the one you get by default, so most people hit these.

Install the stable scaffolder alongside holonix and use it instead:

nix run github:holochain/scaffolding/v0.700.0 -- web-app my-app
# or, once:
cargo install holochain_scaffolding_cli --version 0.700.0

Stable v0.700.0 emits holonix?ref=main-0.7, nodejs_24, and stable pins hdi 0.8.0 / hdk 0.7.0 / holochain 0.7.0, and its validate() codegen compiles against those crates.

Applies to every scaffolder version

SymptomCauseFix
Integrity zome "<name>" was not found in dna "<dna>" (verified)--zome takes the integrity crate’s package name, not the directory name. hc scaffold zome posts creates directory posts with package posts_integrityPass --zome posts_integrity
hc: command not found inside nix developWrong holonix branch, or the shell did not rebuildCheck the ref in flake.nix, then nix flake update && nix develop

Applies to hc-scaffold 0.700.0-rc.0 only (the holonix-bundled binary)

Fixed in stable v0.700.0. If you are on stable and see one of these, something else is wrong.

SymptomCauseFix
Scaffolded flake.nix says ref=main (verified)The rc emits main, and holonix main tracks the 0.8 dev lineChange to ref=main-0.7 after scaffolding, then nix flake update
Scaffolded flake.nix says nodejs_22 (verified)Same stale templateChange to nodejs_24
Scaffolded Cargo.toml pins -rc crates (verified)The scaffolding CLI is itself an rc and pins its own generationPin stable: hdi = "=0.8.0", hdk = "=0.7.0", holochain = "0.7.0"

Conductor startup

Error / symptomCauseFix
Conductor exits immediately after a 0.7 upgradeNetworkConfig rejects unknown fields rather than ignoring themRemove signal_url and webrtc_config. Move request_timeout_s under network. Rename db_sync_strategy to db_sync_level (Full/Normal/Off; old Resilient maps to Normal, Fast to Off). Remove chc_url
Conductor cannot read existing databases0.7 renamed its databases and there is no migration pathhc sandbox clean. Expected, not a bug
Peers never connect after upgradingDNA hashes changed in 0.7 even for otherwise identical DNAs, so you are on a new networkEveryone must be on the same build. Republish the DNA hash to your users
hc sandbox rejects the network typeOnly mem and quic remain; webrtc is gone with tx5Update scripts passing webrtc
App manifest rejected for an unrecognised fieldApp and web-app manifests now reject unknown fieldsRemove stray or misspelled keys. manifest_version is '0'

Validation

SymptomCauseFix
Validation passes locally, fails for other agentsNon-deterministic validation: a get() or get_links() in a coordinator helper called from validation, agent_info(), sys_time(), or get_init_properties()Validation may only inspect the op and what must_get_* returns. See patterns.md
A progenitor check works for the installer and nobody elseReading init_properties instead of modifiers.properties. Init properties are conductor-local and invisible to other peersUse dna_info().modifiers.properties. See progenitor.md
An entry fails to deserialize after a schema changeA field was added without #[serde(default)]Option<T> alone is not sufficient. Add #[serde(default)]
WrongActionError from a narrowing conversionThe action was not the variant sys validation guaranteesPropagate with ?, do not return Invalid. It is a fault, not bad author data

Testing

SymptomCauseFix
Cross-agent read returns None intermittentlyMissing consistency waitawait_consistency([&alice_cell, &bob_cell]).await.unwrap() before every cross-agent read. await_consistency_s(n, ..) for a custom timeout
Suites over roughly 8 tests hang or flakeToo many in-process conductors at oncecargo test -- --test-threads 6
Conductor::install_app_with_manifest not foundMoved behind a feature in 0.7Enable the test_utils feature
mock_network not foundRemoved from holochain_p2p in 0.7Use test_utils directly
Inline zome tests fail to compileInline zome definitions are no longer embedded in DnaDef; closures live on DnaFile, and InlineZome::uuid became InlineZome::hashRework against the 0.7 inline zome API

Deployment

SymptomCauseFix
Kangaroo build uses the wrong conductorCloned the default branchgit checkout main-0.7. main-0.7 pins holochain 0.7.0; main-0.6 pins 0.6.3
App will not start after a version bumpKangaroo isolates data folders on minor and major bumps by designExpected. Only patch bumps share a data folder
Windows MSI build failsSpecial characters in PRODUCT_NAMEAlphanumerics and hyphens only

See also deployment.md, testing.md and scaffolding.md, which point here rather than repeating these tables.

Debugging a Running Conductor

troubleshooting.md maps literal error strings to causes. This file is for the other case: nothing has thrown, and you need to see what the conductor and the network are actually doing.

Command surface read from holochain/holochain at tag holochain-0.7.0 (crates/hc_sandbox/src/cli.rs, crates/hc_client/src/cli.rs, crates/hc_client/src/calls.rs, crates/hc_client/src/zome_call.rs), and log behaviour from holochain 0.7.0 (src/core/ribosome/host_fn/trace.rs), holochain_trace 0.7.0 and hdk 0.7.0.

hc sandbox call is gone in 0.7

If you have muscle memory for hc sandbox call dump-state, it no longer exists. The 0.7 hc sandbox subcommands are exactly:

generate   run   list   clean   remove   admin-ports   create

Admin API calls moved to a separate client CLI. It ships two ways and both work: as a standalone hc-client binary, and as a builtin hc client subcommand (holochain_cli depends on holochain_cli_client, and hc-client is in its builtin command list). This file writes hc-client; substitute hc client if you prefer. That split is the single most disorienting change in the 0.7 developer tooling, and nothing in the error message tells you where the command went.

Two log streams, two environment variables

The conductor and your wasm log through different filters. Setting only one and seeing nothing from the other is a common false alarm.

StreamVariableDefault
Conductor (Rust)RUST_LOGunset means the tracing subscriber is a no-op
Your zome code (wasm)WASM_LOG[wasm_trace]=debug

WASM_LOG parses exactly like RUST_LOG, and holochain_trace will also read CUSTOM_FILTER as an override, complaining loudly if that one fails to parse while staying quiet if RUST_LOG does.

RUST_LOG=info WASM_LOG=debug hc sandbox run
RUST_LOG='holochain=debug,kitsune2_gossip=trace' hc sandbox run

hc sandbox takes --structured to pick Holochain’s log output format, and -f / --force-admin-ports to pin admin ports so they stay stable across runs. -f is only honoured by generate and run, and must be passed on every run:

hc sandbox -f=9000,9001 run

Without -f, ports are assigned by the OS and change every run, which is what breaks a hardcoded HC_ADMIN_PORT in a UI dev script.

Logging from inside a zome

#![allow(unused)]
fn main() {
debug!("post {:?} created by {:?}", action_hash, agent);
warn!("unexpected state: {:?}", state);
}

trace!, debug!, warn! and error! all work because every #[hdk_extern] registers a wasm-capable tracing subscriber. Spans do not: applying #[instrument] to a zome function will likely panic the wasm. Serialization failures between host and guest are already traced as error! without you doing anything.

Finding the conductor

hc sandbox list                # sandboxes in $(pwd)/.hc, with indices
hc sandbox list --verbose
hc sandbox admin-ports         # JSON array of admin ports, for scripting

admin-ports exists precisely to feed hc-client:

PORT=$(hc sandbox admin-ports | jq -r '.[0]')

Inspecting state with hc-client call

Every subcommand maps to one admin API request. Pass the admin port with --port, and --origin if the interface restricts origins.

hc-client call --port $PORT list-apps
hc-client call --port $PORT dump-state --help
SubcommandAnswers
list-apps, list-cells, list-dnas, list-app-wsWhat is installed and running
dump-stateState for one cell
dump-full-stateEverything, including the source chain. Large
dump-conductor-stateConductor-level state, not per cell
dump-op-timingsHow long ops took. Start here for “why is this slow”
dump-network-statsTransport-level connection figures
dump-network-metricsKitsune2 metrics, per space
list-agentsPeers this conductor knows about
peer-meta-infoWhat this conductor records about one peer
list-capability-grantsGrants currently in force for a cell
revoke-zome-call-capabilityRemove one
add-agentsInject agent info, for offline or test networks
install-app, uninstall-app, enable-app, disable-app, new-agentLifecycle, by hand
add-admin-ws, add-app-wsAttach interfaces at runtime

add-app-ws takes an optional app id, and the restriction it applies is worth knowing: if provided, only apps holding an authentication token issued for that same app id may connect to that interface. See the token flow in client.md.

The three questions and where to look

“Did my write land?” dump-state for the cell, then get_validation_receipts from inside the zome for per-action confirmation. See source-chain.md.

“Why can’t these two agents see each other?” list-agents on both conductors. If each knows only itself, that is bootstrap, not gossip: check bootstrap_url and whether both are on the same network_seed. If they know each other and data still is not moving, look at dump-network-metrics and dump-op-timings. See networking.md.

“Why is this call refused?” list-capability-grants for the cell, then compare against what the caller presented. See access-control.md.

Calling a zome function by hand

Two steps, because zome calls must be signed:

# 1. Mint signing credentials and grant the capability. `app_id` is positional.
hc-client zome-call-auth --port $PORT my-app

# 2. Call. All five of app_id, dna_hash, zome, function and payload are
#    positional, in that order. Payload is JSON.
hc-client zome-call --port $PORT \
  my-app \
  uhC0k... \
  posts \
  get_all_posts \
  'null'

zome-call-auth generates signing credentials and grants the capability; zome-call uses them. Payload is JSON. Both read the passphrase interactively unless you pass --piped, which reads it from stdin instead. That is the flag a script needs.

This is the fastest way to isolate whether a bug is in the zome or in the UI: if hc-client zome-call returns the right answer, the zome is fine.

Network diagnostics from TypeScript

The same figures are reachable from a client, which is often more convenient inside a UI:

const stats = await client.dumpNetworkStats();
const connected = stats.transport_stats.connections.length;
const direct = stats.transport_stats.connections.filter((c) => c.is_direct).length;

A healthy small network has most connections is_direct. A network where everything is relayed still works, and will be slower and more dependent on the relay staying up. See client.md for the full type.

Reading gossip behaviour over time

For a problem that only appears after minutes, per-call dumps are the wrong instrument. Turn on Kitsune2 reporting in the conductor config instead:

network:
  report:
    type: json_lines
    days_retained: 7
    fetched_op_interval_s: 60

Then read the resulting JSON lines rather than trying to catch the moment live. Details in networking.md.

Test-time debugging

Sweettest failures have their own tools, and they are usually the faster path: a two-agent test that reproduces the bug beats any amount of conductor archaeology. RUST_LOG and WASM_LOG both apply to Sweettest runs. See testing.md.

  • troubleshooting.md when you have a literal error string
  • networking.md for what the network figures mean
  • client.md for the admin and app API from TypeScript
  • source-chain.md for in-zome introspection and validation receipts

Workflow: Design DHT Data Model

Use this workflow when designing the data model for a new domain or feature in a Holochain hApp. Produces: entry type definitions, link type definitions, discovery strategy, and validation rules ready for implementation.


Step 1: Identify Domains and Zome Pairs

Map the business domain to Holochain’s zome architecture:

For each distinct business domain:
  → 1 integrity crate:   {domain}_integrity
  → 1 coordinator crate: {domain}

Questions to answer:

  • What are the distinct nouns in this feature? (e.g., Request, Offer, Person, Resource)
  • Which nouns belong together conceptually? (e.g., all marketplace data in one zome pair)
  • Which nouns need to be queried independently at scale? (separate zome pairs)

Output: List of zome pairs with their domain responsibilities.


Step 2: Define Entry Types Per Domain

For each entry type, define:

Entry: {EntryName}
Fields:
  - field_name: type   (required)
  - field_name: type   (required)
  - status: StatusEnum (if soft-delete needed)
  - optional_field: type   #[serde(default)]  (if backward-compatible addition)

Visibility: Public | Private
  Public: stored on DHT, visible to all agents
  Private: stored locally only, not shared

State enum (if applicable):
  enum {Entry}Status { Active, Archived, Deleted }

Decision criteria:

  • Is this data meaningful to other agents? → Public
  • Is this personal/sensitive? → Private
  • Does this entry transition through states? → Add status field with enum
  • Can this entry be “updated in place” or should old versions be preserved? → Update chain (links) vs overwrite

For every relationship between entries, define a directional link:

Link: {Base}To{Target}
  Base: {what you start from}
  Target: {what you navigate to}
  Tag: bytes | () | typed data for filtering

Required links per entry:
  ┌─ PathTo{Entry}           Discovery from global path anchor
  ├─ AgentTo{Entry}          Discovery from agent's pubkey
  └─ {Entry}Updates          Update chain tracking (for get-latest)

Optional:
  ├─ {Entry}To{Related}      Bidirectional relationship
  └─ {Related}To{Entry}      Reverse direction (add both)

Bidirectional rule: If you need to navigate A → B and B → A, create two link types. Never navigate backwards through a forward link.

Update chain rule: Every entry that supports update needs a {Entry}Updates link type that records the chain from original_action_hashupdated_action_hash.


Step 4: Choose Discovery Strategy

How will agents find entries?

PatternLinkUse When
Global path anchorPath::from("entries.active") → EntryAll agents browse all entries
Status-scoped pathPath::from("entries.active") vs "entries.archived"Browse by status
Agent-centricAgentPubKey → EntryEach agent manages their own entries
BothPath + Agent linksGlobal browse AND per-agent listing
Hierarchical pathPath::from("category.{id}.entries")Category/tag based grouping

Decision: Almost always use Both (path + agent) unless the domain is strictly personal.


Step 5: Write Validation Rules

For each entry type, define what makes it INVALID:

Validation rules for {EntryName}:
  Field constraints:
    - title: non-empty, max 200 chars
    - description: max 2000 chars
    - status: must be valid enum variant

  Business rules (that can be checked deterministically):
    - Cannot create entry with status = Deleted
    - Cannot have duplicate fields X and Y both empty
    - Tags: max 10 items, each max 50 chars

  FORBIDDEN in validation (causes non-determinism):
    - No non-deterministic reads (get, get_links). `must_get_*` IS allowed
    - No agent_info()
    - No sys_time() comparisons to current time
    - No randomness

Key rule: Validation runs in integrity. It must be pure and deterministic — same input always produces same result, regardless of when or where it runs.


Step 6: Review — Apply the Splitting Test

Before finalizing, run each design decision through the splitting test:

Entry field review:

  • Is every field necessary? (Remove if unused by UI or other zomes)
  • Are there fields that could be derived? (Remove if computable)
  • Are there fields that change independently? (May belong in a separate entry)

Link review:

  • Does every link have a clear query use case?
  • Are bidirectional links actually needed in both directions?
  • Are {Entry}Updates links present for every updatable entry?

Validation review:

  • Is every validation rule actually deterministic?
  • Are validation error messages user-readable?
  • Are there business rules that need to be enforced elsewhere (coordinator) because they require DHT reads?

Output Artifacts

After completing this workflow, you have:

  1. Zome pair list — domain to crate name mapping
  2. Entry structs (Rust) — ready to paste into integrity crate
  3. Link type enum — ready to paste into integrity crate
  4. Discovery strategy — path vs agent vs both, with path strings
  5. Validation checklist — rules ready for validate() callback
  6. Summary table:
| Entry | Links out | Update chain? | Discovery | Status enum? |
|-------|-----------|---------------|-----------|-------------|
| MyEntry | AgentToMyEntry, PathToMyEntry | Yes (MyEntryUpdates) | Path + Agent | Yes |

Proceed to implement-zome.md to implement.

Workflow: Scaffold a Holochain Project

Use this workflow to set up a new Holochain project from scratch, or to add a new domain to an existing hApp.

Architecture guarantee: both paths below (CLI and manual) produce the same standard hc scaffold project structure. When hc scaffold CLI is available use Path A. When not available (e.g., AI coding session without a running Nix shell), use manual-scaffold.md — it writes every file explicitly to produce an identical result.

Reference: ../../scaffolding.md for full details on any step.


Path A: New hApp From Scratch

Step 1 — Install Nix and Holonix

# Install Nix (Determinate Systems installer — recommended)
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install

# Enable flakes (add to ~/.config/nix/nix.conf)
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf

Restart your shell after installation. Verify: nix --version

Checkpoint: nix --version returns a version number.


Step 2 — Bootstrap a Nix Shell

To run hc scaffold, you need a Nix dev shell first. Create a bootstrap flake.nix in any temporary directory:

mkdir bootstrap-holonix && cd bootstrap-holonix
# flake.nix — bootstrap only; hc scaffold happ will generate the real one
{
  description = "Flake for Holochain app development";

  inputs = {
    holonix.url = "github:holochain/holonix?ref=main-0.7";
    nixpkgs.follows = "holonix/nixpkgs";
    flake-parts.follows = "holonix/flake-parts";
  };

  outputs = inputs@{ flake-parts, ... }: flake-parts.lib.mkFlake { inherit inputs; } {
    systems = builtins.attrNames inputs.holonix.devShells;
    perSystem = { inputs', pkgs, ... }: {
      formatter = pkgs.nixpkgs-fmt;
      devShells.default = pkgs.mkShell {
        inputsFrom = [ inputs'.holonix.devShells.default ];
        packages = (with pkgs; [
          nodejs_24
          binaryen
          bun
        ]);
        shellHook = ''
          export PS1='\[\033[1;34m\][holonix:\w]\$\[\033[0m\] '
        '';
      };
    };
  };
}

Enter the dev shell:

nix develop

Checkpoint: hc --version returns a version number inside nix develop.

No CLI available? If hc scaffold is not accessible (e.g., in an AI coding session), use manual-scaffold.md instead — it provides complete file templates for every generated file.


Step 3 — Scaffold the hApp

# From the parent directory (not inside the bootstrap dir), inside nix develop:
hc scaffold happ

The CLI will prompt for:

  • App name — e.g., my-community-app (kebab-case)
  • DNA name — e.g., community (the first domain)
  • Coordinator zome name — e.g., posts (first feature)
  • UI framework — select svelte (or your preferred framework)

hc scaffold happ creates the full project directory with all root files: flake.nix, Cargo.toml, happ.yaml (inside workdir/), package.json, .gitignore, dnas/, tests/, and a ui/ scaffold.

cd <your-app-name>
nix develop  # enter the project's own dev shell

Checkpoint: ls shows Cargo.toml, flake.nix, package.json, workdir/, and dnas/ directory.


Step 4 — Verify Cargo Workspace

Check Cargo.toml at the root uses exact version pins:

[workspace.dependencies]
hdi = "=0.8.0"
hdk = "=0.7.0"
serde = { version = "1", features = ["derive"] }

If the scaffold generated range versions (^), replace them with exact pins (=).

Why: Holochain is sensitive to minor version differences. Range deps can silently break compilation.


Step 5 — Add Entry Types

For each data type in your domain, replace MyEntry with your actual domain noun (e.g., Profile, Post, Listing):

# Inside nix develop, from project root
hc scaffold entry-type MyEntry

# Then add required link types (rename to match your entry type)
hc scaffold link-type AgentToMyEntry
hc scaffold link-type PathToMyEntry
hc scaffold link-type MyEntryUpdates

Step 6 — Verify Compilation

hc s sandbox generate workdir/

Expected: Build succeeds (may take 5-10 minutes on first run due to WASM compilation).

Common issues:

  • wasm32 target not found — you’re outside nix develop; run nix develop first
  • Slow first build — normal; wait for wasm-opt to complete

Step 7 — Set Up Tests

Sweettest (Rust) is the primary testing layer. It runs in-process, is faster, and has first-class HDK 0.7 support. See ../testing.md for full two-agent patterns.

Add a test crate to your Cargo workspace:

mkdir -p dnas/<dna_name>/tests/src

dnas/<dna_name>/tests/Cargo.toml:

[package]
name = "<dna_name>_tests"
version = "0.1.0"
edition = "2021"

[dev-dependencies]
holochain = { version = "=0.7.0", default-features = false, features = ["encryption", "wasmer-sys-cranelift", "test_utils"] }
tokio     = { version = "1", features = ["full"] }

The workspace members glob ("dnas/*/zomes/coordinator/*" etc.) does not pick up the test crate — add it explicitly:

[workspace]
members = [
    "dnas/*/zomes/coordinator/*",
    "dnas/*/zomes/integrity/*",
    "dnas/<dna_name>/tests",
]

Run with:

cargo test --package <dna_name>_tests

Checkpoint: cargo test --package <dna_name>_tests compiles (no tests yet is fine).


Step 8 — Initial Commit

git init

# Create .gitignore to exclude build artifacts
cat > .gitignore << 'EOF'
/target
/workdir
/.cargo
node_modules
dist
EOF

git add .
git commit -m "feat: scaffold initial happ structure"

Proceed to design-data-model.md to design your first domain’s data model, then implement-zome.md to implement.


Path B: Add Domain to Existing hApp

Use this path when your hApp already exists and you need to add a new feature domain.

Step 1 — Enter Dev Shell

nix develop

Step 2 — Scaffold New Zome Pair

hc scaffold zome
# Enter: domain name (e.g., "profiles")
# Select: existing DNA to add it to

Step 3 — Scaffold Entry Types

hc scaffold entry-type Profile
hc scaffold link-type AgentToProfile
hc scaffold link-type PathToProfile
hc scaffold link-type ProfileUpdates

Step 4 — Register in Cargo Workspace

Add new crates to root Cargo.toml members:

[workspace]
members = [
    # ... existing members ...
    "dnas/my_dna/zomes/integrity/profiles_integrity",
    "dnas/my_dna/zomes/coordinator/profiles",
]

Step 5 — Verify Compilation

hc s sandbox generate workdir/

Step 6 — Commit

git add .
git commit -m "feat(profiles): scaffold profiles zome pair"

Proceed to implement-zome.md to implement the domain.


Quick Reference

# Enter dev environment
nix develop

# New project
hc scaffold happ

# New domain
hc scaffold zome
hc scaffold entry-type MyEntry
hc scaffold link-type AgentToMyEntry

# Verify build
hc s sandbox generate workdir/

# Run tests
bun run test:foundation
bun run test:integration

Reference: ../../scaffolding.md for full setup details, troubleshooting, and workspace structure.

Workflow: Manual hApp Scaffold (Without the hc CLI)

Use this when hc scaffold is unavailable: an AI coding session with no Nix shell, a restricted environment, or a machine without the toolchain. The output is the same structure hc scaffold produces on Holochain 0.7.

If hc scaffold IS available, use it instead. See scaffold.md. Hand-writing the validation dispatcher is error-prone and the tool gets it right.

Templates live in assets/templates/. Copy each one to its destination and substitute the placeholders. Do not retype them from memory: they were generated by hc scaffold 0.700.0 on Holochain 0.7 and are the reference shape.


Placeholders

Every template uses the same five:

PlaceholderMeaningExample
<APP_NAME>hApp name, snake_casemy_forum
<DNA_NAME>DNA name, snake_caseforum
<ZOME_NAME>Coordinator zome name; the integrity crate is <ZOME_NAME>_integrityposts
<EntryType>Entry struct name, PascalCasePost
<entry_type>Entry name, snake_casepost

Step 1 — Directory tree

<APP_NAME>/
├── flake.nix
├── Cargo.toml
├── package.json
├── .gitignore
├── workdir/
│   ├── happ.yaml
│   └── web-happ.yaml
└── dnas/<DNA_NAME>/
    ├── workdir/dna.yaml
    └── zomes/
        ├── integrity/<ZOME_NAME>/
        │   ├── Cargo.toml
        │   └── src/{lib.rs, <entry_type>.rs, agent_to_<entry_type>.rs}
        └── coordinator/<ZOME_NAME>/
            ├── Cargo.toml
            ├── src/{lib.rs, <entry_type>.rs, agent_to_<entry_type>.rs}
            └── tests/{common.rs, <entry_type>.rs}

Note the integrity crate directory is <ZOME_NAME> while its Cargo package name is <ZOME_NAME>_integrity. hc scaffold does this too, and the --zome flag expects the package name.


Step 2 — Copy the templates

TemplateDestination
flake.nixflake.nix
Cargo.tomlCargo.toml
package.jsonpackage.json
gitignore.gitignore
happ.yamlworkdir/happ.yaml
web-happ.yamlworkdir/web-happ.yaml
dna.yamldnas/<DNA_NAME>/workdir/dna.yaml
integrity-Cargo.tomldnas/<DNA_NAME>/zomes/integrity/<ZOME_NAME>/Cargo.toml
integrity-lib.rs.../integrity/<ZOME_NAME>/src/lib.rs
integrity-entry_type.rs.../integrity/<ZOME_NAME>/src/<entry_type>.rs
integrity-link_type.rs.../integrity/<ZOME_NAME>/src/agent_to_<entry_type>.rs
coordinator-Cargo.tomldnas/<DNA_NAME>/zomes/coordinator/<ZOME_NAME>/Cargo.toml
coordinator-lib.rs.../coordinator/<ZOME_NAME>/src/lib.rs
coordinator-entry_type.rs.../coordinator/<ZOME_NAME>/src/<entry_type>.rs
coordinator-link_type.rs.../coordinator/<ZOME_NAME>/src/agent_to_<entry_type>.rs
sweettest-common.rs.../coordinator/<ZOME_NAME>/tests/common.rs
sweettest-entry_type.rs.../coordinator/<ZOME_NAME>/tests/<entry_type>.rs

Substitute the five placeholders in every copied file.


Step 3 — Things that are easy to get wrong

  • manifest_version is '0', quoted, in both happ.yaml and dna.yaml. Not '1'.
  • Pin holonix to main-0.7. main tracks the 0.8 dev line. The template already does this, but hc scaffold itself still emits ref=main, so check it if you scaffolded with the CLI.
  • nodejs_24, not 22. Holochain 0.7 moved up.
  • Exact version pins. hdi = "=0.8.0", hdk = "=0.7.0". The = matters; Holochain is sensitive to minor drift.
  • hc scaffold output does not compile against stable 0.7.0 as-is. The 0.700.0-rc generator emits ref create @ OpActivity::CreateAgent { ref action } with create.agent(), which matches the rc crates. Stable hdi 0.8.0 carries agent as a plain field: OpActivity::CreateAgent { agent, action }. The template here is already corrected.
  • Building zomes needs a RUSTFLAGS setting, see Step 4. Without it the build fails inside getrandom with a message about wasm32-unknown-unknown not being supported by default, which does not look like a Holochain problem at all.

Step 4 — Build and verify

nix develop

# The RUSTFLAGS setting is REQUIRED. Without it the build fails in getrandom
# with "The wasm32-unknown-unknown targets are not supported by default".
RUSTFLAGS='--cfg getrandom_backend="custom"' \
  cargo build --release --target wasm32-unknown-unknown

# Pack the hApp
hc app pack workdir --recursive

# Run the Sweettest suite
cargo test

package.json wires these up as npm run build:zomes, npm run build:happ and npm test, with the RUSTFLAGS already set.


Step 5 — Fill in the domain logic

The templates ship the structure with placeholder comments inside each validation function, exactly as hc scaffold generates them. Replace those comments with real rules.

Proceed to implement-zome.md for the CRUD and validation patterns, and patterns.md for the 0.7 action model those validation functions are written against.

Workflow: Implement a Zome Pair

Use this workflow when implementing a new zome pair (integrity + coordinator) for a Holochain domain. Prerequisites: data model designed (see design-data-model.md).


Step 1: Scaffold — Generate Boilerplate

Start from scaffold output to avoid blank-page overhead:

# Navigate to your DNA directory
cd dnas/my_dna

# Scaffold entry type (generates integrity + coordinator stubs)
hc scaffold entry-type MyEntry

# Scaffold link types
hc scaffold link-type AgentToMyEntry
hc scaffold link-type PathToMyEntry
hc scaffold link-type MyEntryUpdates

# Verify compilation after scaffolding
cd ../../
hc s sandbox generate workdir/

What scaffolding generates:

  • Integrity crate: entry type variant + link type variants + stub validate()
  • Coordinator crate: stub create_*, get_*, update_*, delete_* functions
  • Updated happ.yaml and dna.yaml (verify these are correct)

After scaffolding: READ the generated files before editing. Understand what’s there.


Step 2: Integrity Crate — Define Types and Validation

File: zomes/integrity/{domain}_integrity/src/lib.rs

#![allow(unused)]
fn main() {
use hdi::prelude::*;

// 1. Entry struct (from DesignDataModel output)
#[hdk_entry_helper]
#[derive(Clone, PartialEq)]
pub struct MyEntry {
    pub title: String,
    pub description: String,
    pub status: MyEntryStatus,
}

// 2. Status enum (if soft-delete pattern needed)
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum MyEntryStatus {
    Active,
    Archived,
    Deleted,
}

// 3. Entry types enum (register all entry types)
#[hdk_entry_types]
#[unit_enum(UnitEntryTypes)]
pub enum EntryTypes {
    MyEntry(MyEntry),
}

// 4. Link types enum (register all link types)
#[hdk_link_types]
pub enum LinkTypes {
    AgentToMyEntry,
    PathToMyEntry,
    MyEntryUpdates,
}

// 5. Validation callback — dispatches on the flattened op.
// This is an abridged shape. `hc scaffold` generates the full dispatcher,
// including the CreateRecord and AgentActivity arms; see patterns.md.
#[hdk_extern]
pub fn validate(op: Op) -> ExternResult<ValidateCallbackResult> {
    match op.flattened::<EntryTypes, LinkTypes>()? {
        FlatOp::CreateEntry(create_entry) => match create_entry {
            OpEntry::CreateEntry { app_entry, action } => {
                let create_action: TypedAction<EntryCreationData> = action.into();
                match app_entry {
                    EntryTypes::MyEntry(entry) => validate_create_my_entry(create_action, entry),
                }
            }
            _ => Ok(ValidateCallbackResult::Valid),
        },
        FlatOp::Link(OpLink::CreateLink { link_type, action }) => match link_type {
            LinkTypes::AgentToMyEntry => validate_create_link_agent_to_my_entry(action),
            _ => Ok(ValidateCallbackResult::Valid),
        },
        _ => Ok(ValidateCallbackResult::Valid),
    }
}

pub fn validate_create_my_entry(
    _action: TypedAction<EntryCreationData>,
    entry: MyEntry,
) -> ExternResult<ValidateCallbackResult> {
    if entry.title.trim().is_empty() {
        return Ok(ValidateCallbackResult::Invalid(
            "MyEntry title cannot be empty".into(),
        ));
    }
    Ok(ValidateCallbackResult::Valid)
}

// Link validation takes the typed create-link action. Note the type parameter:
// `CreateLinkData` for a create, `DeleteLinkData` for a delete. See patterns.md
// for the body that resolves and checks the link target.
pub fn validate_create_link_agent_to_my_entry(
    _action: TypedAction<CreateLinkData>,
) -> ExternResult<ValidateCallbackResult> {
    Ok(ValidateCallbackResult::Valid)
}
}

Step 3: Coordinator Crate — Implement CRUD

File: zomes/coordinator/{domain}/src/my_entry.rs

Implement in this order: create → get_latest → get_all → update → delete

#![allow(unused)]
fn main() {
use hdk::prelude::*;
use {domain}_integrity::*;

// CREATE
#[hdk_extern]
pub fn create_my_entry(my_entry: MyEntry) -> ExternResult<Record> {
    let hash = create_entry(&EntryTypes::MyEntry(my_entry.clone()))?;

    // Path anchor
    let path = Path::from("entries.active");
    create_link(path.path_entry_hash()?, hash.clone(), LinkTypes::PathToMyEntry, ())?;

    // Agent index
    create_link(
        agent_info()?.agent_initial_pubkey,
        hash.clone(),
        LinkTypes::AgentToMyEntry,
        (),
    )?;

    get(hash, GetOptions::default())?
        .ok_or(wasm_error!(WasmErrorInner::Guest("Record not found after create".into())))
}

// GET LATEST (walks update chain)
#[hdk_extern]
pub fn get_latest_my_entry(original_action_hash: ActionHash) -> ExternResult<Option<Record>> {
    let links = get_links(
        LinkQuery::try_new(original_action_hash.clone(), LinkTypes::MyEntryUpdates)?,
        GetStrategy::default(),
    )?;
    let latest_hash = links
        .into_iter()
        .max_by(|a, b| a.timestamp.cmp(&b.timestamp))
        .and_then(|l| l.target.into_action_hash())
        .unwrap_or(original_action_hash);
    get(latest_hash, GetOptions::default())
}

// GET ALL (from path anchor)
#[hdk_extern]
pub fn get_all_my_entries(_: ()) -> ExternResult<Vec<Record>> {
    let path = Path::from("entries.active");
    let links = get_links(
        LinkQuery::try_new(path.path_entry_hash()?, LinkTypes::PathToMyEntry)?,
        GetStrategy::default(),
    )?;
    let inputs: Vec<GetInput> = links
        .into_iter()
        .filter_map(|l| l.target.into_action_hash())
        .map(|h| GetInput::new(h.into(), GetOptions::default()))
        .collect();
    let records = HDK.with(|hdk| hdk.borrow().get(inputs))?;
    Ok(records.into_iter().flatten().collect())
}

// UPDATE
#[hdk_extern]
pub fn update_my_entry(input: UpdateMyEntryInput) -> ExternResult<Record> {
    let original = get(input.original_action_hash.clone(), GetOptions::default())?
        .ok_or(wasm_error!(WasmErrorInner::Guest("Original not found".into())))?;
    if original.action().author() != &agent_info()?.agent_initial_pubkey {
        return Err(wasm_error!(WasmErrorInner::Guest("Not authorized".into())));
    }
    let updated = update_entry(input.previous_action_hash, &EntryTypes::MyEntry(input.updated_entry))?;
    create_link(input.original_action_hash, updated.clone(), LinkTypes::MyEntryUpdates, ())?;
    get(updated, GetOptions::default())?
        .ok_or(wasm_error!(WasmErrorInner::Guest("Record not found after update".into())))
}

// DELETE
// Decision point: clean up BOTH index links (path + agent) for a full remove,
// or only the path link and leave the agent link as a historical tombstone.
// Most apps clean both. Only keep the agent link if you need "all entries ever
// created by this agent including deleted ones" semantics.
#[hdk_extern]
pub fn delete_my_entry(original_action_hash: ActionHash) -> ExternResult<ActionHash> {
    let original = get(original_action_hash.clone(), GetOptions::default())?
        .ok_or(wasm_error!(WasmErrorInner::Guest("Entry not found".into())))?;
    if original.action().author() != &agent_info()?.agent_initial_pubkey {
        return Err(wasm_error!(WasmErrorInner::Guest("Not authorized".into())));
    }

    // Clean path index (global browse)
    let path = Path::from("entries.active");
    for link in get_links(
        LinkQuery::try_new(path.path_entry_hash()?, LinkTypes::PathToMyEntry)?,
        GetStrategy::default(),
    )? {
        if link.target.into_action_hash() == Some(original_action_hash.clone()) {
            delete_link(link.create_link_hash, GetOptions::default())?;
        }
    }

    // Clean agent index (per-author listing) — omit if you want historical tombstones
    for link in get_links(
        LinkQuery::try_new(
            agent_info()?.agent_initial_pubkey,
            LinkTypes::AgentToMyEntry,
        )?,
        GetStrategy::default(),
    )? {
        if link.target.into_action_hash() == Some(original_action_hash.clone()) {
            delete_link(link.create_link_hash, GetOptions::default())?;
        }
    }

    delete_entry(original_action_hash)
}

// Input type for update (needed since update takes 3 params)
#[derive(Serialize, Deserialize, Debug)]
pub struct UpdateMyEntryInput {
    pub original_action_hash: ActionHash,
    pub previous_action_hash: ActionHash,
    pub updated_entry: MyEntry,
}
}

lib.rs — register all functions (complete example):

#![allow(unused)]
fn main() {
pub mod my_entry;

use hdk::prelude::*;
use {domain}_integrity::*;
use my_entry::UpdateMyEntryInput;

#[hdk_extern]
pub fn create_my_entry(entry: MyEntry) -> ExternResult<Record> {
    my_entry::create_my_entry(entry)
}

#[hdk_extern]
pub fn get_latest_my_entry(original_action_hash: ActionHash) -> ExternResult<Option<Record>> {
    my_entry::get_latest_my_entry(original_action_hash)
}

#[hdk_extern]
pub fn get_all_my_entries(_: ()) -> ExternResult<Vec<Record>> {
    my_entry::get_all_my_entries(())
}

#[hdk_extern]
pub fn update_my_entry(input: UpdateMyEntryInput) -> ExternResult<Record> {
    my_entry::update_my_entry(input)
}

#[hdk_extern]
pub fn delete_my_entry(original_action_hash: ActionHash) -> ExternResult<ActionHash> {
    my_entry::delete_my_entry(original_action_hash)
}
}

Note: each function in my_entry.rs already has #[hdk_extern], so the lib.rs wrappers are thin delegators. This is the standard pattern the scaffold generates.


Step 4: Utils Crate (if cross-zome calls needed)

Add to utils/src/errors.rs:

#![allow(unused)]
fn main() {
// (see ../error-handling.md for full pattern)
}

Add to utils/src/cross_zome.rs:

#![allow(unused)]
fn main() {
// (see ../patterns.md for external_local_call helper)
}

Update workspace Cargo.toml to include utils crate.


Step 5: Tests

Write tests in this order using Sweettest (Rust, cargo test) — the primary testing framework for this skill. See ../testing.md for full patterns.

Foundation (single-agent):

1. Create an entry — assert record returned
2. Get latest — assert matches created entry
3. Get all — assert list contains created entry
4. Update — assert updated fields reflected
5. Delete — assert entry gone from list

Integration (two agents):

1. Alice creates → await_consistency → Bob reads — assert cross-agent read works
2. Alice creates → await_consistency → Bob gets all — assert entry in collection
3. Alice creates → updates → await_consistency → Bob gets latest — assert latest version

Sweettest commands:

cargo test --package my_dna_tests
cargo test --package my_dna_tests two_agents  # single test

See ../testing.md for full code patterns including await_consistency_60s placement, SweetConductorBatch setup, and inline-zome shortcuts.


Step 6: Build and Verify

# Full build — verify no compile errors
hc s sandbox generate workdir/

# If build succeeds, run tests
bun run test:foundation

# After foundation passes, run integration
bun run test:integration

Common build errors:

ErrorCauseFix
cannot find type EntryTypesMissing importAdd use {domain}_integrity::*;
op.to_type() deprecatedOld APIReplace with op.flattened()
expected ExternResult, found ValidateCallbackResultWrong returnUse Ok(ValidateCallbackResult::Valid)
Link type not foundUnregistered linkAdd to #[hdk_link_types] enum in integrity
wasm-opt timeoutBuild too slowNormal for first build; subsequent builds cache

Workflow: Design Access Control

Use this workflow when you need to design who can call what zome functions, how remote signals are authorized, or how admin operations are gated.

Step 1: Identify Callers

Map every zome function to its caller type:

FunctionCallerNotes
create_postUI (same agent)No grant needed
recv_remote_signalAny remote agentNeeds Unrestricted grant
update_admin_statusAdmin agent onlyProgenitor check
get_shared_resourceSpecific partner agentAssigned grant

Questions to answer:

  • Is the caller the same agent as the cell owner? (No grant needed)
  • Can any agent call this function? (Unrestricted)
  • Can only a specific agent call this? (Assigned)
  • Can anyone with a token call this? (Transferable)

Step 2: Choose Pattern per Function

Caller scopePatternWhere
Same agent (UI)No grantN/A
Any agentCapAccess::Unrestricted in init()init() callback
Named agent(s)CapAccess::AssignedOn-demand grant creation
Token holderCapAccess::TransferableOn-demand grant creation
Admin-onlyProgenitor check in coordinatorCoordinator function body

Step 3: Design Cap Grants

For each function requiring a grant:

Function: recv_remote_signal
Grantor: self (init)
Grantee: all
Access: Unrestricted
Grant timing: init() on first run
Function: approve_member
Grantor: progenitor cell
Grantee: specific delegate agent
Access: Assigned { secret, assignees: [delegate_pubkey] }
Grant timing: progenitor creates grant on delegation
Secret distribution: progenitor sends via private entry to delegate

Step 4: Write the init() Function

For every Unrestricted grant, add to init():

#![allow(unused)]
fn main() {
#[hdk_extern]
pub fn init(_: ()) -> ExternResult<InitCallbackResult> {
    let mut functions = HashSet::new();

    // Add each function that needs an unrestricted grant:
    functions.insert((zome_info()?.name, "recv_remote_signal".into()));
    // functions.insert((zome_info()?.name, "another_open_fn".into()));

    create_cap_grant(ZomeCallCapGrant {
        tag: "open_functions".into(),
        access: CapAccess::Unrestricted,
        functions: GrantedFunctions::Listed(functions),
    })?;

    Ok(InitCallbackResult::Pass)
}
}

Step 5: Write Validation Constraints

For admin operations, the coordinator check is the enforcement point:

#![allow(unused)]
fn main() {
pub fn admin_only_function(input: AdminInput) -> ExternResult<ActionHash> {
    // Always check first — before any state mutation
    if !check_if_progenitor()? {
        return Err(wasm_error!(WasmErrorInner::Guest(
            "This function is restricted to the network progenitor.".into()
        )));
    }

    // Proceed with admin logic
}
}

For update/delete operations, also validate in the integrity zome using must_get_action():

#![allow(unused)]
fn main() {
// In integrity validate() for update ops:
let original = must_get_action(original_action_hash)?;
if action.author() != original.action().author() {
    return Ok(ValidateCallbackResult::Invalid("Not the original author".into()));
}
}

Reference

  • Cap grant patterns: ../access-control.md
  • Progenitor setup: ../architecture.md § DNA Properties
  • must_get_* authorship checks: ../patterns.md § must_get

ReviewZome Workflow

Review existing zome code against Holochain best practices, HDK 0.7 patterns, and the project’s established conventions. Run proactively before implementing any zome changes, or explicitly when asked to audit code.


Step 1 — Load context files

Always load both:

  • ../architecture.md — coordinator/integrity split, DNA roles, cross-DNA patterns
  • ../patterns.md — HDK 0.7 API, entry types, link types, CRUD, validation rules

Step 2 — Identify files in scope

If invoked proactively (PLAN phase), scope = files identified in the task plan. If invoked explicitly, scope = files provided or the current PR diff.

For each file determine: integrity zome, coordinator zome, shared types, tests.


Step 3 — Run the checklist

Work through each category. Flag every issue with severity: BLOCK (must fix before merge), WARN (should fix), NOTE (informational).

Entry Schema

  • New fields on existing entry structs have #[serde(default)] — required for schema evolution, prevents deserialization failures on existing entries
  • No agent_pub_key, created_at, or updated_at fields on entry structs (those are in the action header — access via record.action().author() / .timestamp())
  • Status enums use a dedicated enum type, not a raw String

Integrity / Validation

  • validate() uses op.flattened::<EntryTypes, LinkTypes>()? not deprecated op.to_type()
  • No non-deterministic reads inside validate() — no get(), get_links(), agent_info(), sys_time(). must_get_* is allowed and is the only sanctioned way to reach DHT state
  • New entry types are registered in the #[hdk_entry_types] enum
  • New link types are registered in the #[hdk_link_types] enum

Coordinator: HDK 0.7 API

  • delete_link(hash, GetOptions::default()), not the older single-argument form
  • LinkQuery::try_new() used for link queries (not old GetLinksInputBuilder unless specifically needed)
  • GetStrategy::Local for own-data queries; GetStrategy::Network for DHT queries
  • must_get_valid_record() used for fail-fast gets in update/delete authorship checks

Cross-Zome / Cross-DNA Calls

  • CallTargetCell::OtherRole("hrea") role name matches workdir/happ.yaml exactly
  • ZomeName(...) matches the coordinator crate name in its Cargo.toml
  • ZomeCallResponse match is exhaustive (5 variants in 0.7: Ok, Unauthorized, AuthenticationFailed, NetworkError, CountersigningSession. Unauthorized carries no AgentPubKey in 0.7)
  • No direct Cargo dependency on the remote DNA’s crate — use local mirror structs for serialization
  • If using shared utility crates: verify intra-DNA and cross-DNA call helpers match the project’s established patterns (e.g., wrapper functions in a utils crate rather than raw call() everywhere)

Error Handling

  • All fallible operations use ExternResult<T>; no .unwrap() or .expect() in zome functions
  • wasm_error!(WasmErrorInner::Guest(...)) used for domain errors (not WasmErrorInner::Host)
  • Custom error types implement From<MyError> for WasmError

Tests (Sweettest)

  • await_consistency([&cell_a, &cell_b]).await called before any cross-agent read
  • Tests use #[tokio::test(flavor = "multi_thread")] and holochain dev-dependency with test_utils feature
  • New #[hdk_extern] functions have at least one Sweettest test

Step 4 — Output findings

Group by severity:

## ReviewZome: {scope}

### BLOCK (must fix before merge)
- [ ] {file}:{issue} — {explanation}

### WARN (should fix)
- [ ] {file}:{issue} — {explanation}

### NOTE (informational)
- {file}:{observation}

### PASS
- {category}: no issues found

If no issues: “All checks pass. Ready to implement / merge.”


Step 5 — Offer to fix

If BLOCK items were found: “I can fix these now. Say ‘fix’ to proceed.” If only WARN/NOTE: “No blockers found. Suggestions above are optional improvements.”

Workflow: Package and Deploy a Holochain hApp

Guided 7-step workflow for packaging a Holochain hApp into a standalone desktop application using Kangaroo-Electron.

Reference: ../../deployment.md for full details on any step.


Step 1 — Verify Holochain Version Compatibility

Confirm your hApp targets a supported Kangaroo-Electron branch and that your conductor config is compatible.

Check your Cargo.toml versions:

hdk = "=0.7.0"
hdi = "=0.8.0"

Check for iroh transport (required for 0.6+): Ensure your conductor config or app configuration includes a relayUrl. If your app was built for 0.5.x, you must add this before deploying on 0.6+.

Checkpoint: You know which Kangaroo branch to use (main-0.7 for 0.7.x apps).

Common mistake: Using main for a production app. main follows the current line and moves without warning. Use main-0.7 unless you explicitly need cutting-edge features.


Step 2 — Set Up Kangaroo-Electron

Clone the repository and install dependencies.

git clone https://github.com/holochain/kangaroo-electron
cd kangaroo-electron
git checkout main-0.7
npm install

npm install automatically fetches and validates the conductor + lair-keystore binaries with SHA256 checksums. No manual compilation required.

Checkpoint: node_modules/ is populated and npm run start doesn’t error on missing binaries.

Common mistake: Forgetting to git checkout main-0.7 after cloning (defaults to main, which tracks the latest line).


Step 3 — Place Your Artifacts

Build your hApp outside Kangaroo and place the .webhapp bundle into pouch/.

# Build your webhapp (from your hApp project root)
hc app pack ./workdir --recursive
# or your build script:
bun run build:webhapp

# Copy the output into Kangaroo's pouch directory
cp path/to/your-app.webhapp /path/to/kangaroo-electron/pouch/

What goes in pouch/: A .webhapp file — a single bundle containing both your .happ (conductor + DNAs + zomes) and your UI assets. This is NOT the same as a .happ file, which is backend only.

UI icon: Ensure your UI assets include icon.png (≥ 256×256 px) at the UI root. This is required for desktop packaging.

Checkpoint: pouch/your-app.webhapp exists and has a non-zero file size.


Step 4 — Configure Metadata

Update the configuration files with your app’s identity.

package.json — name and version:

{
  "name": "your-app-name",
  "version": "0.1.0"
}

electron/config.ts — product identity and paths:

export const APP_ID = "com.yourorg.yourapp";         // reverse-domain, unique
export const PRODUCT_NAME = "Your App Name";          // Windows: alphanumeric + hyphens only
export const HAPP_PATH = "pouch/your-app.webhapp";

Sync version across files: Ensure package.json version and any version displayed in your UI match.

Checkpoint: APP_ID is unique to your app, PRODUCT_NAME contains no special characters, HAPP_PATH matches the file in pouch/.

Common mistake: Leaving APP_ID as the kangaroo template default — two apps with the same APP_ID will share data folders on user machines.


Step 5 — Test Locally

Verify the app works before publishing.

# Development mode (hot reload, DevTools available)
npm run start

# Production build (all configured platforms)
npm run kangaroo

Checkpoint:

  • npm run start launches the app without errors
  • UI loads and basic zome calls succeed
  • npm run kangaroo completes without errors
  • Installer in dist/ is present and installs cleanly

Common mistake: Testing only in dev mode. Production builds can fail due to code signing or asset path issues not present in dev.


Step 6 — Publish via CI

Push to the appropriate branch to trigger automated cross-platform builds.

# Unsigned builds (development / beta releases)
git push origin HEAD:release

# Code-signed builds (production releases)
git push origin HEAD:release-codesigned

GitHub Actions will build installers for Windows (.exe / .msi), macOS (.dmg), and Linux (.AppImage / .deb).

For code-signed builds, the following secrets must be set in your GitHub repo settings before pushing to release-codesigned:

  • macOS: APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID
  • Windows: WINDOWS_CERTIFICATE, WINDOWS_CERTIFICATE_PASSWORD

Checkpoint: GitHub Actions run completes. Installers are attached to the GitHub release.


Step 7 — Version Future Releases

Apply the correct version bump type for each future release.

Change typeVersion bumpUser data
Bug fix, minor enhancementPatch (0.1.0 → 0.1.1)Preserved
New features, schema changesMinor (0.1.0 → 0.2.0)Isolated (user starts fresh)
Breaking architecture changeMajor (0.1.0 → 1.0.0)Isolated
Any pre-release tagAnyAlways isolated

Rule: Only use patch bumps for updates that are backward-compatible at the data layer.


Quick Reference

Setup:   git checkout main-0.7  →  npm install
Dev:     npm run start
Build:   npm run kangaroo
Publish: git push origin HEAD:release

See also: ../../deployment.md for troubleshooting, CI secrets reference, and alternative deployment options.

Workflow: Upgrade an existing hApp from 0.6 to 0.7

For a hApp already running on Holochain 0.6. If you are on 0.5, do the 0.6 upgrade first.

Authoritative source: developer.holochain.org/resources/upgrade/upgrade-holochain-0.7. This workflow sequences it and adds what the example hApp in this repo hit in practice. Where the two disagree, the official guide wins.


Step 0 — Decide whether to go now

Three things make this more than a version bump:

  1. The action model was rewritten. Every integrity zome’s validate callback and every coordinator’s signal_action needs porting. This is the bulk of the work.
  2. tx5 and WebRTC are gone. Iroh over QUIC is the only transport.
  3. There is no data migration path. DNA hashes change even for identical DNAs, and the databases were renamed. Existing installs must clear their data.

That third point is the decision gate: every 0.7 network is a new network.

  • No production users yet → pure code port, go.
  • Users whose data matters → the port is the easy half. You also need a data strategy, and the developer experience for it is incomplete. Budget for it separately and consider whether a clean restart plus a user-facing export/import is cheaper. See migration.md.

Checkpoint: you know whether you are porting code only, or code plus user data.


Step 1 — Branch and re-pin the toolchain

Expect nothing to compile at the end of this step. That is normal.

git checkout -b upgrade/holochain-0.7

flake.nix:

holonix.url = "github:holochain/holonix?ref=main-0.7";   # was main-0.6
packages = (with pkgs; [
  nodejs_24      # was nodejs_22 (legacy-ok)
  binaryen
  perl           # add if your Sweettest build fails looking for it
]);

Do not use bare main: it now tracks the 0.8 dev line.

nix flake update && git add flake.* && nix develop
holochain --version     # expect 0.7.0

Root Cargo.toml:

[workspace.dependencies]
hdi = "=0.8.0"
hdk = "=0.7.0"

If you have a holochain dev-dependency for Sweettest, its features changed:

holochain = { version = "0.7.0", default-features = false, features = ["encryption", "wasmer-sys-cranelift"] }
# was: features = ["sqlite-encrypted", "wasmer_sys", "transport-iroh"]   (legacy-ok)

package.json: @holochain/client to ^0.21.0, @holochain/hc-spin to ^0.700.0.

Then cargo update and npm install. Delete every sandbox and conductor data directory in the repo; 0.7 cannot read them and the failure is confusing rather than explicit.

Checkpoint: cargo check fails with real API errors, not dependency-resolution errors.


Step 2 — Port the integrity zomes

This is the bulk. Do integrity first; coordinator code depends on the types.

The fastest route is not hand-porting. Scaffold a throwaway app with the same entry and link types and copy its dispatcher across:

hc scaffold -t headless web-app throwaway "tmp"
cd throwaway
hc scaffold -t headless dna d
hc scaffold -t headless zome z --integrity dnas/d/zomes/integrity/ --coordinator dnas/d/zomes/coordinator/
hc scaffold -t headless entry-type my_entry --dna d --zome z_integrity \
  --fields "title:String" --crud crud --reference-entry-hash false --no-ui

Note --zome takes the integrity package name (z_integrity), not the directory name.

The renames you are applying, in full, are in patterns.md. The short version:

  • FlatOp::StoreEntryCreateEntry, StoreRecordCreateRecord, RegisterUpdateUpdate, RegisterDeleteDelete, RegisterAgentActivityAgentActivity, and both link variants → FlatOp::Link(OpLink::…)
  • EntryCreationActionTypedAction<EntryCreationData>
  • Create/Update/Delete/CreateLink/DeleteLink…Data structs, matched on action.data
  • action.authoraction.author()
  • Link validation functions lose their base/target/tag arguments; read them off the action

Checkpoint: integrity crates compile.


Step 3 — Port the coordinator zomes

  • signal_action matches on &action.hashed.content.data with ActionData:: variants.
  • get_agent_activity takes a fourth GetOptions argument and returns AgentActivityStatus.
  • ChainFilter uses constructors: ChainFilter::take(chain_top, 10), not ChainFilter::new(chain_top).take(10).
  • Record::new takes a RecordEntry, not an Option<Entry>.
  • block_agent and unblock_agent are removed, host functions included. WASM referencing them fails to instantiate. Application-level blocking built on them needs redesigning, not porting: blocking is now a system behaviour driven by warrants.
  • must_get_agent_activity has new response variants; match them if you match exhaustively.

Checkpoint: the workspace compiles.

RUSTFLAGS='--cfg getrandom_backend="custom"' \
  cargo build --release --target wasm32-unknown-unknown

That RUSTFLAGS setting is required. Without it the build fails inside getrandom with a message about wasm32-unknown-unknown being unsupported, which does not look like a Holochain problem.


Step 4 — Conductor config and clients

Only if you maintain a conductor-config.yaml, for example in a Kangaroo build. NetworkConfig rejects unknown fields, so a leftover key is a startup failure, not a warning.

  • Remove signal_url, webrtc_config, chc_url
  • Move request_timeout_s under network
  • db_sync_strategydb_sync_level, values Full / Normal / Off
  • wasm_backend is new and optional

JavaScript, see client.md:

  • SignedActionHashed is no longer generic
  • action.hashed.content.author.header.author
  • TransportStatsApiTransportStats, is_webrtcis_direct
  • signalingServerUrlrelayServerUrl

Step 5 — Tests

Sweettest is the supported path; hc scaffold no longer generates Tryorama.

  • await_consistency has a 60 second timeout; await_consistency_s(n, ..) for a custom one
  • Conductor::install_app_with_manifest moved behind test_utils
  • mock_network was removed from holochain_p2p
  • Inline zome definitions changed: closures moved to DnaFile, InlineZome::uuid became hash
hc sandbox clean
cargo test

Step 6 — Packaging

Rebuild whatever you ship through and check its own 0.7 support first; conductor pins lag core. Kangaroo main-0.7 pins holochain 0.7.0.

Publish the new DNA hashes somewhere your users can see. The old network is not reachable from the new build.


Effort

StepRough effort
1 Re-pin toolchainhalf a day
2 Integrity zomestwo to four days
3 Coordinator zomesone to two days
4 Config and clientshalf a day
5 Testsone day
6 Packaginghalf a day, or open-ended

Port the smallest hApp first to build the vocabulary, then the larger ones.

Requirements Specification: Holochain Agent Skill

Discovery session: 2026-03-12 Status: v1 scope confirmed


Problem Statement

The Holochain developer ecosystem lacks a comprehensive AI coding assistant skill that covers the full development cycle in one place. Existing documentation is scattered across developer.holochain.org, GitHub repos, and community channels. New developers face steep learning curves; experienced developers lack a fast co-pilot for implementation patterns. This skill addresses both by providing a structured, context-aware assistant that works across the full spiral: architecture, design, scaffolding, implementation, testing, and deployment.

A secondary goal is enabling the wider Holochain community to benefit from AI-assisted development without requiring PAI (Personal AI Infrastructure) — the skill must work as a standalone agent skill with zero external dependencies.


Target Users

PersonaContextPrimary Need
Junior Holochain developerLearning the framework, first hAppGuided workflows, explanations, scaffold commands
Experienced Holochain developerActive project, knows the patternsFast pattern lookup, CRUD generation, debugging help
Full-stack developer new to HolochainKnows Rust/TypeScript, learning DHT conceptsArchitecture explanation, data model design, TypeScript client integration

Functional Requirements

IDRequirementPriorityAcceptance Criteria
FR-01Skill must cover Architecture domainMustreferences/architecture.md loads on request; covers coordinator/integrity split, DNA structure, Nix, progenitor, multi-DNA, private entries
FR-02Skill must cover Design domainMustreferences/workflows/design-data-model.md guides entry/link type design with output artifacts
FR-03Skill must cover Scaffold domainMustreferences/scaffolding.md + references/workflows/scaffold.md cover: Holonix setup, Nix flake, hc CLI, hc scaffold commands, new project workflow, add-domain-to-existing workflow
FR-04Scaffold workflow follows official Holochain documentationMustCommands and patterns reference developer.holochain.org; version pins current (hdk=0.7.0, hdi=0.8.0)
FR-05Skill must cover Implementation domainMustreferences/patterns.md covers entry types, link types, CRUD, cross-zome calls, signals, validation, HDK 0.7 API
FR-06Skill must cover Testing domainMustreferences/testing.md covers Sweettest setup, two-agent scenarios, await_consistency, update/delete patterns
FR-07Skill must cover Deployment domainMustreferences/deployment.md + references/workflows/package-and-deploy.md cover Kangaroo-Electron packaging, CI/CD, versioning
FR-08Skill must be PAI-independentMustNo voice notification curl, no SKILLCUSTOMIZATIONS hook, no PROJECTS.md references, no Algorithm routing; works in vanilla Claude Code
FR-09Skill must include installation documentationMustREADME.md with 3 installation options (global, project-local, symlink), quick start examples
FR-10Domain correspondence with PAI versionShouldSame sections, same knowledge depth, same workflow structure — different wrappers
FR-11Skill routing covers all 5 workflowsMustSKILL.md routing table maps natural language triggers to correct workflows
FR-12Context files load on demandMustSKILL.md specifies which context file to load for each topic; not all pre-loaded

Non-Functional Requirements

IDCategoryRequirementTarget
NFR-01PortabilityWorks with zero PAI infrastructureVerified by install in fresh Claude Code with no ~/.claude/PAI/
NFR-02CurrencyVersion pins match current stable Holochainhdk=0.7.0, hdi=0.8.0, holonix ref=main-0.7 at release
NFR-03CompletenessAll 6 domains have contentNo stub files in v1 release
NFR-04AccuracyCode examples compile and run correctlyExamples tested against real hAppenings/Nondominium codebase

Constraints

  • v1 conforms to Agent Skills Open Standard — compatible with Claude Code, GitHub Copilot, Cursor, Augment, and Codex
  • Ecosystem expansion deferred to v2: hREA, unyt, ADAM, and deep holochain-open-dev component patterns are not in scope. Wind Tunnel ships in v1.0 (references/wind-tunnel.md), and SKILL.md carries a companion-library currency table so users are not steered at 0.6-era tooling
  • Two independent codebases for v1 — PAI version and vanilla version developed separately; integration/merge post-v1
  • No GUI in v1 — visual tooling, diagram generation, and no-code interfaces are v3+ vision
  • Official docs anchor — Scaffold workflow must follow developer.holochain.org, not invent conventions

Decisions

Settled for v1.0 on 2026-08-17.

QuestionDecisionEvidence
Public repo locationPersonal GitHub, Soushi888/holochain-agent-skills, Apache-2.0. Moving to a community org stays possible later and costs nothing nowThe repo is already there; LICENSE is Apache-2.0
Contribution modelOpen from day oneCONTRIBUTING.md, CODE_OF_CONDUCT.md (Contributor Covenant 2.1), issue forms including a dedicated stale-version report, and a PR template all ship in v1.0

Still open, needs a maintainer call

  • Community discovery: how to publicize to the Holochain developer community. Candidates are the Holochain Discord, the developer forum, and the holochain-open-dev orbit. Not a code decision, so it does not gate v1.0.
  • PAI merge trigger: time-based or milestone-based. Out of scope for the public repo, which must stay PAI-independent by constraint.

Deferred (v2+)

FeatureTarget VersionDescription
hREA / ValueFlows sub-skillv2Scaffold and implement ValueFlows-compatible zomes
holochain-open-dev patternsv2Profiles, links to other happs, linked devices
ADAM (coasys) integrationv2AD4M perspectives and expression languages
unyt integrationv2Unit-aware numeric types for resource tracking
Holo hosting / edge nodesv2HTTP gateway, HolOS, Holo Node ISO setup
Cross-LLM portabilityv2Adapt for GLM 5, other AI clients with skill support
Skill graph / ecosystem orchestratorv2Parent skill routing to domain sub-skills
GUI / visual programmingv3+No-code interface with DHT model explorer
Diagram generationv3+Visual architecture and data flow diagrams
Progressive disclosure UIv3+Junior/senior mode switching

Roadmap

v1 - Core Spiral (SHIPPED 2026-08-17, v1.0.0)

Theme: Everything needed to build, test, and deploy a Holochain hApp from scratch.

Domains: Architecture, Design, Scaffold, Implement, Test, Deploy

Workflows:

  • DesignDataModel — DHT entry/link type design with validation rules
  • Scaffold — New project and new domain scaffolding workflows
  • ImplementZome — Full CRUD zome implementation
  • DesignAccessControl — Capability grants and admin patterns
  • PackageAndDeploy — Kangaroo-Electron packaging and CI/CD
  • ReviewZome — Proactive code review checklist

Context files shipped ahead of schedule:

  • references/wind-tunnel.md — Performance/load testing with wind-tunnel (originally v2)

Target: All Agent Skills-compatible tools (Claude Code, GitHub Copilot, Cursor, Augment, Codex)


v2 — Ecosystem Expansion

Theme: Connect to the broader Holochain ecosystem. Cross-hApp and cross-network patterns.

Planned additions:

Sub-skills

  • hREA / ValueFlows — Scaffold and implement ValueFlows-compatible economic resource tracking; EconomicEvent, EconomicResource, Process entry types; REA ontology patterns
  • holochain-open-dev — Community-standard patterns: Profiles zome, linked devices, file storage, notifications
  • ADAM (coasys) — AD4M perspectives, expression languages, cross-hApp linking
  • Holo Hosting — HTTP gateway setup, edge node configuration, Holo Node ISO, HolOS
  • Unyt — Holochain Foundation’s P2P accounting and payment infrastructure; Alliance setup and configuration, Smart Agreements (RHAI scripting, three-layer template/agreement/RAVE architecture), transaction types (Pay, Request, Trade), inter-network and EVM bridging, agent onboarding via Joining Service REST API, Pricing Oracle integration, and deployment with tauri-plugin-holochain

Architecture improvements

  • Skill graph: parent orchestrator routing to sub-skills
  • Cross-LLM portability (GLM 5, any client with skill support)

v3 — GUI and Visual Tooling

Theme: Make Holochain accessible without deep framework knowledge. From developers to builders.

Vision:

  • Visual DHT data model explorer — design entry/link types through a diagram interface
  • No-code workflow UI — guided scaffold and deploy without terminal commands
  • Architecture diagram generation — auto-generate from zome code
  • Progressive disclosure — beginner mode (guided, verbose) vs. expert mode (fast, terse)
  • Monitoring integration — visual DHT health, gossip status, conductor logs

Inspiration: Holo Node ISO’s web-based Node Manager shows the direction — powerful infrastructure made accessible through UI. This skill’s v3 applies the same principle to development tooling.


PAI Integration (post-v1)

Once both the PAI version and vanilla version are field-tested:

  1. Audit differences — what did each version evolve to independently?
  2. Extract shared knowledge — create canonical knowledge files usable by both
  3. Layer PAI on top — PAI SKILL.md wraps shared files and adds PAI-specific features (voice, project routing, Algorithm integration)
  4. Publish shared core — vanilla skill becomes the community baseline; PAI version is a superset

Version History

VersionDateChanges
0.1.02026-03-12Initial vanilla skill — 6 domains, 5 workflows, requirements spec
0.1.12026-03-12Agent Skills Open Standard conformance, multi-platform README, testing plan
0.1.22026-05-15Version bump to Holochain 0.6.1 (hdk=0.6.1, hdi=0.7.1); references/wind-tunnel.md shipped ahead of schedule
0.2.02026-05-15Expanded progenitor pattern: full DnaProperties setup, bootstrap mode (Option<AgentPubKey>), integrity validation enforcement, is_progenitor hdk_extern, init() bootstrap, deploy-time injection via Kangaroo roles_settings; based on Requests & Offers implementation
1.0.02026-08-17First stable release. Holochain 0.7 only (hdk=0.7.0, hdi=0.8.0, holonix main-0.7). Repository restructured to references/ + assets/ + scripts/. Example hApp compiles and its Sweettest suite passes. Countersigning, scheduling, cryptography and warrants documented. Validator, version bumper and routing eval in CI. Community files added. Verified end to end by a second model producing code that compiled on first try

Packaging Architecture

How this repository turns one directory of markdown into something four different kinds of consumer can install, and why each decision went the way it did. Written alongside the v1.0 packaging work; read it before changing anything under scripts/, nix/, package.json or the files array.

The problem it solves

Before v1.0 the repository root was the skill. Anything that fetched the repository got the whole workshop. Nondominium’s flake.nix did

rsync -a --delete ${inputs.holochain-agent-skill}/ .claude/skills/holochain/

which put book.toml, SUMMARY.md, CHANGELOG.md, CLAUDE.md, docs/ and .github/ inside the installed skill. Every one of those files is context an agent may read and none of it is skill content.

It was also a spec violation. agentskills.io requires the frontmatter name to equal the parent directory name. The frontmatter said holochain and the directory said holochain-agent-skill. It worked only because every installer renames on copy, which means the repository was relying on a behaviour the spec does not promise.

Requirements

Functional. One payload, reachable four ways: an npm package, a downloadable archive, a Nix derivation, and a git clone. An agent handed only the repository URL must be able to install unaided. Adding a second skill must be a new directory, not a restructure.

Non-functional. No artefact may contain a file outside the payload, and that must be asserted rather than trusted. No gate may pass vacuously. Archives must be reproducible, so a checksum published in a Nix expression stays valid. Nothing in the pipeline may block on human input, because the primary caller is an agent.

Constraint. The repository is documentation plus one compiling reference hApp. It has no application runtime, so the packaging layer must not introduce one: zero runtime dependencies, and every gate runnable from a POSIX shell.

Shape

┌────────────────────────────────────────────────────────────────────────┐
│ SOURCE OF TRUTH   skills/<name>/                                       │
├────────────────────────────────────────────────────────────────────────┤
│ SKILL.md   references/   assets/   LICENSE                             │
│                                                                        │
│ The only tree that ships. Everything else in the repo is               │
│ workshop and must never reach a consumer.                              │
└────────────────────────────────────────────────────────────────────────┘
                                    │                                     
                                    ▼                                     
┌────────────────────────────────────────────────────────────────────────┐
│ GATES   run before any artefact is built                               │
├────────────────────────────────────────────────────────────────────────┤
│ validate-skill.sh      structure, routing, links, pins, dead APIs      │
│ run-eval.sh            routing regression floor (65%)                  │
│ check-versions.sh      the four version declarations agree             │
└────────────────────────────────────────────────────────────────────────┘
                                    │                                     
                                    ▼                                     
┌────────────────────────────────────────────────────────────────────────┐
│ BUILDERS   one payload, three artefact shapes                          │
├────────────────────────────────────────────────────────────────────────┤
│ npm pack               files: [skills, bin, README.md, LICENSE]        │
│ build-release-assets   tar.gz + zip, root = one dir per skill          │
│ nix/skill.nix          derivation, root = the skill itself             │
└────────────────────────────────────────────────────────────────────────┘
                                    │                                     
                                    ▼                                     
┌────────────────────────────────────────────────────────────────────────┐
│ CHANNELS   what a consumer reaches for                                 │
├────────────────────────────────────────────────────────────────────────┤
│ bunx holochain-agent-skills install --yes      (agents, humans)        │
│ curl ... | tar -xz -C .claude/skills           (no node)               │
│ lib.mkSkillsHook / packages.<system>.holochain (Nix)                   │
│ node bin/install.mjs install --link            (skill authors)         │
└────────────────────────────────────────────────────────────────────────┘
                                    │                                     
                                    ▼                                     
┌────────────────────────────────────────────────────────────────────────┐
│ HARNESS PATHS   resolved by detection, never assumed                   │
├────────────────────────────────────────────────────────────────────────┤
│ .claude/skills   .agents/skills   .opencode/skills                     │
│ .github/skills   .gemini/skills   .cursor/skills                       │
└────────────────────────────────────────────────────────────────────────┘

Everything flows one way. The payload is never assembled from anything but skills/, the gates always run before a builder, and no builder reaches into the workshop.

Components

skills/<name>/ — the payload

Plural skills/, singular skill directories. The plural buys the v2 ecosystem skills (skills/hrea/, skills/holochain-open-dev/) without another restructure, and it matches the layout the npm skill-manager ecosystem already indexes. The singular directory name is not a style choice: the spec ties it to the frontmatter name, and installers derive the install path from it.

scripts/validate-skill.sh — the structural gate

Every path derives from a single SKILL_DIR, so adding a second skill is a loop rather than a rewrite. Routing targets inside SKILL.md stay relative to the skill root, so the skill is position-independent: the same SKILL.md works at skills/holochain/ in the repo and at .claude/skills/holochain/ once installed. Only the validator knows about the prefix.

It gained a name-matches-directory check with the move, which is the check that would have caught the original violation.

scripts/install.ts — the installer

Compiled to bin/install.mjs with bun build --target=node, so the source is TypeScript and the published binary runs under plain node with no dependencies. Both entry points resolve the payload identically, one level up from the file.

Its decision tree exists because the primary caller is an agent, not a person:

DetectedTTYBehaviour
noneeither.claude/skills and .agents/skills, and says so
exactly oneeitherthat one, silently
more than oneyesnumbered prompt, Enter for all
more than onenoall of them, prints the list

A blocking prompt in the no-TTY row would be a hang, not a question. That row is the whole reason the tree exists.

The harness table carries a source citation per path. This is the one table in the package where being wrong is silent: installing into a directory nothing scans looks exactly like a successful install. Nothing goes in it from recall.

scripts/build-release-assets.sh — the archives

Archive root is one directory per skill, not a versioned directory, so tar -xzf ... -C .claude/skills is genuinely one command. A versioned root would force a --strip-components dance, and the entire point of the curl fallback is that it is one line somebody can paste.

Content comes from git archive, so an untracked target/ in a working tree cannot leak into a release.

flake.nix, nix/skill.nix, nix/mk-skills-hook.nix — the Nix surface

Two output shapes, deliberately:

  • packages.<system>.<name> is rooted at the skill, so ${it}/SKILL.md exists and it can be rsynced or symlinked straight into a harness directory.
  • packages.<system>.default is the bundle, one directory per skill, the same shape as the release archive.

nix/skill.nix runs the validator in its check phase. A broken routing path fails nix build rather than reaching a project.

mkSkillsHook exists because every consumer writes the same rsync glue and gets the same thing wrong. Measured: without --chmod=u+w the materialised tree comes out dr-xr-xr-x / -r--r--r--, and a later mkdir inside it fails with “Permission denied” while a repeat rsync still succeeds. The symptom therefore never appears where it was caused, which is why it reads as an unrelated shellHook failure.

Decisions and trade-offs

#DecisionRejected alternativeWhy
D1Payload under skills/<name>/skill/ singularPlural leaves room for the v2 ecosystem skills and matches what npm skill managers index
D2Repo holochain-agent-skills, skill holochainMatch themThe spec ties the directory to the frontmatter name; the repo is free to describe the collection
D3Explicit install commandnpm postinstallbun add must never write into a consumer’s tree. An install that mutates a project by surprise is worse than one extra command
D4Unscoped npm name at 1.0Wait for @holochainThe scope belongs to the Holochain Foundation. Ask after shipping; never let a release wait on a third party’s decision
D5Detection by marker directoryDetect the skills directoryThe skills directory usually does not exist yet, which is exactly the case an installer is for, so testing for it detects nothing
D6Two Nix output shapesOneConsumers want the skill root; the bundle mirrors the archive. Collapsing them makes one of the two callers wrong
D7Assert tarball contents in CITrust the files arrayThe array is the only thing between a consumer and a skill directory full of CI config, and it is one typo wide
D8Reproducible archivesShip whatever tar emitsA checksum pinned in a Nix expression must stay valid across rebuilds of the same commit

Failure modes, and what catches each

FailureCaught byFails at
Workshop file reaches a consumerpackage CI job asserting the pack manifestpull request
Frontmatter name drifts from the directoryvalidator, and again in the Nix check phasepull request and nix build
A routing target is deleted or renamedvalidator resolution checkpull request
A removed-in-0.7 API enters a templatevalidator forbidden-API scan over shipped codepull request
Version declarations drift apartcheck-versions.sh, three in-tree and again against the tagpull request and release
The installer stops installingsmoke test running it for real into a scratch projectpull request and release
An archive changes shapeextract-and-assert steppull request and release
A published checksum stops matchingcheck-reproducible.sh, asserting the fixed epoch on every memberpull request and release

The pattern is that every gate has a negative probe. A gate that has never been observed failing is a gate nobody has tested.

Consumer migration: Nondominium

Nondominium is the only known consumer of the pre-1.0 layout. Its flake.nix takes the repository as a non-flake source input and rsyncs the tree root into three harness directories through a local nix/agent-skills.nix.

The path is not the interesting part of this migration. Nondominium pins holonix ref=main-0.6, and the v1.0 skill is Holochain 0.7 only, by decision, with all 0.6 API content deleted. Pointing a 0.6 codebase at the 1.0 skill would hand its agents an authoritative-looking reference for an action model that project does not use. That is worse than a stale skill, because it reads as current.

So the migration is two moves, not one, and they are ordered:

Move 1, now: pin the last 0.6-era release and say why. v0.2.0 predates the restructure, so its tree root is still the skill and no path change is needed. This is a one-line edit that stops nix flake update from silently pulling 1.0 onto a 0.6 project.

# Holochain 0.6 content. v1.0.0+ is 0.7-only and would misdescribe this
# codebase; bump it in the same PR that moves holonix to main-0.7.
holochain-agent-skill = {
  url   = "github:Soushi888/holochain-agent-skills/v0.2.0";
  flake = false;
};

Move 2, inside Nondominium’s own 0.7 upgrade PR: take the 1.0 skill, adopt the subdirectory, and delete the local glue. mkSkillsHook replaces nix/agent-skills.nix entirely.

inputs.holochain-agent-skills.url =
  "github:Soushi888/holochain-agent-skills/v1.0.0";   # a flake from 1.0 on

# in shellHook, replacing the agentSkillsHook call:
${inputs.holochain-agent-skills.lib.mkSkillsHook {
  inherit pkgs;
  skills = [
    { src = inputs.holochain-agent-skills.packages.${system}.holochain;
      name = "holochain"; }
    { src = "${./pai/claude}/skills/nondominium-domain";
      name = "nondominium-domain"; }
    { src = "${./pai/claude}/skills/complexity-oriented-programming";
      name = "complexity-oriented-programming"; }
  ];
}}

Two notes for whoever does move 2. mkSkillsHook passes --chmod=u+w itself, so the skills no longer need Nondominium’s chmod -R u+w workaround; the pai/claude materialisation above it still does, since that rsync is unrelated. And mkSkillsHook defaults to .claude, .cursor and .agents, which is exactly the three paths the local helper hardcoded, so the behaviour is unchanged.

Acceptance for both moves: nix develop produces .claude/skills/holochain/SKILL.md and no book.toml anywhere beneath it.

What this design does not do

No registry, no update checker, no telemetry, no version negotiation between a skill and a harness. A skill is a directory of markdown; the moment the packaging layer becomes more complicated than the thing it packages, it has failed.

Testing Plan: Holochain Agent Skill v1

Status: Pre-release checklist Target: v1.0.0 release gate

All tests are manual unless marked [auto]. Check each box before cutting a release.


T1 — Agent Skills Open Standard Conformance

Validate that SKILL.md frontmatter meets the Agent Skills Open Standard spec.

head -25 SKILL.md
#TestPass Condition
T1.1name field presentKey exists in frontmatter
T1.2name is lowercaseValue is holochain (no uppercase, no spaces)
T1.3name uses only alphanumeric + hyphensRegex: ^[a-z0-9-]+$
T1.4description field presentKey exists
T1.5description is between 1 and 1024 characterswc -c on the value
T1.6description mentions primary use casesContains: “zome”, “HDK”, “Holochain”
T1.7license field is Apache-2.0Value exactly matches
T1.8compatibility field present and non-emptyKey exists, value not blank
T1.9metadata.author is soushi888Value matches
T1.10metadata.version is presentKey exists, SemVer format
T1.11metadata.holochain-versions references current pinsContains hdk=0.7.0, hdi=0.8.0, holonix ref=main-0.7

T2 — File Integrity

Verify every file referenced in SKILL.md routing tables actually exists.

ls Workflows/*.md
ls *.md
#FileExists?
T2.1references/workflows/design-data-model.md
T2.2references/workflows/scaffold.md
T2.3references/workflows/implement-zome.md
T2.4references/workflows/design-access-control.md
T2.5references/workflows/package-and-deploy.md
T2.6references/architecture.md
T2.7references/scaffolding.md
T2.8references/patterns.md
T2.9references/access-control.md
T2.10references/cell-cloning.md
T2.11references/error-handling.md
T2.12references/testing.md
T2.13references/client.md
T2.14references/deployment.md
T2.15LICENSE at repo root
T2.16README.md at repo root

T3 — Routing Accuracy

For each Workflow Routing entry in SKILL.md, verify the trigger resolves to the correct file and the file’s content matches the described purpose.

#Trigger phraseExpected fileContent check
T3.1“design data model”references/workflows/design-data-model.mdContains Step 1 (domains/zome pairs) and Step 2 (entry type definition)
T3.2“new happ”references/workflows/scaffold.mdContains Nix install and hc scaffold happ commands
T3.3“implement zome”references/workflows/implement-zome.mdContains hc scaffold entry-type and integrity/coordinator structure
T3.4“who can call”references/workflows/design-access-control.mdContains CapAccess::Unrestricted, CapAccess::Assigned
T3.5“package”references/workflows/package-and-deploy.mdContains Kangaroo-Electron setup steps

For each Context Files entry in SKILL.md:

#Load-when triggerExpected fileContent check
T3.6coordinator/integrity splitreferences/architecture.mdContains hdi and hdk crate explanation
T3.7Nix flake setupreferences/scaffolding.mdContains nix develop and flake.nix
T3.8entry types, CRUDreferences/patterns.mdContains #[hdk_entry_helper] and create_entry()
T3.9cap grantsreferences/access-control.mdContains CapAccess::Unrestricted and init()
T3.10cell cloningreferences/cell-cloning.mdContains createCloneCell and clone_limit
T3.11WasmErrorreferences/error-handling.mdContains WasmError and ExternResult
T3.12Sweettest testsreferences/testing.mdContains await_consistency and two-agent scenario
T3.13holochain-clientreferences/client.mdContains callZome and signal handling
T3.14packaging, Kangarooreferences/deployment.mdContains .webhapp and versioning guidance

T4 — Content Coverage

Verify each of the 6 skill domains has substantive (non-stub) content.

#DomainPrimary filePass condition
T4.1Architecturereferences/architecture.md> 100 lines, covers integrity/coordinator split
T4.2Designreferences/workflows/design-data-model.mdHas at least 4 numbered steps with examples
T4.3Scaffoldreferences/scaffolding.md + references/workflows/scaffold.mdContains nix develop, hc scaffold happ, Nix flake template
T4.4Implementreferences/patterns.mdContains CRUD patterns, link types, validation section
T4.5Testreferences/testing.mdContains Sweettest setup, await_consistency, two-agent example
T4.6Deployreferences/deployment.md + references/workflows/package-and-deploy.mdContains kangaroo-electron, .webhapp bundling, versioning

T5 — Code Example Accuracy

Validate specific API calls against the actual HDK 0.7 API (use the hAppenings or Nondominium codebase as reference).

HDK / HDI API

#Example to validateExpected formFile
T5.1Entry type macro#[hdk_entry_helper] on structreferences/patterns.md
T5.2Entry type enum in integrity#[hdk_entry_types] on enum with #[unit_enum(UnitEntryTypes)]references/patterns.md
T5.3Create entrycreate_entry(EntryTypes::MyEntry(entry))references/patterns.md
T5.4Get entryget(hash, GetOptions::default()) or must_get_entry(hash)references/patterns.md
T5.5Delete linkdelete_link(link_hash, GetOptions::default()) (second arg required in 0.6)references/patterns.md
T5.6Link types enum#[hdk_link_types] on enumreferences/patterns.md
T5.7Update chain trackingcreate_link(original_hash, new_hash, LinkTypes::EntryUpdates, ())references/patterns.md
T5.8Validation signaturepub fn validate(op: Op) -> ExternResult<ValidateCallbackResult>references/patterns.md
T5.9post_commit infallible#[hdk_extern(infallible)] + pub fn post_commit(...)references/architecture.md or references/patterns.md
T5.10Remote signal cap grantCapAccess::Unrestricted grant created in init()references/access-control.md
T5.11consistency callawait_consistency([&alice_cell, &bob_cell])references/testing.md
T5.12Scaffold compile checkhc s sandbox generate workdir/references/workflows/implement-zome.md

Version pin consistency [auto]

grep -rn "hdk\s*=\s*\"=" . --include="*.md" --include="*.toml" | grep -v Plans/
grep -rn "hdi\s*=\s*\"=" . --include="*.md" --include="*.toml" | grep -v Plans/
grep -rn "holonix" . --include="*.md" | grep -v Plans/
#CheckExpected valuePass condition
T5.13hdk pin in SKILL.md Quick Reference"=0.7.0"All occurrences match
T5.14hdi pin in SKILL.md Quick Reference"=0.8.0"All occurrences match
T5.15holonix ref in SKILL.md and references/scaffolding.mdmain-0.7All occurrences match
T5.16No file references hdk = "0.6.*" or olderZero matches
T5.17PackageAndDeploy.md Cargo.toml example pins match currenthdk = "=0.7.0"Matches T5.13

T6 - Installation Tests

Every method below is now covered by an automated job in .github/workflows/validate.yml (the package and nix jobs). Run them by hand only when changing the installer, the files array, the archive builder or the flake. Each one starts from a clean scratch directory.

Option A - release archive, the documented default

mkdir -p /tmp/t6a/.claude/skills && cd /tmp/t6a
curl -fsSL https://github.com/Soushi888/holochain-agent-skills/releases/download/v1.0.0-rc.1/holochain-agent-skills.tar.gz | tar -xz -C .claude/skills
#TestPass condition
T6.1Skill installed at the printed path.claude/skills/holochain/SKILL.md exists
T6.2references/ present.claude/skills/holochain/references/workflows/ holds 8 files
T6.3assets/templates/ present17 template files copied
T6.4No workshop files leakedNo book.toml, SUMMARY.md, CHANGELOG.md, CLAUDE.md, docs/ or scripts/ under the installed directory
T6.5No build output copiedNo target/ or node_modules/ under the installed directory

Option B - harness detection, via the bundled installer

Build it from a clone first (bun run build), then run node <clone>/bin/install.mjs install from each scratch directory. Registry publishing is off, so this is how the installer is reached; the detection logic itself is unchanged.

#TestSetupPass condition
T6.6Nothing detectedEmpty directoryFalls back to .claude/skills and .agents/skills, and says so
T6.7Exactly one detectedmkdir .cursorInstalls to .cursor/skills without asking
T6.8Several detected, no TTYmkdir .claude .opencode .github, run with stdin closedInstalls to all three, does not prompt, does not hang
T6.9Several detected, TTYSame, under a ptyPrompts, honours a numeric selection, installs only what was chosen
T6.10Unknown target--target nopeExits 2 and names the known targets

Option C - archive integrity and the stable URL

# a stable (non-prerelease) tag also answers at the latest/download URL;
# a prerelease is skipped by it by design, so name the tag for a candidate
mkdir -p /tmp/t6c/.claude/skills && cd /tmp/t6c
curl -fsSL https://github.com/Soushi888/holochain-agent-skills/releases/latest/download/holochain-agent-skills.tar.gz | tar -xz -C .claude/skills
#TestPass condition
T6.11One command, correct shape.claude/skills/holochain/SKILL.md exists, no rename step needed
T6.12Checksums verifysha256sum -c SHA256SUMS passes for every asset
T6.13Archives are reproducibleTwo builds of the same commit produce identical SHA256SUMS
git clone https://github.com/Soushi888/holochain-agent-skills ~/holochain-agent-skills
cd /tmp/t6d && node ~/holochain-agent-skills/bin/install.mjs install --link
#TestPass condition
T6.14Symlink created.claude/skills/holochain is a symlink to skills/holochain in the clone
T6.15git pull propagatesPull in the clone, the symlinked install sees the change immediately
T6.16Reinstall over a symlinkA subsequent plain install replaces the symlink with a real directory rather than failing

Option E - Nix

#TestCommandPass condition
T6.17Skill derivation is rooted at the skillnix build .#holochain$out/SKILL.md exists
T6.18Bundle matches the archive shapenix build .#default$out/holochain/SKILL.md exists
T6.19The derivation gates on the validatorSeed a frontmatter/directory name mismatchnix build fails with that message
T6.20Flake checks passnix flake checkExit 0
T6.21mkSkillsHook survives a second shellEnter a consumer devShell twiceNo permission error on the second entry

T7 — Invocation Tests

Verify the skill loads and responds correctly in Claude Code.

#TestStepsPass condition
T7.1Explicit command invocationType /holochain in Claude CodeSkill loads, greets with Holochain context
T7.2Natural language trigger — workflowType “implement zome for Profile entry type”references/workflows/implement-zome.md guidance appears
T7.3Natural language trigger — context fileType “how do I set up a Sweettest test?”references/testing.md content cited
T7.4Natural language trigger — scaffoldType “scaffold a new happ called my-network”references/workflows/scaffold.md steps appear
T7.5Version questionAsk “what version of hdk does this skill target?”Responds with 0.7.0
T7.6Out-of-scope questionAsk a non-Holochain questionSkill does not answer as if it’s Holochain-related

T8 — PAI Independence

Verify the skill works in a clean Claude Code environment with no PAI infrastructure.

#TestStepsPass condition
T8.1No ~/.claude/PAI/ requiredTemporarily rename ~/.claude/PAI/ to ~/.claude/PAI_bak/, invoke skillSkill loads without error
T8.2No voice curl in skill filesgrep -r "localhost:8888" .Zero matches
T8.3No Algorithm routing referencesgrep -r "ALGORITHM|AlgorithmMode|PAI/Algorithm" .Zero matches in skill files
T8.4No PROJECTS.md referencesgrep -r "PROJECTS.md" .Zero matches in skill files
T8.5Restore PAI after testmv ~/.claude/PAI_bak ~/.claude/PAIRestore before next session

T9 — Workflow End-to-End Tests

For each workflow, walk through the steps in Claude Code with a real or simulated project and verify guidance is accurate and complete.

T9.A — DesignDataModel

Trigger: “design data model for a marketplace listing”

#StepPass condition
T9.A.1Step 1: Identify domainsSkill asks or describes how to map business nouns to zome pairs
T9.A.2Step 2: Define entry typesProduces a Rust struct definition with field types
T9.A.3Step 3: Define link typesProduces at least AgentTo*, PathTo*, *Updates link types
T9.A.4Step 4: Discovery strategyExplains Path anchor vs. agent-linked discovery tradeoffs
T9.A.5Step 5: Validation rulesProduces at least one validation rule per entry type
T9.A.6Output completenessProduces a summary table or structured output usable as implementation spec

T9.B — Scaffold

Trigger: “scaffold new happ called community-app”

#StepPass condition
T9.B.1Nix install stepProvides curl Determinate Nix installer command
T9.B.2flake.nix creationProvides template with holonix ref=main-0.7
T9.B.3hc scaffold happ commandCorrect command with app name parameter
T9.B.4First DNA scaffoldhc scaffold dna command shown
T9.B.5First zome pair scaffoldhc scaffold zome for integrity + coordinator
T9.B.6Compile verificationhc s sandbox generate workdir/ step present

T9.C — ImplementZome

Trigger: “implement zome for Profile entry type”

#StepPass condition
T9.C.1Scaffold stephc scaffold entry-type Profile and link-type commands shown
T9.C.2Integrity crateProduces Profile struct with #[hdk_entry_helper], entry type enum
T9.C.3Validation functionProduces validate() function with Op pattern matching
T9.C.4Coordinator — createProduces create_profile() using create_entry()
T9.C.5Coordinator — readProduces get_profile() using get() with GetOptions::default()
T9.C.6Coordinator — updateUses update_entry() and create_link() for update chain
T9.C.7Coordinator — deleteUses delete_entry() and handles link cleanup
T9.C.8Test scaffoldProduces at minimum a two-agent Sweettest structure

T9.D — DesignAccessControl

Trigger: “design access control for my admin zome”

#StepPass condition
T9.D.1Caller mapping tableProduces table of function → caller type
T9.D.2Unrestricted grantShows init() with CapAccess::Unrestricted for remote signals
T9.D.3Progenitor checkShows dna_info().provenance check for admin-only functions
T9.D.4Assigned grantShows CapAccess::Assigned pattern with agent key
T9.D.5recv_remote_signalShows correct extern signature and cap grant pairing

T9.E — PackageAndDeploy

Trigger: “package my happ for desktop distribution”

#StepPass condition
T9.E.1Version compatibility checkAsks for or checks hdk/hdi versions before proceeding
T9.E.2Kangaroo-Electron setupgit clone command for Kangaroo repo shown
T9.E.3.happ bundle stephc app pack or equivalent command shown
T9.E.4.webhapp bundle stepUI + .happ combined packaging step shown
T9.E.5Versioning guidanceExplains semantic version bump for DNA updates vs UI-only updates
T9.E.6CI/CD noteAt minimum mentions GitHub Actions or manual release process

T10 — Cross-Tool Compatibility

Claude Code (primary)

Covered by T7 and T9 above.

GitHub Copilot

#TestPass condition
T10.1Install to .claude/skills/holochain/ in project rootDirectory exists with SKILL.md
T10.2Copilot agent mode recognizes skillSkill name holochain appears in available skills list
T10.3Basic invocationCopilot responds with Holochain context when asked about zomes

Cursor

#TestPass condition
T10.4Install to .claude/skills/holochain/ in project rootDirectory exists with SKILL.md
T10.5Cursor agent detects skillSkill is listed or referenced in agent context
T10.6Basic invocationCursor responds with Holochain guidance when triggered

Augment Code

#TestPass condition
T10.7Install to .claude/skills/holochain/Directory exists
T10.8Skill loaded by AugmentSkill context is included in agent workspace

OpenAI Codex CLI

#TestPass condition
T10.9Install to .claude/skills/holochain/Directory exists
T10.10Codex reads SKILL.md frontmatterInvocation triggers Holochain-domain responses

Note: T10.2–T10.10 require access to each tool. Mark as N/A if the tool is not installed. T10.1 and T10.4 are always testable.


T11 — Repository Hygiene

#TestCommandPass condition
T11.1LICENSE file is Apache-2.0head -3 LICENSEContains “Apache License, Version 2.0”
T11.2No Plans/ content ships as skillSKILL.md routing table has no reference to Plans/Zero Plans/ entries in routing table
T11.3No docs/ loaded by skillSKILL.md routing table has no reference to docs/Zero docs/ entries in routing table
T11.4No broken markdown linksScan for relative markdown links in all filesAll linked files exist
T11.5No TODO / STUB markersgrep -rn "TODO|STUB|PLACEHOLDER" . --include="*.md"Zero matches in non-Plans/ files
T11.6README install command is the one that worksRun the first fenced command in README.md verbatim in a scratch directorySkill installed, exit 0
T11.8Nothing outside the payload shipsnpm pack --dry-run (the packaging manifest is still the payload allowlist, whether or not the package is published)Every path is under skills/, bin/, or is README.md / LICENSE / package.json
T11.9The four version declarations agreesh scripts/check-versions.shExit 0
T11.7CLAUDE.md license annotationgrep "Apache" CLAUDE.mdMatches Apache-2.0

Release Gate

All items below must be ✅ before tagging a release.

Spec & Structure (non-negotiable)

  • T1 — All 11 frontmatter checks pass
  • T2 — All 16 files exist
  • T3 — All 14 routing entries resolve correctly
  • T11 — All 7 hygiene checks pass

Content

  • T4 — All 6 domains have substantive content
  • T5.13–T5.17 — Version pins consistent across all files

Code Accuracy (sample — validate at least 6 of 12)

  • T5.1–T5.12 — At least 6 code examples verified against real codebase

Installation

  • T6.1–T6.4 — Option A passes
  • T6.5–T6.6 — Option B passes
  • T6.7–T6.9 — Option C passes

Invocation

  • T7.1–T7.5 — Claude Code invocation tests pass (T7.6 optional)

Workflows (all 5 required)

  • T9.A — DesignDataModel workflow complete
  • T9.B — Scaffold workflow complete
  • T9.C — ImplementZome workflow complete
  • T9.D — DesignAccessControl workflow complete
  • T9.E — PackageAndDeploy workflow complete

Independence

  • T8.1–T8.4 — PAI independence verified

Cross-tool (Claude Code required; others optional for v1)

  • T10.1 — Install path verified for at least one non-Claude-Code tool

Once all release gate items are checked, tag v1.0.0 and publish.

Release Gate: v1.0.0

Completed 2026-08-17. Every row records the command run and its result. Rows that cannot apply are marked N/A with the reason, never left blank.

Toolchain currency

CheckResultEvidence
hdk pin matches crates.io stablePASShdk 0.7.0
hdi pin matches crates.io stablePASShdi 0.8.0
holochain pin matches crates.io stablePASSholochain 0.7.0
@holochain/client pin matches npm latestPASS0.21.0, published 2026-07-31
@holochain/hc-spin pin matches npm latestPASS0.700.0, published 2026-07-31
holonix ref exists and is the 0.7 linePASSbranch main-0.7, tip ffcc7c63b4b87dde16a69247775639b49c5778b1, matched by the example’s flake.lock
kangaroo-electron ref bundles 0.7PASSbranch main-0.7, kangaroo.config.ts declares holochainVersion: '0.7.0'
Scaffolder guidance names the stable releasePASSholochain_scaffolding_cli 0.700.0, and the skill documents that holonix bundles 0.700.0-rc.0 instead
Companion libraries checked by pin, not tagPASSWind Tunnel tag v0.7.1 pins hdk 0.6.3 while main pins 0.7.0; hREA happ-0.4.0-beta pins hdk 0.6.1. Both stated in the skill

Skill integrity

CheckResultEvidence
Validator passes on a clean treePASSsh scripts/validate-skill.sh exits 0
No orphaned markdownPASSvalidator orphan check
Every routing target resolvesPASSvalidator routing check
No 0.6-era removed APIs in code blocksPASSvalidator forbidden-API check
Old JS test harness retired to a single pointerPASSvalidator retirement check
No leftover placeholder markersPASSvalidator marker check
Docs site buildsPASSmdbook build exits 0, writes book/

Code correctness

CheckResultEvidence
Example hApp zomes compile to wasmPASSRUSTFLAGS='--cfg getrandom_backend="custom"' cargo build --release --target wasm32-unknown-unknown, exit 0
DNA packs from freshly built wasmsPASShc dna pack, exit 0, wrote forum.dna
hApp packsPASShc app pack, exit 0, wrote my_forum.happ
Sweettest suite passesPASScargo test --release -- --test-threads 4, exit 0. create_post, create_and_read_post, create_and_delete_post, create_and_update_post (4 passed, 35.38s); two-agent link_a_agent_to_a_post (1 passed, 10.97s)
Generated-code idiom matches the stable scaffolderPASSExample regenerated by hc-scaffold 0.700.0; output identical to the committed tree except the two files carrying the rc idiom, both adopted verbatim
Templates match the examplePASSassets/templates/integrity-lib.rs derived from the adopted source, zero WrongActionError

Skill efficacy

CheckResultEvidence
Live end-to-end, run A (2026-08-17)PASS, with a caveatGLM 5.3 via opencode, task: add a Comment entry type with CRUD, validation and a PostToComments link. Wrote 6 files, then cargo build --release --target wasm32-unknown-unknown exit 0 on first try. Caveat: described at the time as isolated on the strength of --pure. It was not. --pure disables plugins only; the instructions array still loaded eleven local files. The compile evidence stands; the routing and idiom evidence is weakened, because a context-loaded agent is not the naive reader the check describes
Live end-to-end, run B (2026-08-19)PASS, with the same caveatA later, separate run against the iteration-3 content: membrane gating plus a source-chain query, E2E_WASM_BUILD_EXIT=0, posts_integrity.wasm 1732446 bytes and posts.wasm 3123616 bytes. Same isolation defect as run A. These are two distinct runs on two different task sets, not two descriptions of one run
Live end-to-end under verified isolationOPENThe isolation recipe (HOME + XDG_CONFIG_HOME + XDG_DATA_HOME overridden onto a sandbox home) was established on 2026-08-19 and verified by behaviour rather than self-report. Re-running runs A and B under it is tracked as C46 and C47 in the project ISA. Not a 1.0 blocker: the compile evidence is unaffected, and what is open is how much the skill alone contributed to the routing
End-to-end output uses current idiomPASStry_from_action present, zero WrongActionError, LinkQuery::try_new and GetStrategy used, zero removed-in-0.7 APIs
Routing regression guardPASSscripts/eval/run-eval.sh, 27/39 (69.2%) against a 65% floor. This is a lexical coverage guard, not an accuracy metric. See scripts/eval/README.md
Multi-conductor network test on real infrastructureN/ARequires a deployed bootstrap and relay. Out of scope for a documentation skill; the two-agent Sweettest covers DHT propagation in-process
Cross-tool portability (other AI clients)N/ADeferred to v2 in docs/requirements.md. v1.0 targets Claude Code. The cross-model checks that were run are the two GLM 5.3 end-to-ends above
Windows supportN/AHolochain scaffolding refuses to set up Nix on Windows (ScaffoldError::NixSetupError). Upstream limitation, not a skill gap

Community readiness

CheckResultEvidence
License presentPASSLICENSE, Apache-2.0
Contribution guide present and accurate to this repoPASSCONTRIBUTING.md, written against the actual scripts and layout
Code of conduct presentPASSCODE_OF_CONDUCT.md, Contributor Covenant 2.1
Issue templates presentPASSbug report, feature request, stale version report, plus config.yml
PR template presentPASS.github/pull_request_template.md
CI runs the validatorPASS.github/workflows/validate.yml
Requirements open questions resolved or explicitly deferredPASSdocs/requirements.md now has a Decisions section; the two that remain are flagged as maintainer calls that do not gate v1.0
No personal or PAI-specific content in the public treePASSMEMORY/ and references/example-happ/target/ are gitignored. No absolute home-directory paths, personal identity, or private-tooling references in tracked files. The ~/.claude/skills/ paths in README.md are install instructions for a Claude Code skill, which is what the repo is