---
title: "Create a node in Python"
description: "Scaffold a Python package, define protobuf messages, implement a typed node function with AxiomContext, test it with pytest, and push it to Axiom."
category: guide
surfaces: [cli, sdk]
languages: [python]
related: [getting-started/first-node, getting-started/first-flow, concepts/nodes-packages-flows, concepts/type-system, reference/sdk/python, reference/axiom-yaml, guides/manage-secrets]
last_reviewed: 2026-06-06
---

# Create a node in Python

A node is a plain Python function with a typed input message and a typed
output message. This guide takes one node from empty directory to a pushed
package: scaffold, messages, implementation, tests, push.

## The five commands

The whole path, end to end. Each step is explained in the sections below.

```bash
# Run from the directory that will contain your package
axiom init your-handle/order-tools --language python --description "Order processing utilities"
cd order-tools
touch requirements.txt   # axiom validate requires this file for Python packages
axiom create message OrderRequest --fields "order_id:string; quantity:int32; unit_price_cents:int64"
axiom create message OrderConfirmation --fields "order_id:string; total_cents:int64; accepted:bool"
axiom create node ProcessOrder --input OrderRequest --output OrderConfirmation
# ...implement nodes/process_order.py, edit nodes/process_order_test.py...
axiom test
axiom push
```

## Before you start

- Install the Axiom CLI and authenticate with `axiom login` — see
  [Installation](../getting-started/installation.md). `axiom login` uses the
  OAuth Device Flow and stores the resulting API key in
  `~/.axiom/credentials`; in CI, set `AXIOM_API_KEY` instead.
- Python 3.10 or newer (the generated code uses `dict | None` syntax).
- `pip install grpcio-tools pytest` — `axiom generate` compiles your
  `.proto` files with `python -m grpc_tools.protoc` (falling back to a
  standalone `protoc` if installed), and `axiom test` runs your tests with
  pytest.
- For `axiom push`: the package must live in a git repository with an
  `origin` remote, because the platform builds from the remote at your HEAD
  commit — not from your working tree.

## Create the package and its messages

`axiom init <handle>/<name> --language python` creates a subdirectory named
after the package (the part after the last `/`) containing `axiom.yaml`,
`messages/messages.proto`, `nodes/conftest.py`, and a `.gitignore`. Package
names must be scoped (`your-handle/package-name`); pushing rejects unscoped
names. Use `--description` to set the package description and `--version`
to override the default `0.1.0`.

