Public beta — not for production use. Data may be wiped at any time. Questions? Contact us.
Documentation menu

Create a node in Java

Scaffold a Java package and node with the Axiom CLI, implement the handler against AxiomContext, test it with Maven, and push it to the platform.

View as Markdown

A Java node is a single public static method on a class under nodes/: it takes an AxiomContext and a typed input message, and returns a typed output message. This guide takes you from an empty directory to a pushed package — scaffold, implement, test, push.

Prerequisites

  • The Axiom CLI installed and logged in (axiom login) — see Installation.
  • JDK 21 and Maven (mvn) on your PATH. The generated pom.xml targets Java 21 (maven.compiler.release 21), and axiom test runs mvn test.
  • Docker — only needed for axiom build (optional local image build). axiom test and axiom push do not require it.
  • A git remote: axiom push requires the current HEAD to be pushed to the remote.

Create the package and node

The shortest path — four commands:

# from any working directory
axiom init my-scope/order-tools --language java
cd order-tools
axiom create message OrderRequest --fields "order_id:string; amount:double"
axiom create message OrderConfirmation --fields "order_id:string; approved:bool"
axiom create node ProcessOrder --input OrderRequest --output OrderConfirmation

What each step does:

  • axiom init creates ./order-tools/ (the part after the last /) with axiom.yaml, messages/messages.proto, a generated Maven pom.xml (marked "DO NOT EDIT" — it wires protobuf code generation and the executable jar build), and a .gitignore.
  • axiom create message appends a message block to messages/messages.proto — all messages live in that single file so they can reference each other. Comments written directly above a message or field are extracted at publish time and shown in the registry as documentation. Without --fields, the block is scaffolded with placeholder fields and syntax hints for you to edit.
  • axiom create node (the name must be PascalCase) writes nodes/process_order.java and nodes/process_order_test.java, generates the AxiomContext interface at src/main/java/axiom/AxiomContext.java, and registers the node in axiom.yaml. If --input or --output are omitted in a terminal, it prompts with a numbered list of available messages. In a terminal it also asks for the node type — press Enter to accept the default (unary), or pass --type to skip the prompt.

Keep the snake_case filenames. axiom validate and the build pipeline look up each node at nodes/<snake_name>.java; at build time the file is relocated and renamed automatically to the Maven-conventional src/main/java/nodes/ProcessOrder.java, so the working-tree name never violates Java's class-per-file convention in the compiled layout.

Implement the handler

The validator requires this exact signature shape — a public static method whose name is the camelCase form of the node name:

public static OrderConfirmation processOrder(AxiomContext ax, OrderRequest input)

Local message types are nested under the generated gen.Messages outer class, so each is imported by its fully qualified name and used by its simple name. A complete handler:

// nodes/process_order.java
package nodes;

import axiom.AxiomContext;
import gen.Messages.OrderRequest;
import gen.Messages.OrderConfirmation;
import java.util.Map;

public class ProcessOrder {

    /**
     * Approves orders under 1000.00 and echoes the order id. This Javadoc is
     * extracted at publish time and shown in the Axiom registry as the
     * node's documentation — write the real description here.
     *
     * @param ax    The AxiomContext: logging, secrets, agent memory, reflection, mutation.
     * @param input The decoded OrderRequest for this invocation.
     */
    public static OrderConfirmation processOrder(AxiomContext ax, OrderRequest input) {
        ax.log().info("processOrder handling", Map.of("order_id", input.getOrderId()));
        boolean approved = input.getAmount() < 1000.0;
        return OrderConfirmation.newBuilder()
            .setOrderId(input.getOrderId())
            .setApproved(approved)
            .build();
    }
}

Messages are standard protobuf-java classes: read fields with getters (input.getOrderId()), build outputs with newBuilder()build(). See Type system for how messages define the contract between nodes.

Reach the platform through AxiomContext

AxiomContext (the ax parameter) is the only way node code reaches the platform — everything goes through the sidecar, never a direct call. The interface lives in your package at src/main/java/axiom/AxiomContext.java:

  • ax.log() — structured logging: ax.log().info("msg", Map.of("k", "v")), plus debug, warn, error, each with a single-argument overload.
  • ax.secrets().get("NAME") — returns Optional<String>; tenant-scoped secrets resolved by the platform. Declare the names your node needs under required_secrets in axiom.yaml — see Manage secrets in a flow.
  • ax.agent().memory() — durable agent memory: search(query, limit), write(content, importance), and per-session scope via session("s1") with conversation history (history().last(20), history().append(role, content)). See Memory.
  • ax.executionId(), ax.flowId(), ax.tenantId() — identifiers for the current execution.
  • ax.reflection().flow() — read-only view of the running flow graph (nodes(), edges(), loopEdges(), position(), graphId()).
  • ax.mutation().flow() — buffered, additive mutation of the running flow graph (addNode, addEdge).

