Tutorial 8 — Extend Yano Without Forking It
Open the custom-plugin path in App-Chain Studio
- Level: Java application developer
- Time: 30–60 minutes for a first plugin
- Outcome: build, sign and pin a small state-machine plugin, and install the same JAR on every member of the standard JVM distribution.
You will write a shipment machine that records the latest status of each
shipment. It is deliberately small: the point is the path from scaffold to a
pinned, verifiable JAR. The plugin pages explain each part in depth; this
tutorial links to them instead of repeating them.
Before you start
Section titled “Before you start”- Java 25 and Gradle (or your organization’s Gradle wrapper).
- An extracted Yano X JVM distribution. This tutorial assumes
/opt/yano-xand runs every./yano.shcommand from that directory. - A 32-byte publisher seed, written as 64 hexadecimal characters to a file
outside any repository, for signing. For a throwaway tutorial key:
(umask 077; openssl rand -hex 32 > /secure/publisher.seed). - The exact Yano host version in your distribution’s manifest, printed by
jq -r .version yano-distribution-v1.json. The host and Yano X have separate versions.
1. Choose the smallest extension
Section titled “1. Choose the smallest extension”A Java plugin is the last rung, not the first. Check whether configuration or declarative bindings already model your outcome:
What you're seeing: start with the smallest extension that models your outcome. Each answer says what joins chain identity and how you change it later.
What do you need to change?
- What the ledger records, and how commands change it
Does a stock machine or recipe already model the outcome?
- YesConfiguration only
Select a stock machine or recipe, identically on every member. No JAR, no build.
- Code
- None
- Joins chain identity
- The selected machine or profile and its committed settings
- To change it later
- A fresh chain, or a governed activation where the setting allows one
- No
Do existing machines already have the commands and events you need?
For example: when a registry put happens, append an audit entry; when an approval passes, apply a map write.
- Yes, I need to connect them
Can bindings express the coordination?
Bindings map typed event fields to commands, check conditions and lookups, and attach forbid-only admission rules, with restricted CEL. They cannot call Java, query the network, loop, or create authority.
- YesDeclarative bindings
The bundled
declarative-compositemachine connects existing machines. You write YAML; the tooling compiles it to canonical IR that every member executes.- Code
- None: YAML compiled to IR
- Joins chain identity
- The IR, component settings and limits, in the composite profile digest
- To change it later
- A new workflow generation by governed activation; preflight with
bindings profile-check
- No, it needs coordination outside that languageA Java composite plugin
A small provider that arranges existing components: their order, topics, quotas and workflow transitions. It is short, but it is consensus code.
- Code
- A small, reviewed Java provider
- Joins chain identity
- The profile id and digest; component order is committed profile data
- To change it later
- Stage the reviewed bundle on every member, then a governed activation
- Yes
- No, I need new state or new transition rulesA state-machine plugin
Implement
AppStateMachineand contribute it throughAppStateMachineProvider. Yourapplyruns on every member and must be deterministic.- Code
- A codec, admission checks and a deterministic
apply - Joins chain identity
- The machine id and committed settings; every member runs the same bundle
- To change it later
- A governed activation, or a new id and namespace; never member by member
- Yes, I need to connect them
- Yes
- Something around the ledger: actions, block streams, Cardano facts, read APIs or keys
Which of these?
- Perform an external action after a decision is finalAn effect executor
Performs an authorized external action after the effect’s finality gate, outside
apply. With result policyCHAIN, the outcome comes back as a member-signed~fx/resultmessage.- Contribution
AppEffectExecutorFactory, trust tierPRIVILEGED_LOCAL- Reaches state
- Only through the ordered
~fx/resultinput - Configuration
- Endpoints and secrets stay node-local
- Deliver finalized blocks to another systemA finalized-stream sink
Receives finalized blocks and delivers them to another system, such as Kafka or a webhook. Nothing flows back into the ledger.
- Contribution
FinalizedStreamSinkFactory, trust tierAUXILIARY_LOCAL- Reaches state
- No
- Bring Cardano deposits or metadata into the ledgerAn L1 observer
Watches Cardano for deposits or metadata labels. Observations enter as
~l1/*messages that every member re-derives from its own L1 view before it votes.- Contribution
L1ObserverProvider, trust tierCONSENSUS- Reaches state
- Yes, as ordered, certified inputs
- Joins chain identity
- Observer profiles are committed in the consensus-context digest
- Change how proposers are chosenA sequencer mode
Decides who may propose at each height and whose proposals are acceptable. It cannot weaken finality: certificates and vote locks are enforced by the framework.
- Contribution
SequencerModeProvider, trust tierCONSENSUS- Reaches state
- No, but every member must run the same mode
- Serve a read API, or keep member keys outside the nodeA domain API or a signer
A domain API serves bounded reads over committed state; keep it read-only unless commands still enter through authenticated submission. A signer keeps the member key in external custody.
- Contribution
DomainApiProviderorSignerProviderFactory, trust tierPRIVILEGED_LOCAL- Reaches state
- No
- Perform an external action after a decision is final
What do you need to change?
Does a stock machine or recipe already model the outcome?
Do existing machines already have the commands and events you need?
For example: when a registry put happens, append an audit entry; when an approval passes, apply a map write.
Can bindings express the coordination?
Bindings map typed event fields to commands, check conditions and lookups, and attach forbid-only admission rules, with restricted CEL. They cannot call Java, query the network, loop, or create authority.
Which of these?
Select a stock machine or recipe, identically on every member. No JAR, no build.
- Code
- None
- Joins chain identity
- The selected machine or profile and its committed settings
- To change it later
- A fresh chain, or a governed activation where the setting allows one
The bundled declarative-composite machine connects existing machines. You write YAML; the tooling compiles it to canonical IR that every member executes.
- Code
- None: YAML compiled to IR
- Joins chain identity
- The IR, component settings and limits, in the composite profile digest
- To change it later
- A new workflow generation by governed activation; preflight with
bindings profile-check
A small provider that arranges existing components: their order, topics, quotas and workflow transitions. It is short, but it is consensus code.
- Code
- A small, reviewed Java provider
- Joins chain identity
- The profile id and digest; component order is committed profile data
- To change it later
- Stage the reviewed bundle on every member, then a governed activation
Implement AppStateMachine and contribute it through AppStateMachineProvider. Your apply runs on every member and must be deterministic.
- Code
- A codec, admission checks and a deterministic
apply - Joins chain identity
- The machine id and committed settings; every member runs the same bundle
- To change it later
- A governed activation, or a new id and namespace; never member by member
Performs an authorized external action after the effect’s finality gate, outside apply. With result policy CHAIN, the outcome comes back as a member-signed ~fx/result message.
- Contribution
AppEffectExecutorFactory, trust tierPRIVILEGED_LOCAL- Reaches state
- Only through the ordered
~fx/resultinput - Configuration
- Endpoints and secrets stay node-local
Receives finalized blocks and delivers them to another system, such as Kafka or a webhook. Nothing flows back into the ledger.
- Contribution
FinalizedStreamSinkFactory, trust tierAUXILIARY_LOCAL- Reaches state
- No
Watches Cardano for deposits or metadata labels. Observations enter as ~l1/* messages that every member re-derives from its own L1 view before it votes.
- Contribution
L1ObserverProvider, trust tierCONSENSUS- Reaches state
- Yes, as ordered, certified inputs
- Joins chain identity
- Observer profiles are committed in the consensus-context digest
Decides who may propose at each height and whose proposals are acceptable. It cannot weaken finality: certificates and vote locks are enforced by the framework.
- Contribution
SequencerModeProvider, trust tierCONSENSUS- Reaches state
- No, but every member must run the same mode
A domain API serves bounded reads over committed state; keep it read-only unless commands still enter through authenticated submission. A signer keeps the member key in external custody.
- Contribution
DomainApiProviderorSignerProviderFactory, trust tierPRIVILEGED_LOCAL- Reaches state
- No
The extension ladder describes every rung.
2. Scaffold the plugin
Section titled “2. Scaffold the plugin”./yano.sh appchain plugin scaffold \ --mode state-machine \ --id shipment \ --package com.example.shipment \ --yano-version <matching-yano-host-version> \ --output shipment-pluginThe scaffold is a small Gradle project. It compiles against the host API as
compileOnly, so the JAR never packages Yano API classes. It contains:
src/main/java/com/example/shipment/ShipmentStateMachineProvider.java, the provider, which does no business work yet;- its
META-INF/services/org.yanoproject.api.appchain.AppStateMachineProviderentry; - the runtime manifest
META-INF/yano/plugins/plugin-bundle.shipment.json; and - the product catalog
META-INF/yano/appchain-component-catalog-v1.json, which declares the capabilitystate:shipment.
The tool refuses a non-empty output directory. Other modes are
composite-role, effect-executor and sink.
The generated build.gradle resolves the host API from Maven Central. If your
distribution’s host version is a commit snapshot (it ends in -SNAPSHOT), add
maven { url = uri('https://repo.bloxbean.org/maven/snapshots') } to its
repositories block.
3. Implement the machine
Section titled “3. Implement the machine”Replace the generated provider file with this one. A message body is the UTF-8
text <shipment-id>=<status>; the machine stores each shipment’s latest
status.
package com.example.shipment;
import com.bloxbean.cardano.yaci.core.protocol.appmsg.model.AppMessage;import org.yanoproject.api.appchain.AppBlockExecutionContext;import org.yanoproject.api.appchain.AppStateMachine;import org.yanoproject.api.appchain.AppStateMachineProvider;import org.yanoproject.api.appchain.AppStateWriter;import org.yanoproject.api.appchain.effects.AppEffectEmitter;
import java.nio.charset.StandardCharsets;import java.util.Optional;
public final class ShipmentStateMachineProvider implements AppStateMachineProvider { @Override public String id() { return "shipment"; }
@Override public AppStateMachine create() { return new Machine(); }
/** Stores the latest status of each shipment. */ private static final class Machine implements AppStateMachine { private static final int MAX_BODY_BYTES = 256;
@Override public String id() { return "shipment"; }
@Override public AdmissionResult validate(AppMessage message) { // A fast structural check; never perform I/O here. return parse(message.getBody()).isPresent() ? AdmissionResult.accept() : AdmissionResult.reject("INVALID_SHIPMENT_COMMAND"); }
@Override public void apply(AppBlockExecutionContext context, AppStateWriter writer, AppEffectEmitter effects) { for (AppMessage message : context.messages()) { // Finalized bytes can still be malformed: skip them, never throw. parse(message.getBody()).ifPresent(update -> writer.put( ("shipment/" + update[0]).getBytes(StandardCharsets.UTF_8), update[1].getBytes(StandardCharsets.UTF_8))); } }
private static Optional<String[]> parse(byte[] body) { if (body == null || body.length == 0 || body.length > MAX_BODY_BYTES) { return Optional.empty(); } String text = new String(body, StandardCharsets.UTF_8); int split = text.indexOf('='); if (split <= 0 || split == text.length() - 1) { return Optional.empty(); } return Optional.of(new String[]{text.substring(0, split), text.substring(split + 1)}); } }}Three rules matter here, and Consensus rules explains them all:
apply(AppBlockExecutionContext, AppStateWriter, AppEffectEmitter)is the host’s only execution entry point. It runs on every member and must give byte-identical state: no clock, randomness or I/O.validateis a filter at ingress, not a security boundary.applymust still treat every message as hostile and bound its work.- A rejection reason is a public code: at most 32 uppercase letters or underscores.
Read Determinism rules before you
add more logic. To act on an external system, emit an effect from apply and
let an executor perform it after finality; see effects.
4. Test it
Section titled “4. Test it”Unit-test parse and apply first, including empty, oversized and malformed
bodies. Then climb the testing ladder:
replay, hostile input, an embedded multi-member cluster, and a packaged JVM
cluster. The plugin template shows unit,
conformance and domain API tests for a similar machine.
5. Sign, build and validate
Section titled “5. Sign, build and validate”Sign the exact catalog and runtime manifest, then build the JAR so it carries the signature:
./yano.sh appchain plugin sign \ --catalog shipment-plugin/src/main/resources/META-INF/yano/appchain-component-catalog-v1.json \ --runtime-manifest shipment-plugin/src/main/resources/META-INF/yano/plugins/plugin-bundle.shipment.json \ --seed-file /secure/publisher.seed \ --key-id example-release-2026 \ --output shipment-plugin/src/main/resources/META-INF/yano/appchain-component-catalog-v1.sig.json
(cd shipment-plugin && gradle jar)
./yano.sh appchain plugin validate shipment-plugin/build/libs/shipment-yano-plugin.jar \ --trust-key example-release-2026=<64-hex-public-key> \ --output shipment-catalog.jsontools/yano-plugins/bin/yano-plugins validate shipment-plugin/build/libs/shipment-yano-plugin.jarplugin sign ends with PLUGIN_CATALOG_SIGNED key=example-release-2026 public-key=<64 hex characters>; that public key is the
<64-hex-public-key> for --trust-key here and in step 6. plugin validate
prints PLUGIN_CATALOG_VALID and SNAPSHOT_WRITTEN shipment-catalog.json,
and yano-plugins validate prints VALID.
Pass the seed by file only, and keep it out of the repository. plugin validate
verifies the signature and exports a data-only snapshot; yano-plugins validate runs the node’s structural checks. Neither runs your code.
SPI and manifest explains what the
signature does and does not cover.
6. Pin it into a project
Section titled “6. Pin it into a project”./yano.sh appchain init --non-interactive \ --recipe custom-plugin --network devnet --members 3 --runtime jvm \ --capability state:shipment \ --plugin-jar shipment-plugin/build/libs/shipment-yano-plugin.jar \ --trust-key example-release-2026=<64-hex-public-key> \ --generate-local-member-keys \ --http-port-base 7370 --server-port-base 13637 \ --output shipment-chain
./yano.sh appchain config validate --mode project shipment-chainThe project stores the signed snapshot under component-catalogs/, and
appchain.lock pins the catalog, runtime manifest, configuration metadata and
complete JAR digests. Rendering and doctor re-verify the snapshot.
--generate-local-member-keys creates three disposable devnet member keys, so
you can run the chain on this machine in step 7. Their private files go to the
project’s owner-only secrets/ directory, and only the public keys are written
to appchain.yaml. For members on separate machines, pin each member’s own
public key instead, as Tutorial 9 shows. The port
bases put the nodes on HTTP ports 7370–7372.
7. Install on every member
Section titled “7. Install on every member”Copy the exact pinned JAR into plugins/ in every member’s distribution, then
check readiness:
cp shipment-plugin/build/libs/shipment-yano-plugin.jar /opt/yano-x/plugins/./yano.sh appchain prepare shipment-chain./yano.sh appchain doctor shipment-chain --distribution /opt/yano-x✓ You should see
PREPARED, then a doctor report ending inDOCTOR_WARNINGS:EXTERNAL_TARGETS_READYstaysPENDINGforreviewed-plugin-jar, a reminder that someone must review the JAR you ship.
A missing or different JAR fails artifact readiness. Yano X plugins target the JVM distribution. This project is a local devnet, so start its three nodes here; for members on separate machines, start each node as the deployment guide describes. Each node checks the JAR again at start-up, before your code runs; How plugins load lists those checks.
export YANO_HOME=/opt/yano-xshipment-chain/scripts/startshipment-chain/scripts/statusThen confirm that every member runs the same plugin catalog. drift reads each
node’s runtime identity, which needs an operator API key. On this devnet the
key is in the project’s secrets/ files; this reads it without printing it:
export YANO_OPERATOR_API_KEY="$(sed -n 's/^YANO_APPCHAIN_API_KEYS=//p' \ shipment-chain/secrets/node0.env)"./yano.sh appchain drift shipment-chain --peer http://127.0.0.1:7370/api/v1/ \ --peer http://127.0.0.1:7371/api/v1/ --peer http://127.0.0.1:7372/api/v1/ \ --api-key-env YANO_OPERATOR_API_KEY✓ You should see
MATCHorNOT_APPLICABLEfor every compared value, thenDRIFT_OK peers=3.
On a real deployment, set the key to one of the values in the nodes’
YANO_APPCHAIN_API_KEYS. Without --api-key-env, the nodes reject the
identity requests with HTTP 401. Stop the nodes with
shipment-chain/scripts/stop.
8. Change it safely later
Section titled “8. Change it safely later”Changing what apply computes is a consensus change, not a rolling upgrade.
Never roll new semantics out member by member: give the change a governed
activation height, or a new machine id and namespace. Compare a project change
with appchain diff before you apply it.
Consensus rules
describes the options.
Go deeper
Section titled “Go deeper”- Scaffold, sign, install, the same lifecycle as a reference
- Testing and deployment
- Composite implementation guide, for a Java composite
- Plugin template scaffold
- Yano core testkit
- Yano’s plugin query and domain API guide and plugin operations guide