Every node input and output is a [message](../reference/glossary.md#message)
defined in `messages/messages.proto`. All messages live in that single file
so they can reference each other without imports. Scaffold them with
`axiom create message`:

```bash
# Run inside the package directory (where axiom.yaml lives)
axiom create message OrderRequest --fields "order_id:string; quantity:int32; unit_price_cents:int64"
axiom create message OrderConfirmation --fields "order_id:string; total_cents:int64; accepted:bool"
```

`--fields` takes semicolon-separated definitions in colon shorthand
(`name:string`), proto3 without field numbers (`string name`), or canonical
proto3 (`string name = 1`); field numbers are auto-assigned when omitted.
Omit `--fields` to get a placeholder block with syntax hints to edit by
hand. After appending the message, the command runs `axiom generate`
automatically to compile bindings into `gen/` (skip with `--no-generate`
when creating several messages at once).

The result in `messages/messages.proto`:

```protobuf
// messages/messages.proto
message OrderRequest {
  string order_id = 1;
  int32 quantity = 2;
  int64 unit_price_cents = 3;
}

message OrderConfirmation {
  string order_id = 1;
  int64 total_cents = 2;
  bool accepted = 3;
}
```

Comments written directly above a message or field declaration (with no
blank line in between) are extracted at publish time and shown in the Axiom
registry as documentation; a comment separated by a blank line is ignored.
See [The type system](../concepts/type-system.md) for how messages type-check
flow edges, and [Import message types from another package](import-package-types.md)
to reuse messages published by others.

## Scaffold the node

`axiom create node` writes the implementation file and a test file into
`nodes/`, then records the node in `axiom.yaml`:

```bash
# Run inside the package directory
axiom create node ProcessOrder --input OrderRequest --output OrderConfirmation
```

The node name must be PascalCase. Input and output messages must be defined
in `messages/messages.proto` or available from an imported package. If you
omit `--input` or `--output` in an interactive terminal, the command prompts
with a numbered list of available messages. The default node type is
`unary` (one input message in, one output message out per invocation); pass
`--type pipeline` for a streaming node instead.

This creates `nodes/process_order.py` and `nodes/process_order_test.py`,
generates `gen/messages_pb2.py` (your message bindings) and
`gen/axiom_context.py` (the `AxiomContext` type), and updates `axiom.yaml`:

```yaml
# axiom.yaml
name: your-handle/order-tools
version: 0.1.0
language: python
description: Order processing utilities
nodes:
    - name: ProcessOrder
      input: OrderRequest
      output: OrderConfirmation
```

See the [axiom.yaml reference](../reference/axiom-yaml.md) for every
manifest field.

## Implement the node function

A Python node is a function whose first parameter is the `AxiomContext`
(`ax`) and whose second parameter is the input message; it returns the
output message. Declare it as `async def` if you need `await` other
async platform APIs.

```python
# nodes/process_order.py
from gen.messages_pb2 import OrderRequest, OrderConfirmation
from gen.axiom_context import AxiomContext


def process_order(ax: AxiomContext, input: OrderRequest) -> OrderConfirmation:
    """Validates an order request and returns a confirmation with the
    calculated total. Orders with zero quantity are rejected.
    """
    if input.quantity <= 0:
        ax.log.warn("rejected order", order_id=input.order_id)
        return OrderConfirmation(order_id=input.order_id, accepted=False)

    total = input.quantity * input.unit_price_cents
    ax.log.info("order accepted", order_id=input.order_id, total_cents=total)
    return OrderConfirmation(
        order_id=input.order_id,
        total_cents=total,
        accepted=True,
    )
```

The docstring directly inside the function is extracted at publish time and
shown in the Axiom registry as the node's documentation — write a plain
description of what the node does.

`ax` is the single injection point for every platform capability:

- `ax.log` — structured logger with `debug` / `info` / `warn` / `error`,
  taking keyword attributes. Use it instead of `print()`: in local
  development it writes plain text; in production it writes JSON with
  `trace_id`, `span_id`, and `execution_id` baked in.
- `ax.secrets.get(name)` — returns a `(value, found)` tuple. Use it instead
  of environment variables for API keys; list the secret names a node reads
  under `required_secrets` in `axiom.yaml` so they are validated during
  publish. See [Manage secrets in a flow](manage-secrets.md).
- `ax.execution_id`, `ax.flow_id`, `ax.tenant_id` — identifiers for the
  current [execution](../reference/glossary.md#execution), the flow, and
  the tenant.

The full surface is documented in the
[Python SDK reference](../reference/sdk/python.md).

## Test the node

`axiom create node` generated `nodes/process_order_test.py` containing a
`_TestContext` class — a minimal `AxiomContext` implementation with a no-op
logger and a secrets map you can populate
(`_TestContext(secrets_map={"OPENAI_KEY": "sk-test"})`). Replace the
generated placeholder test with assertions on real output fields:

```python
# nodes/process_order_test.py — replaces the generated placeholder test;
# keep the generated _TestContext class and imports above it.
def test_process_order_accepts_and_totals():
    ax = _TestContext()
    input_msg = OrderRequest(order_id="ord-1", quantity=3, unit_price_cents=250)
    result = process_order(ax, input_msg)
    assert result.order_id == "ord-1"
    assert result.total_cents == 750
    assert result.accepted is True


def test_process_order_rejects_zero_quantity():
    ax = _TestContext()
    result = process_order(ax, OrderRequest(order_id="ord-2", quantity=0))
    assert result.accepted is False
```

Create a `requirements.txt` listing your Python dependencies — it may be
empty, but `axiom validate` fails without it. Then run:

```bash
# Run inside the package directory
axiom test
```

`axiom test` compiles proto bindings (`axiom generate`), validates the
package (`axiom validate` — `axiom.yaml` schema, proto definitions, node
signatures, node tests, and language files such as `requirements.txt`),
then runs `pytest nodes/` with output streamed live. Pass arguments to
pytest after `--`, e.g. `axiom test -- -k test_process_order`.

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. Assert output fields meaningfully — not
just type-checked — so the gate actually catches regressions.

## Stream output with a pipeline node

Pass `--type pipeline` to scaffold a streaming node:

```bash
axiom create node StreamConfirmations --input OrderRequest --output OrderConfirmation --type pipeline
```

A pipeline node takes an iterator of input frames and yields any number of
output frames:

```python
# nodes/stream_confirmations.py — generated shape of a Python pipeline node
from typing import Iterator
from gen.messages_pb2 import OrderRequest, OrderConfirmation
from gen.axiom_context import AxiomContext


def stream_confirmations(ax: AxiomContext, inputs: Iterator[OrderRequest]) -> Iterator[OrderConfirmation]:
    for input in inputs:
        yield OrderConfirmation(order_id=input.order_id, accepted=True)
```

For the start node of a flow running in pipeline mode, the iterator yields
exactly one item. See [execution model](../concepts/execution-model.md) for
how unary and pipeline mode differ end to end.

## Push the package

`axiom push` deploys the package to the Axiom platform, visible only to
your own tenant — it does not appear in the public marketplace. The
platform clones your repository at your current HEAD commit and builds from
that, so commit and push first:

```bash
# Run inside the package directory
git add -A && git commit -m "ProcessOrder node" && git push
axiom push
```

Push validates the package locally, then streams the platform pipeline's
progress: server-side validation, code generation, Docker image build, and
deployment. It requires a prior `axiom login`, a git remote named
`origin`, and the current HEAD pushed to that remote. You can push the same
version repeatedly — each re-push refreshes the code, descriptions, and OpenAPI
spec — which lets you iterate on deployed code before publishing. (A deployed
node's input/output wire signature is frozen; a wire change is rejected until
you bump the version.) When you are satisfied, run
`axiom publish <package>@<version>` (or use the Axiom UI) to make it available
to others as an immutable versioned release. Use `--json` for a single
machine-readable result object instead of progress output.

After pushing, `axiom info your-handle/order-tools` shows the package's
nodes, messages, and live endpoint.

## Next steps

- Place the node in a flow on the canvas:
  [Build your first flow](../getting-started/first-flow.md).
- Invoke the flow over HTTP:
  [Invoke a flow via the API](../getting-started/invoke-via-api.md).
- Iterate locally with hot reload: `axiom dev` starts a local development
  server that watches `nodes/`, `messages/`, and `axiom.yaml`, exposes each
  node at `POST http://localhost:8083/nodes/<NodeName>`, and converts JSON
  payloads to and from protobuf automatically.
- Understand how nodes, packages, and flows relate:
  [Nodes, packages, and flows](../concepts/nodes-packages-flows.md).
