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 MarkdownA 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 pytestIf 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 unaryAfter 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 greeterThe 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 generatedconftest.pysopytestresolves imports from the package root).gen/— generated Protocol Buffers bindings (created byaxiom 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 unaryThis 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).
--inputand--outputmust name messages defined inmessages/messages.proto(or available from an imported package — see import package types).- If you omit
--input,--output, or--typeat 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),--inputand--outputare required and an omitted--typedefaults tounary. --typeisunary(one input in, one output out) orpipeline(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. axisAxiomContext, the single injection point for every platform capability:ax.logfor structured logging (use it instead ofprint()— 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), andax.agent.memoryfor durable agent memory (declare the functionasync deftoawaitmemory 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- axiom.yaml schema — required fields, semver format, language, message references.
- Proto definitions — every
.protofile inmessages/compiles. - Node signatures — each node declared in
axiom.yamlhas an implementation with the expected function signature. - Node tests — every node has a test file that declares at least one test (reported as a warning; this check does not block).
- 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 testArguments 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
- Push the package and build your first flow
— publish
my-org/greeterand compose its node in the canvas. - Invoke a flow via the API.
- Nodes, packages, and flows — how the pieces relate.
- Create a node in Python — the per-language guide with imports, secrets, and pipeline nodes in depth.
- Writing in another language? The same commands work for Go, TypeScript, Rust, Java, and C# — start from Create a node in Go.