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

Create a node in Rust

Scaffold a Rust node package, implement the handler against the AxiomContext trait, run cargo-based tests with axiom test, and push it to the platform.

View as Markdown

This guide takes you from an empty directory to a pushed Rust node: scaffold a package, define messages, implement the handler, test it, and push. If you have never built a node in any language, start with your first node; this page covers the Rust-specific workflow.

Prerequisites

  • The Axiom CLI installed and logged in (axiom login) — see installation.
  • A Rust toolchain with cargo on your PATH. If you don't have one: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh.
  • For axiom push: the package directory must be a git repository whose current HEAD is pushed to its remote.

You do not need protoc or any protoc plugin for Rust — proto compilation happens inside the build via tonic-build (see How Rust differs from other languages).

Create a Rust node in six commands

# from any directory — scaffolds ./order-tools/
# the scope before the "/" must be your Axiom handle — push rejects any other scope
axiom init your-handle/order-tools --language rust
cd order-tools
axiom create message OrderRequest --fields "order_id:string; total:double"
axiom create message OrderConfirmation --fields "order_id:string; confirmed:bool"
axiom create node ProcessOrder --input OrderRequest --output OrderConfirmation
axiom test

Then implement the handler in nodes/process_order.rs (next section), make the generated test assert real output fields, and push:

# from the package root
axiom push

What each step did:

  • axiom init your-handle/order-tools --language rust created an order-tools/ subdirectory (the part of the package name after the last /) with axiom.yaml, the standard layout (messages/, nodes/, and an empty gen/, which Rust leaves unused because codegen happens at build time), a .gitignore (ignoring .axiom/, gen/, and target/), and a Cargo.toml. Package names must be scoped as your-handle/package-name, where the scope matches your Axiom account handle. The crate is always named service — the Axiom package name lives in axiom.yaml. The manifest pins tonic, prost, tokio, tokio-stream, and serde_json as dependencies and tonic-build as a build dependency.
  • axiom create message appended a message to messages/messages.proto — the single file where all of the package's messages live. The --fields flag accepts colon shorthand (name:string), proto3 syntax with or without field numbers, and the scalar types string, int32, int64, uint32, uint64, float, double, bool, bytes.
  • axiom create node ProcessOrder created nodes/process_order.rs and nodes/process_order_test.rs (the node name must be PascalCase; the Rust function name is its snake_case form), and registered the node in axiom.yaml. Omit --input/--output to pick messages interactively.
  • axiom test validated the package and ran cargo test.
  • axiom push validated the package and pushed it tenant-private to the platform, ready to place in a flow.

Implement the node handler

A Rust node is a plain public function in nodes/<snake_name>.rs. It receives the AxiomContext trait object and a typed input message, and returns a Result with the typed output message:

// nodes/process_order.rs
use crate::axiom_context::AxiomContext;
use crate::gen::messages::{OrderRequest, OrderConfirmation};
use std::collections::HashMap;

/// Confirms an incoming order. This doc comment documents the handler for
/// anyone reading the node's source, which the registry captures at push time.
pub fn process_order(
    ax: &dyn AxiomContext,
    input: OrderRequest,
) -> Result<OrderConfirmation, Box<dyn std::error::Error>> {
    ax.log().info(
        "processing order",
        &HashMap::from([("order_id", input.order_id.clone())]),
    );
    Ok(OrderConfirmation {
        order_id: input.order_id,
        confirmed: true,
    })
}

Key points:

  • Messages are prost structs. Each protobuf message in messages/messages.proto becomes a Rust struct under crate::gen::messages with snake_case fields and a Default impl. Messages from imported packages are re-exported into the same gen::messages module — see import package types and the type system.
  • The signature is enforced. axiom validate (run automatically by axiom test, axiom build, and the publish pipeline) checks that each node declared in axiom.yaml has a function of exactly this shape: pub fn <name>(ax: &dyn AxiomContext, input: <Input>) -> Result<<Output>, ...>.
  • The registry description comes from axiom.yaml. The description field on the node's entry in axiom.yaml (set with --description on axiom create node, or edited any time before pushing) is what the Axiom registry displays as the node's description. The node's source file — including its doc comment — is captured at push time and shown in the registry's source view, so edit the scaffolded placeholder too.

Use platform capabilities through AxiomContext

