---
title: "Write your first node"
description: "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."
category: tutorial
surfaces: [cli, sdk]
languages: [python]
related: [getting-started/installation, getting-started/first-flow, concepts/nodes-packages-flows, guides/create-a-node-python, reference/sdk/python, reference/axiom-yaml]
last_reviewed: 2026-06-06
---

# Write your first node

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](/docs/getting-started/installation).
- Python 3.10 or newer.
- The Python tooling the local loop shells out to:

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

```bash
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](#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.

```bash
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](/docs/reference/axiom-yaml).
- `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.

```bash
# 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](/docs/concepts/type-system).

## Scaffold the node

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

```bash
# 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](/docs/guides/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](/docs/concepts/execution-model).

## Implement the node

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

```python
# 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](/docs/guides/manage-secrets)), and
  `ax.agent.memory` for durable agent memory (declare the function
  `async def` to `await` memory calls — see
  [memory](/docs/concepts/memory)). Full API:
  [Python SDK reference](/docs/reference/sdk/python).

## Validate the package

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

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

```python
# 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!"
```

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

```bash
axiom dev
```

```bash
# 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](/docs/getting-started/first-flow)
  — publish `my-org/greeter` and compose its node in the canvas.
- [Invoke a flow via the API](/docs/getting-started/invoke-via-api).
- [Nodes, packages, and flows](/docs/concepts/nodes-packages-flows) — how the
  pieces relate.
- [Create a node in Python](/docs/guides/create-a-node-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](/docs/guides/create-a-node-go).
