---
title: "Create a node in Rust"
description: "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."
category: guide
surfaces: [cli, sdk]
languages: [rust]
related: [getting-started/first-node, concepts/nodes-packages-flows, concepts/type-system, guides/manage-secrets, guides/import-package-types, reference/axiom-yaml]
last_reviewed: 2026-06-06
---

# Create a node in Rust

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](../getting-started/first-node.md); this page covers the
Rust-specific workflow.

## Prerequisites

- The Axiom CLI installed and logged in (`axiom login`) — see
  [installation](../getting-started/installation.md).
- 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](#how-rust-differs-from-other-languages)).

## Create a Rust node in six commands

```bash
# 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:

```bash
# 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](../getting-started/first-flow.md).

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

```rust
// 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](./import-package-types.md) and the
  [type system](../concepts/type-system.md).
- **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](../concepts/sandboxing-and-tenancy.md)). 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](../concepts/memory.md)).
- `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:

```rust
// 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:

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

See [manage secrets in a flow](./manage-secrets.md) 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:

```rust
// 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:

```bash
# 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:

```bash
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):

```rust
// 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](../concepts/execution-model.md) for how pipeline
mode differs from unary execution.

## How Rust differs from other languages

Three Rust-specific behaviors to know, compared with the
[Python](./create-a-node-python.md), [Go](./create-a-node-go.md), and
[TypeScript](./create-a-node-typescript.md) 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

```bash
# 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:

- Place the node in a flow on the canvas — [build your first flow](../getting-started/first-flow.md).
- Call the flow over HTTP — [invoke via API](../getting-started/invoke-via-api.md).
- Full command details — [axiom init](../reference/cli/axiom-init.md),
  [axiom create node](../reference/cli/axiom-create-node.md),
  [axiom test](../reference/cli/axiom-test.md),
  [axiom build](../reference/cli/axiom-build.md),
  [axiom push](../reference/cli/axiom-push.md).