AxiomContext is the only way node code reaches the platform — there is no direct network access from a node (see sandboxing and tenancy). The trait exposes:

  • ax.log() — structured logging: debug, info, warn, error, each taking a message and a &HashMap<&str, String> of attributes.
  • ax.secrets() — read-only tenant secrets: ax.secrets().get(name) returns (String, bool) — the value and whether the secret exists.
  • ax.agent().memory() — agent memory: session-scoped history and semantic search/write (see memory).
  • ax.execution_id(), ax.flow_id(), ax.tenant_id() — identifiers for the current invocation.
  • ax.reflection() and ax.mutation() — read and append-only-extend the running flow's graph (advanced agent features).

Reading a secret:

// nodes/process_order.rs — inside the handler body
let (api_key, ok) = ax.secrets().get("STRIPE_API_KEY");
if !ok {
    return Err("secret STRIPE_API_KEY is not registered".into());
}

Declare every secret the node reads in axiom.yaml so it is recorded with the node and shown in the marketplace — users then know which secrets to register before running a flow that contains the node:

# axiom.yaml
nodes:
  - name: ProcessOrder
    input: OrderRequest
    output: OrderConfirmation
    required_secrets:
      - STRIPE_API_KEY

See manage secrets in a flow for registering the secret value itself.

Test the node with axiom test

axiom create node scaffolds nodes/<snake_name>_test.rs with a mock AxiomContext (test_context()) you edit to drive scenarios. Replace the generated TODO with assertions on real output fields:

// nodes/process_order_test.rs — inside the generated #[cfg(test)] module
#[test]
fn test_process_order() {
    let ax = test_context();
    let input = OrderRequest { order_id: "ord-1".into(), total: 12.5 };
    let out = process_order(&ax, input).unwrap();
    assert_eq!(out.order_id, "ord-1");
    assert!(out.confirmed);
}

Run the suite from the package root:

# validates the package, then runs cargo test in the assembled build context
axiom test

# pass filters through to the cargo test binary after --
axiom test -- test_process_order

Do not run cargo test directly in the package root — it will fail to compile. Rust node tests are #[cfg(test)] modules of the generated service crate and reference crate:: paths that only exist once the build context is assembled. axiom test assembles that build context and runs cargo test inside it.

axiom validate (run by axiom test, axiom build, and axiom push) warns when a node has no test file or the test file defines no test. For Rust the push build compiles your crate but does not execute tests, so a green axiom test run before pushing is the quality gate that matters. Make the assertions meaningful: a test that only Ok-checks the result passes against any implementation.

Write a streaming pipeline node

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

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

A pipeline node receives an iterator of input frames and returns an iterator of results (for the start node of a pipeline flow, inputs yields exactly one item):

// nodes/stream_orders.rs
use crate::axiom_context::AxiomContext;
use crate::gen::messages::{OrderRequest, OrderConfirmation};

/// Emits one confirmation per incoming order frame.
pub fn stream_orders<I>(
    ax: &dyn AxiomContext,
    inputs: I,
) -> impl Iterator<Item = Result<OrderConfirmation, Box<dyn std::error::Error>>>
where
    I: Iterator<Item = OrderRequest>,
{
    let _ = ax;
    inputs.map(|input| {
        Ok(OrderConfirmation {
            order_id: input.order_id,
            confirmed: true,
        })
    })
}

See the execution model for how pipeline mode differs from unary execution.

How Rust differs from other languages

Three Rust-specific behaviors to know, compared with the Python, Go, and TypeScript workflows:

  • No axiom generate step. Rust defers all proto codegen to the build tool: a generated build.rs compiles messages/*.proto (plus any imported packages and the platform's node service proto) with tonic-build at cargo build. Running axiom generate in a Rust package prints a note and does nothing. No protoc or protoc plugin is required.
  • axiom dev supports Rust via a rebuild-on-save-restart loop: each saved change runs cargo build and restarts the service (compiled languages recompile rather than hot-swap, so the first build is slower while the crate graph warms up; a failed build leaves the previous server running). The HTTP bridge (default :8083) converts JSON to and from Protobuf so you can curl your nodes — the same fast loop Go/Python/TypeScript get. You can still use axiom build to produce a local Docker image identical to the publish pipeline (all artifacts are written under .axiom/ and are inspectable).
  • The crate compiles only in the assembled build context. The package root has no service.rs, build.rs, or generated bindings — those are injected when axiom test or axiom build assembles the context under .axiom/. Use axiom test rather than bare cargo commands.

Push and use the node

# from the package root, with HEAD pushed to your git remote
axiom push

axiom push validates the package locally, then pushes it to the platform tenant-private: it is visible only to your own tenant and does not appear in the public marketplace. You can push the same version repeatedly — each push overwrites the previous one — so you can iterate on deployed code. When you are satisfied, run axiom publish <package>@<version> (or use the Axiom UI) to make it an immutable public release.

Next steps: