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

Write your first node

Scaffold a package with axiom init, create a Python node, and iterate locally with axiom validate, axiom test, and the axiom dev hot-reload server.

View as Markdown

A node is the single unit of compute in Axiom: a plain function with a typed input message and a typed output message. This tutorial scaffolds a Python package, writes a Greet node, and exercises the full local loop — axiom validate, axiom test, and the axiom dev hot-reload server — without deploying anything.

Prerequisites

  • The Axiom CLI installed and on your PATH — see Install the Axiom CLI.
  • Python 3.10 or newer.
  • The Python tooling the local loop shells out to:
# grpcio-tools compiles .proto files (axiom generate); pytest runs axiom test
pip install grpcio-tools pytest

If grpcio-tools is not installed, axiom generate falls back to a standalone protoc binary on your PATH; either one works. To let the CLI help instead, run axiom doctor (checks the toolchain and prints install hints; axiom doctor --fix, run inside a package, installs the project-local pieces) or scaffold with axiom init --install (see below).

The fast path

The whole scaffold is five commands. Each step is explained in the sections below.

axiom init my-org/greeter --language python
cd greeter
axiom create message GreetRequest --fields "name:string"
axiom create message GreetReply --fields "greeting:string"
axiom create node Greet --input GreetRequest --output GreetReply --type unary

After these commands you have a buildable package; the sections Implement the node onward fill in the function body and run it.

Create a package

axiom init creates a package: the unit of publishing that holds your message types and nodes.

axiom init my-org/greeter --language python
cd greeter

The part of the name after the last / becomes the directory name, so this creates ./greeter/ containing:

  • axiom.yaml — the package manifest (name, version, language, nodes). See axiom.yaml reference.
  • messages/messages.proto — the single file where all of the package's message types are defined.
  • nodes/ — node implementations and their tests (plus a generated conftest.py so pytest resolves imports from the package root).
  • gen/ — generated Protocol Buffers bindings (created by axiom generate).
  • .gitignore — excludes .axiom/ build artifacts and Python caches.

The default language is Go, so pass --language python explicitly. Optional flags: --version (defaults to 0.1.0) and --description.

For a Python package axiom init also writes a requirements.txt — where a Python package lists its pip dependencies — because axiom validate requires that file. It starts empty (valid when your nodes have no dependencies); add your dependencies as you need them. (Pass --install to axiom init to run the language's dependency install right after scaffolding.)

Define the input and output messages

Every node input and output is a message — a Protocol Buffers type defined in messages/messages.proto. axiom create message appends a message to that file; all messages live in one file so they can reference each other without import statements.

# Run inside the package directory (where axiom.yaml is).
axiom create message GreetRequest --fields "name:string"
axiom create message GreetReply --fields "greeting:string"

--fields is a semicolon-separated list and accepts canonical proto3 (string name = 1), proto3 without field numbers (string name), or the colon shorthand used above (name:string). Field numbers are auto-assigned when omitted. After each message is added, axiom generate runs automatically and compiles the bindings Python imports from gen/.

Run axiom create message <Name> without --fields to get placeholder fields plus a HINTS comment block explaining proto syntax; edit the fields in your editor, then run axiom generate to rebuild gen/.

Comments written directly above a message or field (no blank line in between) are extracted at publish time and shown in the Axiom registry as documentation. For how messages type-check across a flow, see the type system.

Scaffold the node

axiom create node writes the implementation file and a test file, and registers the node in axiom.yaml:

# Run inside the package directory (where axiom.yaml is).
axiom create node Greet --input GreetRequest --output GreetReply --type unary

This creates nodes/greet.py and nodes/greet_test.py (the file name is the snake_case of the node name) and appends the node entry to axiom.yaml.

Rules and behavior:

  • The node name must be PascalCase (letters and digits only).
  • --input and --output must name messages defined in messages/messages.proto (or available from an imported package — see import package types).
  • If you omit --input, --output, or --type at an interactive terminal, the command prompts with a numbered list of available messages and a node type prompt (press Enter to accept the default, unary). In non-interactive runs (scripts, CI), --input and --output are required and an omitted --type defaults to unary.
  • --type is unary (one input in, one output out) or pipeline (streaming) — see the execution model.

Implement the node

Replace the generated stub in nodes/greet.py with a working body:

# nodes/greet.py
from gen.messages_pb2 import GreetRequest, GreetReply
from gen.axiom_context import AxiomContext


def greet(ax: AxiomContext, input: GreetRequest) -> GreetReply:
    """Returns a personalized greeting for the caller's name."""
    ax.log.info("greeting requested", name=input.name)
    return GreetReply(greeting=f"Hello, {input.name}!")

Two things to know about this signature:

  • The docstring is your registry description. The first string literal inside the function is extracted when the package is published (axiom push) and shown in the Axiom registry as the node's documentation — write a real description before publishing.
  • ax is AxiomContext, the single injection point for every platform capability: ax.log for structured logging (use it instead of print() — in production each line carries the execution and trace IDs), ax.secrets.get(name) for tenant secrets (returns a (value, found) tuple — see manage secrets), and ax.agent.memory for durable agent memory (declare the function async def to await memory calls — see memory). Full API: Python SDK reference.

Validate the package

axiom validate checks the package and prints a per-check report:

# Run inside the package directory (where axiom.yaml is).
axiom validate
  1. axiom.yaml schema — required fields, semver format, language, message references.
  2. Proto definitions — every .proto file in messages/ compiles.
  3. Node signatures — each node declared in axiom.yaml has an implementation with the expected function signature.
  4. Node tests — every node has a test file that declares at least one test (reported as a warning; this check does not block).
  5. Language files — the language's required manifest exists; for Python that is requirements.txt (an empty file passes).

Pass --json for structured output in scripts. You rarely run it alone: axiom test runs the same validation and stops before testing if any check fails, and axiom dev runs it at startup and reports issues as warnings.

Run the tests

axiom test regenerates proto bindings, validates the package, then runs the language-native test runner — pytest nodes/ for Python — streaming its output live. The exit code mirrors the test runner's exit code.

axiom create node already generated nodes/greet_test.py with a _TestContext stub (a minimal AxiomContext for unit tests) and one test. Replace the generated test_greet function at the bottom of that file so it asserts real output values:

# nodes/greet_test.py — replace the generated test_greet function with this
def test_greet():
    ax = _TestContext()
    result = greet(ax, GreetRequest(name="Ada"))
    assert isinstance(result, GreetReply)
    assert result.greeting == "Hello, Ada!"
axiom test

Arguments after -- go straight to pytest, e.g. axiom test -- -k test_greet to filter by name.

Tests run again inside the push build: the platform's image build runs pytest, and a failing test fails the push. axiom validate warns — without blocking — when a node has no test, because a node with zero tests passes silently. Assert output field values — not just types — so the gate actually catches regressions. If your node needs a secret, construct the stub as _TestContext(secrets_map={"MY_KEY": "test-value"}).

Iterate live with axiom dev

axiom dev is the local loop: it generates the same gRPC service the publish pipeline produces, compiles and runs it natively, and starts an HTTP bridge (default port 8083; change with --port) that converts JSON to and from Protocol Buffers so you can test with curl:

axiom dev
# In a second terminal:
curl localhost:8083/nodes/Greet -d '{"name": "Ada"}'
# → {"greeting":"Hello, Ada!"}

The dev server also serves:

  • GET http://localhost:8083/docs — an interactive API reference for your nodes, no client setup needed.
  • GET http://localhost:8083/openapi.json — an OpenAPI spec you can import into Postman, Insomnia, or Bruno.

It watches nodes/, messages/, and axiom.yaml, and recompiles and restarts automatically on every change. A failed build leaves the previous service running, so you always have a working server while you fix the error. Stop it with Ctrl-C.

Next steps