Holochain Agent Skills
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.
| Harness | Project path | Global 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
| Domain | Description |
|---|---|
| Architecture | Coordinator and integrity zome split, DNA structure, Cargo workspace, Nix dev environment, progenitor pattern, multi-DNA, private entries |
| Design | DHT data modeling, entry and link type design, discovery strategy, validation rules |
| Scaffold | Holonix setup, Nix flake, hc CLI, hc scaffold, new project and new domain workflows |
| Implement | Entry types, link types, CRUD, cross-zome calls, signals, validation, HDK 0.7 API |
| Test | Sweettest two-agent scenarios, await_consistency, update and delete patterns, inline zomes |
| Deploy | Kangaroo-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/*.happordnas/*/zomes/) - Task involves
.rsfiles insidezomes/coordinator/orzomes/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
| Workflow | Trigger | File |
|---|---|---|
| ReviewZome | review zome, audit zome, check implementation, validate patterns, before implementing, PR review, pull request, sanity check, double-check, code review on zome | references/workflows/review-zome.md |
| DesignDataModel | design data model, model entries, what entries, what links, entry vs link, DHT schema, DHT shape | references/workflows/design-data-model.md |
| Scaffold | scaffold, new happ, new project, setup environment, init project, Holonix, nix develop, hc scaffold | references/workflows/scaffold.md |
| ManualScaffold | project files, scaffold without CLI, manual scaffold, by hand, AI creates files, no hc scaffold, scaffold in session | references/workflows/manual-scaffold.md |
| ImplementZome | implement zome, create zome, scaffold zome, write zome, full zome, CRUD zome, coordinator and integrity crates | references/workflows/implement-zome.md |
| DesignAccessControl | design access control, who can call, allowed to call, cap grant design, capability grants | references/workflows/design-access-control.md |
| UpgradeHolochain07 | upgrade 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 upgrade | references/workflows/upgrade-holochain-0.7.md |
| PackageAndDeploy | deploy, package, distribute, distribution, kangaroo, installer, desktop app, webhapp | references/workflows/package-and-deploy.md |
Context Files
Load on demand based on task:
| File | Load When |
|---|---|
references/architecture.md | Coordinator/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.md | Progenitor 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.md | New project setup, Holonix installation, Nix flake, hc CLI, hc scaffold commands, new domain, adding a new domain to existing project |
references/patterns.md | Entry 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.md | Cap grants, capability grants, capability system, cap claim, remote signal, recv_remote_signal setup, admin-only access |
references/cryptography.md | App-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.md | Scheduled functions, schedule(), Schedule::Persisted crontab vs Schedule::Ephemeral, #[hdk_extern(infallible)], scheduler loop timing, why scheduled fns run as the chain author |
references/countersigning.md | Countersigning, 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.md | Cell cloning, partitioned data, own copy of the DNA, clone roles, createCloneCell, clone_limit |
references/error-handling.md | Error types, WasmError, ExternResult, extern result patterns, thiserror |
references/testing.md | Four-layer strategy, Sweettest (Rust-native), two agents, await_consistency, E2E Playwright + AdminWebsocket, Wind-Tunnel performance |
references/wind-tunnel.md | Performance/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.md | holochain-client setup, callZome, signals, SvelteKit integration |
references/troubleshooting.md | Any 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.md | Kitsune2 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.md | Nothing 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.md | Membrane proof, genesis_self_check, gating who may join, AgentValidationPkg validation, provideMemproofs, awaiting_memproofs, invite codes |
references/source-chain.md | query() 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.md | DNA migration, init_properties, get_init_properties(), carry over, chain history, carrying data across DNA versions, why 0.7 is a new network |
references/deployment.md | Packaging, 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.
| Component | Current | Source |
|---|---|---|
hdk / hdi / holochain | 0.7.0 / 0.8.0 / 0.7.0 | crates.io |
@holochain/client | 0.21.0 | npm |
@holochain/hc-spin | 0.700.0 | npm |
holochain_scaffolding_cli | 0.700.0 (stable, 2026-07-31) | crates.io / scaffolding v0.700.0 |
| holonix | branch main-0.7 | github.com/holochain/holonix |
| kangaroo-electron | branch main-0.7 | github.com/holochain/kangaroo-electron |
@holochain/tryorama | 0.19.2, last published 2026-05-15, no 0.7 release | npm |
Scaffolder version trap. Holonix
main-0.7shipshc-scaffold 0.700.0-rc.0, not the stablev0.700.0. The rc emitsholonix?ref=mainand-rccrate pins, and its generatedvalidate()does not compile against the stablehdi 0.8.0it 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 --lockedAlways pass
--version.cargo install holochain_scaffolding_cliwith no version installs 0.4000.4, a Holochain 0.4-era scaffolder. This crate’s version history mixes numbering schemes, and semver orders0.4000.4above0.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"while0.700.0sits further down the version list.
references/troubleshooting.mdlists 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.
| Project | State on 0.7 | What to do |
|---|---|---|
@holochain-open-dev/elements, /utils, /file-storage | 0.700.0, published 2026-07-31 to 08-13 | Safe to use |
@holochain-open-dev/profiles | 0.701.0, published 2026-08-11 | Safe to use |
@holochain-open-dev/signals | stable 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 Tunnel | latest tag v0.7.1 pins hdk 0.6.3; branch main pins hdk 0.7.0 | Track main. See references/wind-tunnel.md |
| hREA | happ-0.4.0-beta pins hdk 0.6.1 | Still 0.6 generation. Do not assume 0.7 compatibility |
Moss / Weave (@theweave/api) | 0.7.0-dev.2, dev channel only | Pre-release |
| Tauri Holochain plugins | tauri-plugin-holochain-service 0.2.3, last published 2025-11-10; tauri-plugin-holochain is a 0.0.0 placeholder | Unmaintained against 0.7. Use Kangaroo |
| Official editor extension, non-JS/Rust bindings | none found | The 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.
| Feature | Gates | Notes |
|---|---|---|
unstable-countersigning | Atomic multi-agent commits | See references/countersigning.md |
unstable-migration | The DNA manifest lineage field and UseExisting dependency matching | A declared ancestor chain. Holochain does not verify the lineage is truthful |
unstable-functions | A small set of host functions including the deprecated sleep() | Low value for most hApps |
unstable-sharding | Declared in holochain 0.7.0’s feature list. Nothing in the vendored crate sources references it, so its current scope is unverified | Do 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
-
ZomeCallResponseis exhaustive — 0.7 has 5 variants:Ok(ExternIO),AuthenticationFailed(Signature, AgentPubKey),Unauthorized(ZomeCallAuthorization, Option<CapSecret>, ZomeName, FunctionName),NetworkError(String),CountersigningSession(String). NoteUnauthorizedcarries noAgentPubKeyin 0.7. Wildcard_is safe but hides new variants. Exhaustive match is preferred. - Role name matches
happ.yaml—CallTargetCell::OtherRole("role_name")must exactly match the role name inworkdir/happ.yaml. Typos fail silently at runtime. - Zome name matches coordinator crate name —
ZomeName("zome_name")must match the coordinator’snameinCargo.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()— noget(),get_links(),agent_info(),sys_time(). DHT reads ARE allowed throughmust_get_*, which defers on an unresolved dependency instead of failing. Everything else comes from the op itself. - Use
op.flattened::<EntryTypes, LinkTypes>()— Not the oldop.to_type().references/patterns.mdhas the correct pattern. - Narrow actions with
TypedAction::<D>::try_from_action(...)?— Notlet r: Result<_, WrongActionError> = action.try_into();followed bymap_err(|e| wasm_error!(...)).try_from_actionreturnsExternResultand drops into a?-chain directly.TypedAction<CreateData>andTypedAction<UpdateData>widen intoTypedAction<EntryCreationData>infallibly with.into(). - A shape sys validation already guarantees is an error, not
Invalid— if aDeleteLink’s target is not aCreateLink, propagate with?. ReturningValidateCallbackResult::Invalidblames the author for a fault in how the op reached your code. -
TypedAction<D>derefs toD—action.data.entry_hashandaction.entry_hashboth work for reads. Keepaction.data.xwhere you need to move the field out, sinceDerefonly lends.
HDK 0.7 Get and Link API
-
delete_link()requiresGetOptions—delete_link(hash, GetOptions::default())notdelete_link(hash). -
get_links()takes aLinkQueryplus aGetStrategy—get_links(LinkQuery::try_new(base, LinkTypes::X)?, GetStrategy::default()). NotGetLinksInputBuilderfor most cases. -
GetStrategy::LocalvsNetwork— UseLocalfor own-data queries (fast, no network),Networkfor DHT queries (cross-agent data).
Shared Utility Patterns (project-specific)
-
agent_pub_keyandcreated_atare 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:
| Layer | Crate type | Role |
|---|---|---|
| Integrity | hdi | Defines entry types, link types, and validation rules. Deterministic; the only reads available are must_get_*. |
| Coordinator | hdk | Implements 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]enumvalidate()callback- Entry structs with
#[hdk_entry_helper] - Status enums (e.g.,
ListingStatus)
Coordinator crate only:
create_*,get_*,update_*,delete_*pub functionsrecv_remote_signalhandlerpost_commithook (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):
| Pattern | When to use |
|---|---|
| Single DNA | All agents share the same DHT network; simplest |
| Multiple roles | Separate concerns with different network boundaries (e.g., public + private data) |
| Clone cells | Partitioned 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_propertiesfor this. Holochain 0.7 added anInitPropertiestype set onRoleSettings::Provisionedand read withget_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 theinitcallback, 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.propertiesis 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; seemigration.md.
Two reference implementations inform this page:
- Requests & Offers (
happenings-community/requests-and-offers) — coordinator-only enforcement, auto-registration via the firstcreate_usercall - Moss (
lightningrodlabs/moss) — opt-in at group creation, integrity-level enforcement invalidate(), 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 nameprogenitor, notprogenitor_pubkey) with the sameOption<String>Rust type and the sameSerializedBytesdeserialization 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(notprogenitor_pubkey) - Progenitor injection is only for the
groupDNA — Moss applets must inject their own if needed - The invite-link carries
networkSeed+progenitortogether; validation confirms the key starts withuhCAkand decodes to 39 bytes
Common Pitfalls
| Pitfall | Fix |
|---|---|
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 entries | Add 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 DnaProperties | The .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 progenitor | Copy the creator’s key verbatim (invite-link / config); joiners must match DNA hash |
| Hardcoding a pubkey in source | Always read from dna_info().modifiers.properties |
| Progenitor key rotation | The 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 header | How to access (coordinator) |
|---|---|
| Author (agent pubkey) | record.action().author() |
| Timestamp | record.action().timestamp() |
| Entry hash | record.action().entry_hash() |
| Previous action hash | available 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),
}
}
Link Types (Integrity Crate)
#![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.
Implicit vs. Explicit Links
Holochain has two layers of navigable relationships. Understanding the distinction prevents over-engineering and redundant data.
Implicit — action metadata and DHT metadata (no create_link needed)
1. Action metadata — fields baked into every action header:
| Field | Type | How to access |
|---|---|---|
author | AgentPubKey | record.action().author() |
timestamp | Timestamp | record.action().timestamp() |
original_action_address | ActionHash | only on Action::Update — the original creation action |
deletes_address | ActionHash | only 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.
Explicit links — you define, create, and query them
| Link type | Purpose |
|---|---|
PathToMyEntry | Global discovery — browse all entries from a known path string |
AgentToMyEntry | Per-agent listing — “show me this agent’s entries” |
MyEntryUpdates | Forward traversal — original hash → latest version |
MyEntryToRelated | Cross-domain relationship navigation |
Decision rule
| Question | Tool |
|---|---|
| “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:
ChainStatus::Validis 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. Checkingstatuswithout also checkingwarrantsgives you a false clean bill of health.warrantsis the field that carries the cross-authority picture.status,valid_activityandrejected_activityare all scoped to the responding authority.valid_activitycan list actions that other authorities have warranted.Forkedwins overInvalid. A chain that is both forked and has invalid records reportsForked. To see the invalid records too, readwarrants, or re-query withActivityRequest::Fulland inspectrejected_activity.Closedwins overValid. A chain whose head is aCloseChainaction reportsClosed, notValid. 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.
delete_link() — requires GetOptions
#![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:
| Strategy | When to use |
|---|---|
GetStrategy::Local | Source chain only — use for get_my_* (own authored data, fast, no network) |
GetStrategy::Network | DHT — 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 validationmust_get_action(action_hash)— raw action (use in validation)must_get_entry(entry_hash)— entry contentmust_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.6 | 0.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(..) |
EntryCreationAction | TypedAction<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()orget_links(). Neither exists inhdiat 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. Seeprogenitor.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 withUnresolvedDependencies, 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 cellsend_remote_signal→recv_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:
- Grantor generates a secret:
let secret = generate_cap_secret()?; - Grantor creates grant on their source chain (stored locally, not DHT)
- Grantee receives the secret via private entry, signal, or other channel
- Grantee stores it as a cap claim:
create_cap_claim(CapClaim { tag, grantor, secret })?; - Grantee calls with secret:
call_remote(grantor, zome, fn_name, Some(secret), payload)?;
Decision Table
| Scenario | Pattern |
|---|---|
recv_remote_signal open to all agents | CapAccess::Unrestricted in init() |
| Delegate a specific function to one agent | CapAccess::Assigned + share secret via private entry |
| UI calling own zome (same agent, same cell) | No grant needed |
| Admin-only zome function | Progenitor check in coordinator (see architecture.md § DNA Properties) |
| Public API any agent can call | CapAccess::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::Allgrants 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
| Place | Runs on | Can it see the network? | Blocks what |
|---|---|---|---|
genesis_self_check callback | The joining agent’s own machine, before joining | No. Deterministic, local only | A bad proof, early, with a clear error |
Validation of the AgentValidationPkg op | Every validating authority | No. Same determinism rules as all validation | The join, for real |
Conductor install flow (membrane_proof / provideMemproofs) | The installer | Yes, it is ordinary application code | Nothing. 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 on | Reaching the proof | |
|---|---|---|
AgentActivity(OpActivity::CreateAgent) | The agent-activity authority for the joining agent | One must_get_action back to the previous action |
CreateRecord(OpRecord::AgentValidationPkg) | The record authority for the validation-package action | Handed 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 to | Allowed |
|---|---|
| Verify a signature over the proof | Yes. Pure computation |
Compare against a key in dna_info().modifiers.properties | Yes. Identical for every agent, part of the DNA hash |
must_get_valid_record an invite the proof names by hash | Yes. 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.0sources. 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.
Related
progenitor.mdfor putting the issuing key in DNA propertiesaccess-control.mdfor capability grants, which govern calls rather than joinsclient.mdfor the admin API surface used abovepatterns.mdfor theFlatOpandTypedAction<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 case | Pattern |
|---|---|
| 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 users | No 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_limitinhapp.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: trueis not required for clonable roles: a provisioned role can be cloned, as Volla Messages does in production withclone_limit: 100deferredis in fact ignored on install.AppBundle::resolve_celldestructuresCreate { .. }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 assemblingAppInfothen reachesunimplemented!()inholochain_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 reboot | Yes | No |
| Survives an irrecoverable error | Yes | No |
| Repeats | Yes, per the crontab | No, one shot per return value |
| To keep the schedule | Return the same crontab every time | Return 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
-
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 theschedule()call, never inside the scheduled function, because by then there is no caller to check. -
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 returnedNone. -
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.
-
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.
-
initis lazy. It does not run until some other zome call runs for the first time after installation. Scheduling frominitis 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.
- The initiator builds a
PreflightRequestand distributes it to every signer, usually by remote call. - Each signer calls
accept_countersigning_preflight_request, which freezes that signer’s source chain until the session ends. Each returns aPreflightRequestAcceptanceto the initiator. - 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 thanN. - If there are optional signers, the enzyme must be used, and it must be the first agent in both
signing_agentsandoptional_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-countersigningenabled? - Is any single agent a participant in a high proportion of sessions? If so,
AnotherSessionIsInProgressis 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
PreflightRequestAcceptancevariants, 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?
Related
access-control.mdfor the cap grants the remote calls that distribute preflight requests will need.patterns.mdfor 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.
Related
access-control.mdfor capability grants, which govern who may call your functions rather than who may read your data.patterns.mdfor 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:
From<MyDomainError> for WasmErroris implemented (above)WasmErrorimplementsInto<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:
| Variant | When to use |
|---|---|
NotFound(String) | DHT get returns None after expected create |
NotAuthorized | Author 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) |
EntryTypeMismatch | Retrieved 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”.
| Field | Type | Meaning |
|---|---|---|
sequence_range | ChainQueryFilterRange | Which slice of the chain. Default Unbounded |
entry_type | Option<Vec<EntryType>> | Keep only these entry types |
entry_hashes | Option<HashSet<EntryHash>> | Keep only these entry hashes |
action_type | Option<Vec<ActionType>> | Keep only these action types |
include_entries | bool | Load entry content, not just actions. Default false |
order_descending | bool | Default 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):
| Call | Returns | Use |
|---|---|---|
record.action() | &Action | The action content, { header, data } |
record.action_address() | &ActionHash | This 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:
| Variant | Fields |
|---|---|
CreateData | entry_type, entry_hash |
UpdateData | original_action_address, original_entry_address, entry_type, entry_hash |
DeleteData | deletes_address, deletes_entry_address |
CreateLinkData | base_address, target_address, zome_index, link_type, tag |
DeleteLinkData | base_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:
Unboundedis equivalent toActionSeqRange(0, u32::MAX).ActionSeqRangeis 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. Theentry_typeandaction_typefilters 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).
| Function | Crate | Answers |
|---|---|---|
dna_info() | hdi | Which DNA am I, with what modifiers |
zome_info() | hdi | Which zome am I, with what types in scope |
agent_info() | hdk | Who am I, and where is my chain head |
call_info() | hdk | Who 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:
provenanceis the caller, which for acall_remoteis the remote agent, not you. This is the field to check when a function should behave differently for remote callers. Comparingprovenanceagainstagent_info()?.agent_initial_pubkeyis the “was this called locally” test.cap_grantis 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. Seeaccess-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_LOGenvironment variable, which behaves exactly likeRUST_LOGdoes 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.
Related
patterns.mdforget_agent_activity,must_get_*and chain forksdebugging.mdfor reading trace output and conductor stateprogenitor.mdfor the DNA-properties pattern thatdna_info()servesaccess-control.mdforCapGrantand whatcall_info().cap_granttells 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.6 | 0.7 |
|---|---|
tx5 / WebRTC transport, optional transport-iroh feature | iroh over QUIC, unconditional. grep tx5 holochain_p2p/Cargo.toml returns nothing |
signal_url pointing at an SBD signal server | relay_url pointing at an iroh relay |
webrtc_config | gone. Use advanced for direct Kitsune2 tuning |
transport-iroh cargo feature on the holochain crate | removed. 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.
| Field | Type | Default | What it does |
|---|---|---|---|
bootstrap_url | URL | https://dev-test-bootstrap2.holochain.org | The Kitsune2 bootstrap server used for WAN peer discovery |
relay_url | URL | https://use1-1.relay.n0.iroh-canary.iroh.link./ | The iroh relay used when a direct connection cannot be established |
base64_auth_material_bootstrap | Option<String> | none | Auth material if your bootstrap service requires it. Base64 url-safe, no padding |
base64_auth_material_relay | Option<String> | none | Same, for the relay service |
request_timeout_s | u64 | 60 | Request/response roundtrip timeout, in seconds |
target_arc_factor | u32 | 1 | Multiplier applied to arc-size hints from Kitsune2 |
report | ReportConfig | None | Kitsune2 reporting output |
advanced | Option<JSON> | none | Raw 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, becauseConductorConfigmarks the field#[serde(default)]and falls back toNetworkConfig::default(). - Write a partial
network:block that sets, say, onlyrequest_timeout_s, and startup fails withmissing 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:
| Setting | Testing | Production |
|---|---|---|
--listen | 127.0.0.1:0 | 0.0.0.0:443 and [::]:443 |
--worker-thread-count | 2 | 4 x cpu count |
--request-listen-duration-ms | 10ms | 2s |
--prune-interval-ms | 10s | 60s |
--max-entries-per-space | 32 | 32 |
--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:
| Field | Note |
|---|---|
data_root_path | Databases and compiled wasm live under here. Required by the time the config builds a conductor |
keystore | lair_server_in_proc for a self-contained conductor, or a separate lair server |
admin_interfaces | A list of { driver: { type: websocket, port, allowed_origins } }. Omit in production if nothing should manage the conductor remotely |
wasm_backend | Only needed when more than one wasm backend is compiled in |
db_sync_level | SQLite PRAGMA synchronous. Leave alone unless you have measured a reason |
db_max_readers | Defaults to twice the CPU count, minimum 8. Related to the authority-response concurrency limit; change both or neither |
tracing_override | Overrides the environment tracing config. See debugging.md |
Related
debugging.mdfor reading what the network is actually doing at runtimeclient.mdfordumpNetworkStatsanddumpNetworkMetricsfrom TypeScriptdeployment.mdfor 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
Link Type
# 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.toml — hc 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
| Problem | Cause | Fix |
|---|---|---|
nix: command not found | Nix not installed or not in PATH | Restart shell after install; check ~/.nix-profile/bin in PATH |
flakes not enabled | Missing experimental-features config | Add experimental-features = nix-command flakes to ~/.config/nix/nix.conf |
hc: command not found inside nix develop | Wrong holonix branch | Check flake.nix ref — must be main-0.7, not main |
wasm32 target not found | Rust toolchain outside Nix | Use nix develop; don’t use system Rust for Holochain builds |
First build hangs at wasm-opt | wasm-opt is slow on first run | Normal — 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?" │
└──────────────────────────────────────────────────────────────────┘
| Layer | Tool | Output | What it catches |
|---|---|---|---|
| Unit | Vitest | pass/fail | Store logic, mappers, computed values |
| Integration | Sweettest | pass/fail | Zome logic, validation, DHT sync, auth |
| E2E UI | Playwright + @holochain/client | pass/fail | Full user journeys, real data display |
| Performance | Wind-Tunnel | metrics (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 withcargo 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. Seewind-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 Case | Sweettest | Playwright | Wind-Tunnel |
|---|---|---|---|
| Zome logic, validation, CRUD | ✅ Preferred | No | No |
| DHT propagation, consistency | ✅ Preferred | No | No |
| Multi-agent scenarios | ✅ Preferred | No | No |
| Inline zomes (no WASM compile) | ✅ Yes | No | No |
| Direct DHT database inspection | ✅ Yes | No | No |
| Full UI user journeys | No | ✅ Yes | No |
| Real data rendered in browser | No | ✅ Yes | No |
| Latency / throughput metrics | No | No | ✅ Yes |
| DHT sync lag measurement | No | No | ✅ Yes |
| Soak / sustained load testing | No | No | ✅ Yes |
| Language | Rust | TypeScript | Rust |
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
| Type | Purpose |
|---|---|
SweetConductor | Single conductor instance |
SweetConductorBatch | Multiple conductors for multi-agent scenarios |
SweetApp | Installed app with pre-built cells |
SweetCell | Cell reference — access agent key, DNA hash, zome handles |
SweetZome | (CellId, ZomeName) handle passed to conductor.call() |
SweetAgents | Agent key generation utilities |
SweetDnaFile | DNA construction helpers |
SweetInlineZomes | Define 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)
| Symptom | Root Cause | Fix |
|---|---|---|
| Bob can’t find Alice’s entry | Missing await_consistency | Add await_consistency([&alice_cell, &bob_cell]).await.unwrap() |
Compilation error on call() | Missing feature flag | Add features = ["test_utils"] to holochain dev-dep |
Timeout in await_consistency | Conductors not networked | Call conductors.exchange_peer_info().await after setup_app |
Wrong type on call() | Type annotation missing | Add explicit type: let result: MyType = conductor.call(...) |
into_tuple() fails | Wrong number of cells destructured | Match tuple arity to number of DNA roles |
| Invalid ops present unexpectedly | Validation logic accepting bad data | Use get_invalid_integrated_ops() to inspect rejected ops |
reveal_peer_info / gossip never starts | Full arc not declared | Call declare_full_storage_arcs() on test conductors |
| Scheduled fn never fires | Scheduler not started | Call start_scheduler() or dispatch_scheduled_fns(timestamp) |
| WebSocket auth fails in test | Using wrong port | Use 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
| Symptom | Root Cause | Fix |
|---|---|---|
| Conductor never ready | Polling instead of stdout | Listen for "Conductor ready" in stdout |
callZome rejected | Using AppWebsocket on admin port | Use AdminWebsocket on admin port (8888), AppWebsocket on app port |
| Auth error on connect | Missing token | Call admin.issueAppAuthenticationToken() and pass token to AppWebsocket.connect |
| Tests interfere with each other | Shared conductor state | Run with workers: 1, reset data in beforeEach if needed |
| UI shows no data | Race — browser loads before seeding | Seed 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 workspaceCargo.tomlon each ref:
Ref Pins Holochain 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
mainuntil a release ships with 0.7 pins. Used for: Performance regression CI (every merge to holochain main), soak testing, benchmarking
Testing Layers Compared
| Layer | Tool | Purpose | Output |
|---|---|---|---|
| 1 | Sweettest (Rust) | Correctness — does the hApp work right? | pass/fail |
| 2 | Playwright (TypeScript) | E2E functional — does the UI+zome flow work? | pass/fail |
| 3 | Wind-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
| Crate | Purpose |
|---|---|
wind_tunnel_runner | Core: ScenarioDefinitionBuilder, run(), AgentContext, RunnerContext, Executor |
wind_tunnel_instruments | Metrics: Reporter, ReportMetric, OperationRecord |
wind_tunnel_instruments_derive | Proc macro: #[wind_tunnel_instrument] |
wind_tunnel_core | Core types: AgentBailError, ShutdownHandle |
holochain_wind_tunnel_runner | Holochain bindings: call_zome(), install_app(), HolochainAgentContext |
holochain_client_instrumented | Auto-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
| Variable | Purpose |
|---|---|
WT_HOLOCHAIN_PATH | Path to custom Holochain binary |
HOLOCHAIN_INFLUXIVE_FILE | Enable conductor-level metrics to file |
WT_METRICS_DIR | Directory for metrics output (set by Nix) |
RUST_LOG | Log level (e.g. RUST_LOG=info) |
Metrics Architecture
Wind-Tunnel collects three simultaneous metric layers, enabling correlation:
| Layer | Source | What |
|---|---|---|
| OS | Telegraf (systemd) | CPU, memory, disk I/O, network, swap |
| Conductor | Holochain influxive | Internal conductor performance |
| Scenario | ReportMetric | Custom application metrics |
Reporter backends:
| Flag | Use |
|---|---|
--reporter=in-memory | Console output (default, local dev) |
--reporter=influx-file | Write InfluxDB line protocol for upload |
--reporter=noop | Disable 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:
| Scenario | Tests |
|---|---|
zome_call_single_value | Baseline zome call latency |
write_read | Create + immediate get throughput |
dht_sync_lag | DHT propagation delay between agents |
app_install | App installation latency (minimal vs large) |
remote_signals | Remote signal round-trip latency |
remote_call_rate | Remote zome call throughput |
two_party_countersigning | Full countersigning session lifecycle |
single_write_many_read | Write amplification pattern |
validation_receipts | Validation receipt delivery timing |
local_signals | Local signal handling performance |
full_arc_create_validated_zero_arc_read | Mixed-arc topology |
zero_arc_create_data | Zero-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}`),
});
AppAgentWebsocketno longer exists. It was merged intoAppWebsocket, 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.x | 0.21.0 |
|---|---|
AppAgentWebsocket.connect(url, appId) | AppWebsocket.connect() |
SignedActionHashed<Create> | SignedActionHashed |
action.hashed.content.author | action.hashed.content.header.author |
action.hashed.content.timestamp | action.hashed.content.header.timestamp |
import { Create, CreateLink, ... } | no longer exported; remove them |
TransportStats | ApiTransportStats |
stats.connections | stats.transport_stats.connections |
connection.is_webrtc | connection.is_direct |
signalingServerUrl in ConnectionServices | relayServerUrl |
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 (
.webhappbundle 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
| Branch | Holochain version | Status | Use when |
|---|---|---|---|
main | 0.7.0 | Development | Tracks the latest line; moves without warning |
main-0.7 | 0.7.0 | Recommended | New production projects |
main-0.6 | 0.6.3 | Legacy | Existing 0.6.x apps not yet ported |
main-0.5 | 0.5.x | Archived | Old 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:
- Your
.happ(conductor + DNA + zomes) - 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.happfileuiPort— 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_NAMEmust 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 change | Data folder | Effect |
|---|---|---|
Patch (0.1.0 → 0.1.1) | Shared | Safe upgrade — user keeps data |
Minor (0.1.0 → 0.2.0) | Isolated | Breaking — user starts fresh |
Major (0.1.0 → 1.0.0) | Isolated | Breaking — user starts fresh |
| Pre-release tag (any) | Isolated | Always 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:
| Branch | Build type | Code signing |
|---|---|---|
release | Cross-platform executables | Unsigned |
release-codesigned | Cross-platform executables | Signed (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
| Option | Description | When to use |
|---|---|---|
| p2p Shipyard | Community-maintained Tauri + Nix approach | Need Android support |
| Moss | Holochain groupware framework | App integrates into a shared workspace |
What NOT to Use
| Tool | Reason |
|---|---|
| Holochain Launcher | Officially deprecated; development paused. Do not build new projects for it. |
| Kangaroo-Tauri | Frozen at Holochain 0.3.2 (last update Aug 2024). Not maintained. |
Troubleshooting
| Error / Symptom | Cause | Fix |
|---|---|---|
| App won’t start after version bump | Minor/major bump → new isolated data folder | Expected behavior. User data not migrated automatically — implement migration if needed. |
| Network connectivity fails | iroh relay not configured | Add relayUrl to conductor config in electron/config.ts |
| Conductor exits immediately on start | Config still sets signal_url / webrtc_config / chc_url / db_sync_strategy | Remove 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 upgrade | 0.7 renamed its databases and changed DNA hashes | Expected. There is no migration path; clear conductor data (hc sandbox clean). You are joining a new network |
| Binary checksum mismatch | Corrupted or incomplete download | rm -rf node_modules/.cache && npm install |
| Windows MSI build fails | Special characters in PRODUCT_NAME | Use 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:
- Opaque to the conductor. The bytes mean nothing to Holochain; you define the encoding.
- Never written to the DHT. They stay in the conductor database.
- Readable only from
init. No other callback or zome function can see them. - 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.properties | init_properties | |
|---|---|---|
| Part of the DNA hash | Yes | No |
| Visible to other peers | Yes, every peer agrees on it | No, conductor-local |
| Readable from validation | Yes, via dna_info() | No |
| Readable from any zome fn | Yes | No, init only |
| Lifetime | Permanent | Cleared after init |
| Use for | Network-wide config: progenitor key, membrane settings | Per-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:
| Approach | What it costs | When it fits |
|---|---|---|
| Clean restart | Nothing technical; users lose history | Pre-production, or data that is genuinely disposable |
| User-facing export / import | UI work in both versions, no admin API needed | Most apps. Often cheaper than the “proper” path and easier to explain to users |
| Hash pointers | Small payload; the old data must stay reachable somewhere | Archival references where content need not move |
| Notary-signed rollups | A trusted signer, and a scheme for it | Balances or aggregates where per-event history is not needed |
| Whole source chain | Largest payload; needs the most validation thought | Full-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
- Decide what must survive, using the table above.
- Extract it from the 0.6 install, while it still runs.
- Encode it and pass it as
init_propertiesonRoleSettings::Provisionedwhen installing the 0.7 app. - In
init, readget_init_properties(), decode, and write entries to the new source chain. - 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 text | Cause | Fix |
|---|---|---|
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 problem | RUSTFLAGS='--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 scope | 0.6 code on 0.7 | TypedAction<EntryCreationData>. See patterns.md |
no variant named StoreEntry found for enum FlatOp | 0.6 FlatOp names | CreateEntry. Full rename table in patterns.md |
no variant named RegisterCreateLink / RegisterDeleteLink | Both folded into one variant in 0.7 | FlatOp::Link(OpLink::CreateLink { .. }) |
no method named author found on an action struct | The per-variant action structs are gone | action.author(), or action.header.author. Accessors exist on both Action and TypedAction<D> |
cannot move out of dereference on a TypedAction field | TypedAction<D> derefs to its data; you cannot move through a deref | Use .data explicitly: action.data.target_address.into_action_hash() |
unresolved import holochain_zome_types::action::Action | Crate-root and module re-exports were removed in 0.7 | Import from a prelude: holochain_zome_types::prelude::Action. Zomes importing only hdi::prelude / hdk::prelude are unaffected |
feature sqlite-encrypted does not exist | Renamed in 0.7 | encryption |
feature wasmer_sys does not exist | Renamed in 0.7 | wasmer-sys-cranelift |
feature transport-iroh does not exist | Removed; iroh is compiled in unconditionally | Drop it from the feature list |
Build fails looking for perl | A holochain build dependency needs it on PATH, usually surfacing only when building a Sweettest suite | Add 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 combination | Pin 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 documents | Match 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
| Symptom | Cause | Fix |
|---|---|---|
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_integrity | Pass --zome posts_integrity |
hc: command not found inside nix develop | Wrong holonix branch, or the shell did not rebuild | Check 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.
| Symptom | Cause | Fix |
|---|---|---|
Scaffolded flake.nix says ref=main (verified) | The rc emits main, and holonix main tracks the 0.8 dev line | Change to ref=main-0.7 after scaffolding, then nix flake update |
Scaffolded flake.nix says nodejs_22 (verified) | Same stale template | Change to nodejs_24 |
Scaffolded Cargo.toml pins -rc crates (verified) | The scaffolding CLI is itself an rc and pins its own generation | Pin stable: hdi = "=0.8.0", hdk = "=0.7.0", holochain = "0.7.0" |
Conductor startup
| Error / symptom | Cause | Fix |
|---|---|---|
| Conductor exits immediately after a 0.7 upgrade | NetworkConfig rejects unknown fields rather than ignoring them | Remove 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 databases | 0.7 renamed its databases and there is no migration path | hc sandbox clean. Expected, not a bug |
| Peers never connect after upgrading | DNA hashes changed in 0.7 even for otherwise identical DNAs, so you are on a new network | Everyone must be on the same build. Republish the DNA hash to your users |
hc sandbox rejects the network type | Only mem and quic remain; webrtc is gone with tx5 | Update scripts passing webrtc |
| App manifest rejected for an unrecognised field | App and web-app manifests now reject unknown fields | Remove stray or misspelled keys. manifest_version is '0' |
Validation
| Symptom | Cause | Fix |
|---|---|---|
| Validation passes locally, fails for other agents | Non-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 else | Reading init_properties instead of modifiers.properties. Init properties are conductor-local and invisible to other peers | Use dna_info().modifiers.properties. See progenitor.md |
| An entry fails to deserialize after a schema change | A field was added without #[serde(default)] | Option<T> alone is not sufficient. Add #[serde(default)] |
WrongActionError from a narrowing conversion | The action was not the variant sys validation guarantees | Propagate with ?, do not return Invalid. It is a fault, not bad author data |
Testing
| Symptom | Cause | Fix |
|---|---|---|
Cross-agent read returns None intermittently | Missing consistency wait | await_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 flake | Too many in-process conductors at once | cargo test -- --test-threads 6 |
Conductor::install_app_with_manifest not found | Moved behind a feature in 0.7 | Enable the test_utils feature |
mock_network not found | Removed from holochain_p2p in 0.7 | Use test_utils directly |
| Inline zome tests fail to compile | Inline zome definitions are no longer embedded in DnaDef; closures live on DnaFile, and InlineZome::uuid became InlineZome::hash | Rework against the 0.7 inline zome API |
Deployment
| Symptom | Cause | Fix |
|---|---|---|
| Kangaroo build uses the wrong conductor | Cloned the default branch | git 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 bump | Kangaroo isolates data folders on minor and major bumps by design | Expected. Only patch bumps share a data folder |
| Windows MSI build fails | Special characters in PRODUCT_NAME | Alphanumerics 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.
| Stream | Variable | Default |
|---|---|---|
| Conductor (Rust) | RUST_LOG | unset 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
| Subcommand | Answers |
|---|---|
list-apps, list-cells, list-dnas, list-app-ws | What is installed and running |
dump-state | State for one cell |
dump-full-state | Everything, including the source chain. Large |
dump-conductor-state | Conductor-level state, not per cell |
dump-op-timings | How long ops took. Start here for “why is this slow” |
dump-network-stats | Transport-level connection figures |
dump-network-metrics | Kitsune2 metrics, per space |
list-agents | Peers this conductor knows about |
peer-meta-info | What this conductor records about one peer |
list-capability-grants | Grants currently in force for a cell |
revoke-zome-call-capability | Remove one |
add-agents | Inject agent info, for offline or test networks |
install-app, uninstall-app, enable-app, disable-app, new-agent | Lifecycle, by hand |
add-admin-ws, add-app-ws | Attach 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.
Related
troubleshooting.mdwhen you have a literal error stringnetworking.mdfor what the network figures meanclient.mdfor the admin and app API from TypeScriptsource-chain.mdfor 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
statusfield with enum - Can this entry be “updated in place” or should old versions be preserved? → Update chain (links) vs overwrite
Step 3: Design Link Types
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_hash → updated_action_hash.
Step 4: Choose Discovery Strategy
How will agents find entries?
| Pattern | Link | Use When |
|---|---|---|
| Global path anchor | Path::from("entries.active") → Entry | All agents browse all entries |
| Status-scoped path | Path::from("entries.active") vs "entries.archived" | Browse by status |
| Agent-centric | AgentPubKey → Entry | Each agent manages their own entries |
| Both | Path + Agent links | Global browse AND per-agent listing |
| Hierarchical path | Path::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}Updateslinks 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:
- Zome pair list — domain to crate name mapping
- Entry structs (Rust) — ready to paste into integrity crate
- Link type enum — ready to paste into integrity crate
- Discovery strategy — path vs agent vs both, with path strings
- Validation checklist — rules ready for
validate()callback - 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 scaffoldis not accessible (e.g., in an AI coding session), usemanual-scaffold.mdinstead — 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 outsidenix develop; runnix developfirst- Slow first build — normal; wait for
wasm-optto 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:
| Placeholder | Meaning | Example |
|---|---|---|
<APP_NAME> | hApp name, snake_case | my_forum |
<DNA_NAME> | DNA name, snake_case | forum |
<ZOME_NAME> | Coordinator zome name; the integrity crate is <ZOME_NAME>_integrity | posts |
<EntryType> | Entry struct name, PascalCase | Post |
<entry_type> | Entry name, snake_case | post |
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
| Template | Destination |
|---|---|
flake.nix | flake.nix |
Cargo.toml | Cargo.toml |
package.json | package.json |
gitignore | .gitignore |
happ.yaml | workdir/happ.yaml |
web-happ.yaml | workdir/web-happ.yaml |
dna.yaml | dnas/<DNA_NAME>/workdir/dna.yaml |
integrity-Cargo.toml | dnas/<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.toml | dnas/<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_versionis'0', quoted, in bothhapp.yamlanddna.yaml. Not'1'.- Pin holonix to
main-0.7.maintracks the 0.8 dev line. The template already does this, buthc scaffolditself still emitsref=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 scaffoldoutput does not compile against stable 0.7.0 as-is. The 0.700.0-rc generator emitsref create @ OpActivity::CreateAgent { ref action }withcreate.agent(), which matches the rc crates. Stable hdi 0.8.0 carriesagentas 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
getrandomwith a message aboutwasm32-unknown-unknownnot 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.yamlanddna.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:
| Error | Cause | Fix |
|---|---|---|
cannot find type EntryTypes | Missing import | Add use {domain}_integrity::*; |
op.to_type() deprecated | Old API | Replace with op.flattened() |
expected ExternResult, found ValidateCallbackResult | Wrong return | Use Ok(ValidateCallbackResult::Valid) |
| Link type not found | Unregistered link | Add to #[hdk_link_types] enum in integrity |
wasm-opt timeout | Build too slow | Normal 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:
| Function | Caller | Notes |
|---|---|---|
create_post | UI (same agent) | No grant needed |
recv_remote_signal | Any remote agent | Needs Unrestricted grant |
update_admin_status | Admin agent only | Progenitor check |
get_shared_resource | Specific partner agent | Assigned 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 scope | Pattern | Where |
|---|---|---|
| Same agent (UI) | No grant | N/A |
| Any agent | CapAccess::Unrestricted in init() | init() callback |
| Named agent(s) | CapAccess::Assigned | On-demand grant creation |
| Token holder | CapAccess::Transferable | On-demand grant creation |
| Admin-only | Progenitor check in coordinator | Coordinator 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, orupdated_atfields on entry structs (those are in the action header — access viarecord.action().author()/.timestamp()) - Status enums use a dedicated enum type, not a raw
String
Integrity / Validation
-
validate()usesop.flattened::<EntryTypes, LinkTypes>()?not deprecatedop.to_type() - No non-deterministic reads inside
validate()— noget(),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 oldGetLinksInputBuilderunless specifically needed) -
GetStrategy::Localfor own-data queries;GetStrategy::Networkfor 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 matchesworkdir/happ.yamlexactly -
ZomeName(...)matches the coordinator cratenamein itsCargo.toml -
ZomeCallResponsematch is exhaustive (5 variants in 0.7:Ok,Unauthorized,AuthenticationFailed,NetworkError,CountersigningSession.Unauthorizedcarries noAgentPubKeyin 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
utilscrate rather than rawcall()everywhere)
Error Handling
- All fallible operations use
ExternResult<T>; no.unwrap()or.expect()in zome functions -
wasm_error!(WasmErrorInner::Guest(...))used for domain errors (notWasmErrorInner::Host) - Custom error types implement
From<MyError> for WasmError
Tests (Sweettest)
-
await_consistency([&cell_a, &cell_b]).awaitcalled before any cross-agent read - Tests use
#[tokio::test(flavor = "multi_thread")]andholochaindev-dependency withtest_utilsfeature - 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 startlaunches the app without errors- UI loads and basic zome calls succeed
npm run kangaroocompletes 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 type | Version bump | User data |
|---|---|---|
| Bug fix, minor enhancement | Patch (0.1.0 → 0.1.1) | Preserved |
| New features, schema changes | Minor (0.1.0 → 0.2.0) | Isolated (user starts fresh) |
| Breaking architecture change | Major (0.1.0 → 1.0.0) | Isolated |
| Any pre-release tag | Any | Always 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:
- The action model was rewritten. Every integrity zome’s
validatecallback and every coordinator’ssignal_actionneeds porting. This is the bulk of the work. - tx5 and WebRTC are gone. Iroh over QUIC is the only transport.
- 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::StoreEntry→CreateEntry,StoreRecord→CreateRecord,RegisterUpdate→Update,RegisterDelete→Delete,RegisterAgentActivity→AgentActivity, and both link variants →FlatOp::Link(OpLink::…)EntryCreationAction→TypedAction<EntryCreationData>Create/Update/Delete/CreateLink/DeleteLink→…Datastructs, matched onaction.dataaction.author→action.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_actionmatches on&action.hashed.content.datawithActionData::variants.get_agent_activitytakes a fourthGetOptionsargument and returnsAgentActivityStatus.ChainFilteruses constructors:ChainFilter::take(chain_top, 10), notChainFilter::new(chain_top).take(10).Record::newtakes aRecordEntry, not anOption<Entry>.block_agentandunblock_agentare 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_activityhas 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_sundernetwork db_sync_strategy→db_sync_level, valuesFull/Normal/Offwasm_backendis new and optional
JavaScript, see client.md:
SignedActionHashedis no longer genericaction.hashed.content.author→.header.authorTransportStats→ApiTransportStats,is_webrtc→is_directsignalingServerUrl→relayServerUrl
Step 5 — Tests
Sweettest is the supported path; hc scaffold no longer generates Tryorama.
await_consistencyhas a 60 second timeout;await_consistency_s(n, ..)for a custom oneConductor::install_app_with_manifestmoved behindtest_utilsmock_networkwas removed fromholochain_p2p- Inline zome definitions changed: closures moved to
DnaFile,InlineZome::uuidbecamehash
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
| Step | Rough effort |
|---|---|
| 1 Re-pin toolchain | half a day |
| 2 Integrity zomes | two to four days |
| 3 Coordinator zomes | one to two days |
| 4 Config and clients | half a day |
| 5 Tests | one day |
| 6 Packaging | half 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
| Persona | Context | Primary Need |
|---|---|---|
| Junior Holochain developer | Learning the framework, first hApp | Guided workflows, explanations, scaffold commands |
| Experienced Holochain developer | Active project, knows the patterns | Fast pattern lookup, CRUD generation, debugging help |
| Full-stack developer new to Holochain | Knows Rust/TypeScript, learning DHT concepts | Architecture explanation, data model design, TypeScript client integration |
Functional Requirements
| ID | Requirement | Priority | Acceptance Criteria |
|---|---|---|---|
| FR-01 | Skill must cover Architecture domain | Must | references/architecture.md loads on request; covers coordinator/integrity split, DNA structure, Nix, progenitor, multi-DNA, private entries |
| FR-02 | Skill must cover Design domain | Must | references/workflows/design-data-model.md guides entry/link type design with output artifacts |
| FR-03 | Skill must cover Scaffold domain | Must | references/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-04 | Scaffold workflow follows official Holochain documentation | Must | Commands and patterns reference developer.holochain.org; version pins current (hdk=0.7.0, hdi=0.8.0) |
| FR-05 | Skill must cover Implementation domain | Must | references/patterns.md covers entry types, link types, CRUD, cross-zome calls, signals, validation, HDK 0.7 API |
| FR-06 | Skill must cover Testing domain | Must | references/testing.md covers Sweettest setup, two-agent scenarios, await_consistency, update/delete patterns |
| FR-07 | Skill must cover Deployment domain | Must | references/deployment.md + references/workflows/package-and-deploy.md cover Kangaroo-Electron packaging, CI/CD, versioning |
| FR-08 | Skill must be PAI-independent | Must | No voice notification curl, no SKILLCUSTOMIZATIONS hook, no PROJECTS.md references, no Algorithm routing; works in vanilla Claude Code |
| FR-09 | Skill must include installation documentation | Must | README.md with 3 installation options (global, project-local, symlink), quick start examples |
| FR-10 | Domain correspondence with PAI version | Should | Same sections, same knowledge depth, same workflow structure — different wrappers |
| FR-11 | Skill routing covers all 5 workflows | Must | SKILL.md routing table maps natural language triggers to correct workflows |
| FR-12 | Context files load on demand | Must | SKILL.md specifies which context file to load for each topic; not all pre-loaded |
Non-Functional Requirements
| ID | Category | Requirement | Target |
|---|---|---|---|
| NFR-01 | Portability | Works with zero PAI infrastructure | Verified by install in fresh Claude Code with no ~/.claude/PAI/ |
| NFR-02 | Currency | Version pins match current stable Holochain | hdk=0.7.0, hdi=0.8.0, holonix ref=main-0.7 at release |
| NFR-03 | Completeness | All 6 domains have content | No stub files in v1 release |
| NFR-04 | Accuracy | Code examples compile and run correctly | Examples 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), andSKILL.mdcarries 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.
| Question | Decision | Evidence |
|---|---|---|
| Public repo location | Personal GitHub, Soushi888/holochain-agent-skills, Apache-2.0. Moving to a community org stays possible later and costs nothing now | The repo is already there; LICENSE is Apache-2.0 |
| Contribution model | Open from day one | CONTRIBUTING.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+)
| Feature | Target Version | Description |
|---|---|---|
| hREA / ValueFlows sub-skill | v2 | Scaffold and implement ValueFlows-compatible zomes |
| holochain-open-dev patterns | v2 | Profiles, links to other happs, linked devices |
| ADAM (coasys) integration | v2 | AD4M perspectives and expression languages |
| unyt integration | v2 | Unit-aware numeric types for resource tracking |
| Holo hosting / edge nodes | v2 | HTTP gateway, HolOS, Holo Node ISO setup |
| Cross-LLM portability | v2 | Adapt for GLM 5, other AI clients with skill support |
| Skill graph / ecosystem orchestrator | v2 | Parent skill routing to domain sub-skills |
| GUI / visual programming | v3+ | No-code interface with DHT model explorer |
| Diagram generation | v3+ | Visual architecture and data flow diagrams |
| Progressive disclosure UI | v3+ | 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 rulesScaffold— New project and new domain scaffolding workflowsImplementZome— Full CRUD zome implementationDesignAccessControl— Capability grants and admin patternsPackageAndDeploy— Kangaroo-Electron packaging and CI/CDReviewZome— 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,Processentry 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:
- Audit differences — what did each version evolve to independently?
- Extract shared knowledge — create canonical knowledge files usable by both
- Layer PAI on top — PAI SKILL.md wraps shared files and adds PAI-specific features (voice, project routing, Algorithm integration)
- Publish shared core — vanilla skill becomes the community baseline; PAI version is a superset
Version History
| Version | Date | Changes |
|---|---|---|
| 0.1.0 | 2026-03-12 | Initial vanilla skill — 6 domains, 5 workflows, requirements spec |
| 0.1.1 | 2026-03-12 | Agent Skills Open Standard conformance, multi-platform README, testing plan |
| 0.1.2 | 2026-05-15 | Version bump to Holochain 0.6.1 (hdk=0.6.1, hdi=0.7.1); references/wind-tunnel.md shipped ahead of schedule |
| 0.2.0 | 2026-05-15 | Expanded 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.0 | 2026-08-17 | First 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:
| Detected | TTY | Behaviour |
|---|---|---|
| none | either | .claude/skills and .agents/skills, and says so |
| exactly one | either | that one, silently |
| more than one | yes | numbered prompt, Enter for all |
| more than one | no | all 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.mdexists and it can be rsynced or symlinked straight into a harness directory.packages.<system>.defaultis 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
| # | Decision | Rejected alternative | Why |
|---|---|---|---|
| D1 | Payload under skills/<name>/ | skill/ singular | Plural leaves room for the v2 ecosystem skills and matches what npm skill managers index |
| D2 | Repo holochain-agent-skills, skill holochain | Match them | The spec ties the directory to the frontmatter name; the repo is free to describe the collection |
| D3 | Explicit install command | npm postinstall | bun add must never write into a consumer’s tree. An install that mutates a project by surprise is worse than one extra command |
| D4 | Unscoped npm name at 1.0 | Wait for @holochain | The scope belongs to the Holochain Foundation. Ask after shipping; never let a release wait on a third party’s decision |
| D5 | Detection by marker directory | Detect the skills directory | The skills directory usually does not exist yet, which is exactly the case an installer is for, so testing for it detects nothing |
| D6 | Two Nix output shapes | One | Consumers want the skill root; the bundle mirrors the archive. Collapsing them makes one of the two callers wrong |
| D7 | Assert tarball contents in CI | Trust the files array | The array is the only thing between a consumer and a skill directory full of CI config, and it is one typo wide |
| D8 | Reproducible archives | Ship whatever tar emits | A checksum pinned in a Nix expression must stay valid across rebuilds of the same commit |
Failure modes, and what catches each
| Failure | Caught by | Fails at |
|---|---|---|
| Workshop file reaches a consumer | package CI job asserting the pack manifest | pull request |
| Frontmatter name drifts from the directory | validator, and again in the Nix check phase | pull request and nix build |
| A routing target is deleted or renamed | validator resolution check | pull request |
| A removed-in-0.7 API enters a template | validator forbidden-API scan over shipped code | pull request |
| Version declarations drift apart | check-versions.sh, three in-tree and again against the tag | pull request and release |
| The installer stops installing | smoke test running it for real into a scratch project | pull request and release |
| An archive changes shape | extract-and-assert step | pull request and release |
| A published checksum stops matching | check-reproducible.sh, asserting the fixed epoch on every member | pull 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
| # | Test | Pass Condition |
|---|---|---|
| T1.1 | name field present | Key exists in frontmatter |
| T1.2 | name is lowercase | Value is holochain (no uppercase, no spaces) |
| T1.3 | name uses only alphanumeric + hyphens | Regex: ^[a-z0-9-]+$ |
| T1.4 | description field present | Key exists |
| T1.5 | description is between 1 and 1024 characters | wc -c on the value |
| T1.6 | description mentions primary use cases | Contains: “zome”, “HDK”, “Holochain” |
| T1.7 | license field is Apache-2.0 | Value exactly matches |
| T1.8 | compatibility field present and non-empty | Key exists, value not blank |
| T1.9 | metadata.author is soushi888 | Value matches |
| T1.10 | metadata.version is present | Key exists, SemVer format |
| T1.11 | metadata.holochain-versions references current pins | Contains 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
| # | File | Exists? |
|---|---|---|
| T2.1 | references/workflows/design-data-model.md | ☐ |
| T2.2 | references/workflows/scaffold.md | ☐ |
| T2.3 | references/workflows/implement-zome.md | ☐ |
| T2.4 | references/workflows/design-access-control.md | ☐ |
| T2.5 | references/workflows/package-and-deploy.md | ☐ |
| T2.6 | references/architecture.md | ☐ |
| T2.7 | references/scaffolding.md | ☐ |
| T2.8 | references/patterns.md | ☐ |
| T2.9 | references/access-control.md | ☐ |
| T2.10 | references/cell-cloning.md | ☐ |
| T2.11 | references/error-handling.md | ☐ |
| T2.12 | references/testing.md | ☐ |
| T2.13 | references/client.md | ☐ |
| T2.14 | references/deployment.md | ☐ |
| T2.15 | LICENSE at repo root | ☐ |
| T2.16 | README.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 phrase | Expected file | Content check |
|---|---|---|---|
| T3.1 | “design data model” | references/workflows/design-data-model.md | Contains Step 1 (domains/zome pairs) and Step 2 (entry type definition) |
| T3.2 | “new happ” | references/workflows/scaffold.md | Contains Nix install and hc scaffold happ commands |
| T3.3 | “implement zome” | references/workflows/implement-zome.md | Contains hc scaffold entry-type and integrity/coordinator structure |
| T3.4 | “who can call” | references/workflows/design-access-control.md | Contains CapAccess::Unrestricted, CapAccess::Assigned |
| T3.5 | “package” | references/workflows/package-and-deploy.md | Contains Kangaroo-Electron setup steps |
For each Context Files entry in SKILL.md:
| # | Load-when trigger | Expected file | Content check |
|---|---|---|---|
| T3.6 | coordinator/integrity split | references/architecture.md | Contains hdi and hdk crate explanation |
| T3.7 | Nix flake setup | references/scaffolding.md | Contains nix develop and flake.nix |
| T3.8 | entry types, CRUD | references/patterns.md | Contains #[hdk_entry_helper] and create_entry() |
| T3.9 | cap grants | references/access-control.md | Contains CapAccess::Unrestricted and init() |
| T3.10 | cell cloning | references/cell-cloning.md | Contains createCloneCell and clone_limit |
| T3.11 | WasmError | references/error-handling.md | Contains WasmError and ExternResult |
| T3.12 | Sweettest tests | references/testing.md | Contains await_consistency and two-agent scenario |
| T3.13 | holochain-client | references/client.md | Contains callZome and signal handling |
| T3.14 | packaging, Kangaroo | references/deployment.md | Contains .webhapp and versioning guidance |
T4 — Content Coverage
Verify each of the 6 skill domains has substantive (non-stub) content.
| # | Domain | Primary file | Pass condition |
|---|---|---|---|
| T4.1 | Architecture | references/architecture.md | > 100 lines, covers integrity/coordinator split |
| T4.2 | Design | references/workflows/design-data-model.md | Has at least 4 numbered steps with examples |
| T4.3 | Scaffold | references/scaffolding.md + references/workflows/scaffold.md | Contains nix develop, hc scaffold happ, Nix flake template |
| T4.4 | Implement | references/patterns.md | Contains CRUD patterns, link types, validation section |
| T4.5 | Test | references/testing.md | Contains Sweettest setup, await_consistency, two-agent example |
| T4.6 | Deploy | references/deployment.md + references/workflows/package-and-deploy.md | Contains 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 validate | Expected form | File |
|---|---|---|---|
| T5.1 | Entry type macro | #[hdk_entry_helper] on struct | references/patterns.md |
| T5.2 | Entry type enum in integrity | #[hdk_entry_types] on enum with #[unit_enum(UnitEntryTypes)] | references/patterns.md |
| T5.3 | Create entry | create_entry(EntryTypes::MyEntry(entry)) | references/patterns.md |
| T5.4 | Get entry | get(hash, GetOptions::default()) or must_get_entry(hash) | references/patterns.md |
| T5.5 | Delete link | delete_link(link_hash, GetOptions::default()) (second arg required in 0.6) | references/patterns.md |
| T5.6 | Link types enum | #[hdk_link_types] on enum | references/patterns.md |
| T5.7 | Update chain tracking | create_link(original_hash, new_hash, LinkTypes::EntryUpdates, ()) | references/patterns.md |
| T5.8 | Validation signature | pub fn validate(op: Op) -> ExternResult<ValidateCallbackResult> | references/patterns.md |
| T5.9 | post_commit infallible | #[hdk_extern(infallible)] + pub fn post_commit(...) | references/architecture.md or references/patterns.md |
| T5.10 | Remote signal cap grant | CapAccess::Unrestricted grant created in init() | references/access-control.md |
| T5.11 | consistency call | await_consistency([&alice_cell, &bob_cell]) | references/testing.md |
| T5.12 | Scaffold compile check | hc 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/
| # | Check | Expected value | Pass condition |
|---|---|---|---|
| T5.13 | hdk pin in SKILL.md Quick Reference | "=0.7.0" | All occurrences match |
| T5.14 | hdi pin in SKILL.md Quick Reference | "=0.8.0" | All occurrences match |
| T5.15 | holonix ref in SKILL.md and references/scaffolding.md | main-0.7 | All occurrences match |
| T5.16 | No file references hdk = "0.6.*" or older | — | Zero matches |
| T5.17 | PackageAndDeploy.md Cargo.toml example pins match current | hdk = "=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
| # | Test | Pass condition |
|---|---|---|
| T6.1 | Skill installed at the printed path | .claude/skills/holochain/SKILL.md exists |
| T6.2 | references/ present | .claude/skills/holochain/references/workflows/ holds 8 files |
| T6.3 | assets/templates/ present | 17 template files copied |
| T6.4 | No workshop files leaked | No book.toml, SUMMARY.md, CHANGELOG.md, CLAUDE.md, docs/ or scripts/ under the installed directory |
| T6.5 | No build output copied | No 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.
| # | Test | Setup | Pass condition |
|---|---|---|---|
| T6.6 | Nothing detected | Empty directory | Falls back to .claude/skills and .agents/skills, and says so |
| T6.7 | Exactly one detected | mkdir .cursor | Installs to .cursor/skills without asking |
| T6.8 | Several detected, no TTY | mkdir .claude .opencode .github, run with stdin closed | Installs to all three, does not prompt, does not hang |
| T6.9 | Several detected, TTY | Same, under a pty | Prompts, honours a numeric selection, installs only what was chosen |
| T6.10 | Unknown target | --target nope | Exits 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
| # | Test | Pass condition |
|---|---|---|
| T6.11 | One command, correct shape | .claude/skills/holochain/SKILL.md exists, no rename step needed |
| T6.12 | Checksums verify | sha256sum -c SHA256SUMS passes for every asset |
| T6.13 | Archives are reproducible | Two builds of the same commit produce identical SHA256SUMS |
Option D - symlink, for skill development
git clone https://github.com/Soushi888/holochain-agent-skills ~/holochain-agent-skills
cd /tmp/t6d && node ~/holochain-agent-skills/bin/install.mjs install --link
| # | Test | Pass condition |
|---|---|---|
| T6.14 | Symlink created | .claude/skills/holochain is a symlink to skills/holochain in the clone |
| T6.15 | git pull propagates | Pull in the clone, the symlinked install sees the change immediately |
| T6.16 | Reinstall over a symlink | A subsequent plain install replaces the symlink with a real directory rather than failing |
Option E - Nix
| # | Test | Command | Pass condition |
|---|---|---|---|
| T6.17 | Skill derivation is rooted at the skill | nix build .#holochain | $out/SKILL.md exists |
| T6.18 | Bundle matches the archive shape | nix build .#default | $out/holochain/SKILL.md exists |
| T6.19 | The derivation gates on the validator | Seed a frontmatter/directory name mismatch | nix build fails with that message |
| T6.20 | Flake checks pass | nix flake check | Exit 0 |
| T6.21 | mkSkillsHook survives a second shell | Enter a consumer devShell twice | No permission error on the second entry |
T7 — Invocation Tests
Verify the skill loads and responds correctly in Claude Code.
| # | Test | Steps | Pass condition |
|---|---|---|---|
| T7.1 | Explicit command invocation | Type /holochain in Claude Code | Skill loads, greets with Holochain context |
| T7.2 | Natural language trigger — workflow | Type “implement zome for Profile entry type” | references/workflows/implement-zome.md guidance appears |
| T7.3 | Natural language trigger — context file | Type “how do I set up a Sweettest test?” | references/testing.md content cited |
| T7.4 | Natural language trigger — scaffold | Type “scaffold a new happ called my-network” | references/workflows/scaffold.md steps appear |
| T7.5 | Version question | Ask “what version of hdk does this skill target?” | Responds with 0.7.0 |
| T7.6 | Out-of-scope question | Ask a non-Holochain question | Skill 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.
| # | Test | Steps | Pass condition |
|---|---|---|---|
| T8.1 | No ~/.claude/PAI/ required | Temporarily rename ~/.claude/PAI/ to ~/.claude/PAI_bak/, invoke skill | Skill loads without error |
| T8.2 | No voice curl in skill files | grep -r "localhost:8888" . | Zero matches |
| T8.3 | No Algorithm routing references | grep -r "ALGORITHM|AlgorithmMode|PAI/Algorithm" . | Zero matches in skill files |
| T8.4 | No PROJECTS.md references | grep -r "PROJECTS.md" . | Zero matches in skill files |
| T8.5 | Restore PAI after test | mv ~/.claude/PAI_bak ~/.claude/PAI | Restore 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”
| # | Step | Pass condition |
|---|---|---|
| T9.A.1 | Step 1: Identify domains | Skill asks or describes how to map business nouns to zome pairs |
| T9.A.2 | Step 2: Define entry types | Produces a Rust struct definition with field types |
| T9.A.3 | Step 3: Define link types | Produces at least AgentTo*, PathTo*, *Updates link types |
| T9.A.4 | Step 4: Discovery strategy | Explains Path anchor vs. agent-linked discovery tradeoffs |
| T9.A.5 | Step 5: Validation rules | Produces at least one validation rule per entry type |
| T9.A.6 | Output completeness | Produces a summary table or structured output usable as implementation spec |
T9.B — Scaffold
Trigger: “scaffold new happ called community-app”
| # | Step | Pass condition |
|---|---|---|
| T9.B.1 | Nix install step | Provides curl Determinate Nix installer command |
| T9.B.2 | flake.nix creation | Provides template with holonix ref=main-0.7 |
| T9.B.3 | hc scaffold happ command | Correct command with app name parameter |
| T9.B.4 | First DNA scaffold | hc scaffold dna command shown |
| T9.B.5 | First zome pair scaffold | hc scaffold zome for integrity + coordinator |
| T9.B.6 | Compile verification | hc s sandbox generate workdir/ step present |
T9.C — ImplementZome
Trigger: “implement zome for Profile entry type”
| # | Step | Pass condition |
|---|---|---|
| T9.C.1 | Scaffold step | hc scaffold entry-type Profile and link-type commands shown |
| T9.C.2 | Integrity crate | Produces Profile struct with #[hdk_entry_helper], entry type enum |
| T9.C.3 | Validation function | Produces validate() function with Op pattern matching |
| T9.C.4 | Coordinator — create | Produces create_profile() using create_entry() |
| T9.C.5 | Coordinator — read | Produces get_profile() using get() with GetOptions::default() |
| T9.C.6 | Coordinator — update | Uses update_entry() and create_link() for update chain |
| T9.C.7 | Coordinator — delete | Uses delete_entry() and handles link cleanup |
| T9.C.8 | Test scaffold | Produces at minimum a two-agent Sweettest structure |
T9.D — DesignAccessControl
Trigger: “design access control for my admin zome”
| # | Step | Pass condition |
|---|---|---|
| T9.D.1 | Caller mapping table | Produces table of function → caller type |
| T9.D.2 | Unrestricted grant | Shows init() with CapAccess::Unrestricted for remote signals |
| T9.D.3 | Progenitor check | Shows dna_info().provenance check for admin-only functions |
| T9.D.4 | Assigned grant | Shows CapAccess::Assigned pattern with agent key |
| T9.D.5 | recv_remote_signal | Shows correct extern signature and cap grant pairing |
T9.E — PackageAndDeploy
Trigger: “package my happ for desktop distribution”
| # | Step | Pass condition |
|---|---|---|
| T9.E.1 | Version compatibility check | Asks for or checks hdk/hdi versions before proceeding |
| T9.E.2 | Kangaroo-Electron setup | git clone command for Kangaroo repo shown |
| T9.E.3 | .happ bundle step | hc app pack or equivalent command shown |
| T9.E.4 | .webhapp bundle step | UI + .happ combined packaging step shown |
| T9.E.5 | Versioning guidance | Explains semantic version bump for DNA updates vs UI-only updates |
| T9.E.6 | CI/CD note | At minimum mentions GitHub Actions or manual release process |
T10 — Cross-Tool Compatibility
Claude Code (primary)
Covered by T7 and T9 above.
GitHub Copilot
| # | Test | Pass condition |
|---|---|---|
| T10.1 | Install to .claude/skills/holochain/ in project root | Directory exists with SKILL.md |
| T10.2 | Copilot agent mode recognizes skill | Skill name holochain appears in available skills list |
| T10.3 | Basic invocation | Copilot responds with Holochain context when asked about zomes |
Cursor
| # | Test | Pass condition |
|---|---|---|
| T10.4 | Install to .claude/skills/holochain/ in project root | Directory exists with SKILL.md |
| T10.5 | Cursor agent detects skill | Skill is listed or referenced in agent context |
| T10.6 | Basic invocation | Cursor responds with Holochain guidance when triggered |
Augment Code
| # | Test | Pass condition |
|---|---|---|
| T10.7 | Install to .claude/skills/holochain/ | Directory exists |
| T10.8 | Skill loaded by Augment | Skill context is included in agent workspace |
OpenAI Codex CLI
| # | Test | Pass condition |
|---|---|---|
| T10.9 | Install to .claude/skills/holochain/ | Directory exists |
| T10.10 | Codex reads SKILL.md frontmatter | Invocation triggers Holochain-domain responses |
Note: T10.2–T10.10 require access to each tool. Mark as
N/Aif the tool is not installed. T10.1 and T10.4 are always testable.
T11 — Repository Hygiene
| # | Test | Command | Pass condition |
|---|---|---|---|
| T11.1 | LICENSE file is Apache-2.0 | head -3 LICENSE | Contains “Apache License, Version 2.0” |
| T11.2 | No Plans/ content ships as skill | SKILL.md routing table has no reference to Plans/ | Zero Plans/ entries in routing table |
| T11.3 | No docs/ loaded by skill | SKILL.md routing table has no reference to docs/ | Zero docs/ entries in routing table |
| T11.4 | No broken markdown links | Scan for relative markdown links in all files | All linked files exist |
| T11.5 | No TODO / STUB markers | grep -rn "TODO|STUB|PLACEHOLDER" . --include="*.md" | Zero matches in non-Plans/ files |
| T11.6 | README install command is the one that works | Run the first fenced command in README.md verbatim in a scratch directory | Skill installed, exit 0 |
| T11.8 | Nothing outside the payload ships | npm 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.9 | The four version declarations agree | sh scripts/check-versions.sh | Exit 0 |
| T11.7 | CLAUDE.md license annotation | grep "Apache" CLAUDE.md | Matches 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
| Check | Result | Evidence |
|---|---|---|
hdk pin matches crates.io stable | PASS | hdk 0.7.0 |
hdi pin matches crates.io stable | PASS | hdi 0.8.0 |
holochain pin matches crates.io stable | PASS | holochain 0.7.0 |
@holochain/client pin matches npm latest | PASS | 0.21.0, published 2026-07-31 |
@holochain/hc-spin pin matches npm latest | PASS | 0.700.0, published 2026-07-31 |
| holonix ref exists and is the 0.7 line | PASS | branch main-0.7, tip ffcc7c63b4b87dde16a69247775639b49c5778b1, matched by the example’s flake.lock |
| kangaroo-electron ref bundles 0.7 | PASS | branch main-0.7, kangaroo.config.ts declares holochainVersion: '0.7.0' |
| Scaffolder guidance names the stable release | PASS | holochain_scaffolding_cli 0.700.0, and the skill documents that holonix bundles 0.700.0-rc.0 instead |
| Companion libraries checked by pin, not tag | PASS | Wind 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
| Check | Result | Evidence |
|---|---|---|
| Validator passes on a clean tree | PASS | sh scripts/validate-skill.sh exits 0 |
| No orphaned markdown | PASS | validator orphan check |
| Every routing target resolves | PASS | validator routing check |
| No 0.6-era removed APIs in code blocks | PASS | validator forbidden-API check |
| Old JS test harness retired to a single pointer | PASS | validator retirement check |
| No leftover placeholder markers | PASS | validator marker check |
| Docs site builds | PASS | mdbook build exits 0, writes book/ |
Code correctness
| Check | Result | Evidence |
|---|---|---|
| Example hApp zomes compile to wasm | PASS | RUSTFLAGS='--cfg getrandom_backend="custom"' cargo build --release --target wasm32-unknown-unknown, exit 0 |
| DNA packs from freshly built wasms | PASS | hc dna pack, exit 0, wrote forum.dna |
| hApp packs | PASS | hc app pack, exit 0, wrote my_forum.happ |
| Sweettest suite passes | PASS | cargo 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 scaffolder | PASS | Example 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 example | PASS | assets/templates/integrity-lib.rs derived from the adopted source, zero WrongActionError |
Skill efficacy
| Check | Result | Evidence |
|---|---|---|
| Live end-to-end, run A (2026-08-17) | PASS, with a caveat | GLM 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 caveat | A 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 isolation | OPEN | The 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 idiom | PASS | try_from_action present, zero WrongActionError, LinkQuery::try_new and GetStrategy used, zero removed-in-0.7 APIs |
| Routing regression guard | PASS | scripts/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 infrastructure | N/A | Requires 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/A | Deferred 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 support | N/A | Holochain scaffolding refuses to set up Nix on Windows (ScaffoldError::NixSetupError). Upstream limitation, not a skill gap |
Community readiness
| Check | Result | Evidence |
|---|---|---|
| License present | PASS | LICENSE, Apache-2.0 |
| Contribution guide present and accurate to this repo | PASS | CONTRIBUTING.md, written against the actual scripts and layout |
| Code of conduct present | PASS | CODE_OF_CONDUCT.md, Contributor Covenant 2.1 |
| Issue templates present | PASS | bug report, feature request, stale version report, plus config.yml |
| PR template present | PASS | .github/pull_request_template.md |
| CI runs the validator | PASS | .github/workflows/validate.yml |
| Requirements open questions resolved or explicitly deferred | PASS | docs/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 tree | PASS | MEMORY/ 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 |