Skip to content

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.

  • Java 25 and Gradle (or your organization’s Gradle wrapper).
  • An extracted Yano X JVM distribution. This tutorial assumes /opt/yano-x and runs every ./yano.sh command 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.

A Java plugin is the last rung, not the first. Check whether configuration or declarative bindings already model your outcome:

Which extension do you need?Decision aid

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?

    • Yes
      Configuration 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.

        • Yes
          Declarative bindings

          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
        • No, it needs coordination outside that language
          A 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
      • No, I need new state or new transition rules
        A state-machine plugin

        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
  • Something around the ledger: actions, block streams, Cardano facts, read APIs or keys

    Which of these?

    • Perform an external action after a decision is final
      An effect executor

      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 tier PRIVILEGED_LOCAL
      Reaches state
      Only through the ordered ~fx/result input
      Configuration
      Endpoints and secrets stay node-local
    • Deliver finalized blocks to another system
      A 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 tier AUXILIARY_LOCAL
      Reaches state
      No
    • Bring Cardano deposits or metadata into the ledger
      An 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 tier CONSENSUS
      Reaches state
      Yes, as ordered, certified inputs
      Joins chain identity
      Observer profiles are committed in the consensus-context digest
    • Change how proposers are chosen
      A 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 tier CONSENSUS
      Reaches state
      No, but every member must run the same mode
    • Serve a read API, or keep member keys outside the node
      A 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
      DomainApiProvider or SignerProviderFactory, trust tier PRIVILEGED_LOCAL
      Reaches state
      No

The extension ladder describes every rung.

Terminal window
./yano.sh appchain plugin scaffold \
--mode state-machine \
--id shipment \
--package com.example.shipment \
--yano-version <matching-yano-host-version> \
--output shipment-plugin

The 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.AppStateMachineProvider entry;
  • 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 capability state: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.

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.
  • validate is a filter at ingress, not a security boundary. apply must 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.

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.

Sign the exact catalog and runtime manifest, then build the JAR so it carries the signature:

Terminal window
./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.json
tools/yano-plugins/bin/yano-plugins validate shipment-plugin/build/libs/shipment-yano-plugin.jar

plugin 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.

Terminal window
./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-chain

The 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.

Copy the exact pinned JAR into plugins/ in every member’s distribution, then check readiness:

Terminal window
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 in DOCTOR_WARNINGS: EXTERNAL_TARGETS_READY stays PENDING for reviewed-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.

Terminal window
export YANO_HOME=/opt/yano-x
shipment-chain/scripts/start
shipment-chain/scripts/status

Then 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:

Terminal window
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 MATCH or NOT_APPLICABLE for every compared value, then DRIFT_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.

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.