Why the indirection: node code is sandboxed and the sidecar is the trust boundary — see Sandboxing and tenancy.

Java has no axiom generate step

For Java packages, protobuf code generation is deferred to the build tool: the protobuf-maven-plugin declared in the generated pom.xml compiles messages/messages.proto (plus the platform's node service proto) during every mvn build. Running axiom generate in a Java package prints a notice and generates nothing — there is no gen/ bindings directory to commit.

Two naming rules follow from this:

  • Local messages compile with option java_package = "gen" and option java_outer_classname = "Messages", so a local message Foo is the Java type gen.Messages.Foo.
  • Imported messages (types from another package, added with axiom import) compile under imports.<proto_package>, so an imported message Bar from package acme/billing is imports.acme.billing.Bar. axiom create node writes the correct import lines for whichever source each message comes from — see Import package types.

Test the node

axiom test

axiom test validates the package, then assembles the full Maven build context (the same src/main + src/test layout axiom build compiles, with proto codegen wired into the pom) and runs mvn test inside it. Java node tests cannot run in the bare package root — they compile against the generated service, AxiomContext, and proto bindings that only exist in the assembled context.

The scaffolded nodes/process_order_test.java contains a TestContext class — a no-op AxiomContext implementation you can edit to drive a specific scenario (its secrets return empty, memory returns empty, reflection exposes an empty graph). Replace the generated testProcessOrder method with real assertions, keeping the generated imports and the TestContext class:

// nodes/process_order_test.java — replaces the generated testProcessOrder method
@Test
public void testProcessOrder() {
    AxiomContext ax = new TestContext();
    OrderRequest input = OrderRequest.newBuilder()
        .setOrderId("ord-1")
        .setAmount(250.0)
        .build();
    OrderConfirmation result = ProcessOrder.processOrder(ax, input);
    assertEquals("ord-1", result.getOrderId());
    assertTrue(result.getApproved());
}

axiom validate warns — without blocking — when a node has no test, and axiom test runs the suite with Maven. The push build does not execute tests, so a green axiom test before pushing is the quality gate that matters. Assert output fields meaningfully — not just null-checked.

Build and push

axiom validate   # axiom.yaml schema, proto definitions, node signatures, node tests
axiom build      # optional: local Docker image, identical to the publish pipeline
axiom push       # deploy to the platform, tenant-private
  • axiom validate checks axiom.yaml, compiles every proto in messages/, and verifies each node's function signature matches the expected shape. It runs automatically inside axiom build and the publish pipeline.
  • axiom build produces a local Docker image: the Java build compiles the package and packages it as an executable fat jar that runs on a JRE base image. All artifacts land in .axiom/ for inspection.
  • axiom push validates, then pushes the package to the platform. Commit and push your changes first — the platform builds from the git remote, not your working tree. A pushed package is visible only to your own tenant, and you can push the same version repeatedly while iterating; run axiom publish <package>@<version> (or use the Axiom UI) when you want an immutable public release.

Note: axiom dev supports Java via a rebuild-on-save-restart loop — each saved change runs mvn package and restarts the service (compiled languages recompile rather than hot-swap, so the first build is slower while Maven warms up; a failed build leaves the previous server running). The HTTP bridge (default :8083) lets you curl your nodes with JSON. axiom test and axiom build remain available.

Next: place the node in a flow (Build your first flow) and invoke it (Invoke a flow via the API).

Streaming pipeline nodes

Pass --type pipeline to scaffold a streaming node instead of a unary one:

axiom create node TransformFrames --input OrderRequest --output OrderConfirmation --type pipeline

A pipeline node consumes an iterator of input frames and returns a stream of output frames:

public static Stream<OrderConfirmation> transformFrames(AxiomContext ax, Iterator<OrderRequest> inputs)

When a pipeline node is the entry node of a flow, the iterator yields exactly one item. See Execution model for how pipeline mode changes flow execution.