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

Create a node in Go

Scaffold a Go node with the Axiom CLI, implement the handler against generated protobuf types, test it, and run it locally with hot reload.

View as Markdown

This guide takes a Go node from empty directory to a locally running, HTTP-invocable handler. A node is a plain Go function — no SDK imports in your business logic, no Dockerfile, no gRPC plumbing.

Prerequisites

  • The axiom CLI installed and on your PATH (see installation).
  • A Go toolchain. Generated Go packages target go 1.22 (the version the generated go.mod declares), so any Go 1.22+ toolchain builds your package. The higher "Go 1.25 or newer" in installation applies only to building the axiom CLI itself from source — not to the packages you author (and not at all once you install a prebuilt CLI binary).
  • protoc (install guide: https://grpc.io/docs/protoc-installation/) and the Go plugin: go install google.golang.org/protobuf/cmd/protoc-gen-go@latest.

The short path

Four commands scaffold a working Go package with one node:

axiom init demo/text-tools --language go
cd text-tools
axiom create message TextRequest --fields "text:string"
axiom create message WordCountResult --fields "count:int32"
axiom create node CountWords --input TextRequest --output WordCountResult --type unary

axiom init creates a text-tools/ directory (the part of the package name after the last /) containing axiom.yaml, messages/, nodes/, gen/, a go.mod whose module path is the package name, and a .gitignore that excludes the regenerated gen/ directory. Go is the CLI's default language, so --language go is optional but explicit.

axiom create node writes nodes/count_words.go and nodes/count_words_test.go, generates the axiom/context.go interface file, and registers the node in axiom.yaml. Then implement the handler (next sections), run axiom test, and run axiom dev to invoke it over HTTP.

Define the input and output messages

Every node has a typed input message and a typed output message, defined as Protocol Buffers in messages/messages.proto. axiom create message appends a message block there and then runs axiom generate automatically to compile Go bindings into gen/:

axiom create message TextRequest --fields "text:string"

--fields is a semicolon-separated list and accepts three forms: canonical proto3 ("string text = 1"), proto3 without field numbers ("string text" — numbers are auto-assigned), or colon shorthand ("text:string"). Without --fields, the command writes placeholder fields plus a HINTS comment block to edit in your IDE; after editing, run axiom generate to rebuild the bindings. Pass --no-generate to skip generation while creating several messages in a row.

Comments written directly above a message or field (no blank line between comment and declaration) are extracted at publish time and shown in the Axiom registry as documentation.

Messages from other packages can also be used as node inputs and outputs — see import types from another package.

Scaffold the node

axiom create node CountWords --input TextRequest --output WordCountResult --type unary

The node name must be PascalCase. If --input or --output is omitted and you are at an interactive terminal, the command prompts with a numbered list of every available message (local and imported); in non-interactive mode both flags are required. --type is unary (default, one input message in, one output message out) or pipeline (streaming — see the pipeline section below). The command creates:

  • nodes/count_words.go — the handler stub (file name is the snake_case of the node name)
  • nodes/count_words_test.go — a matching test stub
  • axiom/context.go — the generated axiom.Context interface (regenerated idempotently; do not edit)
  • an entry under nodes: in axiom.yaml

The comment directly above the function — no blank line before func — is extracted at publish time and becomes the node's description in the Axiom registry. Write it as a plain sentence and replace the generated placeholder before pushing.

Implement the handler

A unary Go node is one function in package nodes. Local message types come from the generated gen package; platform capabilities arrive through the single ax axiom.Context parameter:

// nodes/count_words.go
package nodes

import (
	"context"
	"strings"

	"demo/text-tools/axiom"
	gen "demo/text-tools/gen"
)

// CountWords splits the input text on whitespace and returns the word count.
func CountWords(ctx context.Context, ax axiom.Context, input *gen.TextRequest) (*gen.WordCountResult, error) {
	words := strings.Fields(input.GetText())
	ax.Log().Info("counted words", "count", len(words))
	return &gen.WordCountResult{Count: int32(len(words))}, nil
}

The signature is fixed: (ctx context.Context, ax axiom.Context, input *gen.<Input>) (*gen.<Output>, error). axiom validate checks it, so keep the shape the scaffold generated. Through ax you get structured logging (ax.Log().Info(msg, key, value, ...) — use it instead of fmt.Println), secrets (ax.Secrets().Get("MY_API_KEY") returns (value string, ok bool); declare the name under the node's required_secrets in axiom.yaml — see manage secrets in a flow), agent memory (ax.Agent().Memory() — see memory), and execution identity (ax.ExecutionID(), ax.FlowID(), ax.TenantID()). The full Go surface is documented in the Go SDK reference.

Test the node

Edit the generated test so it asserts real output values, then run:

axiom test

axiom test regenerates proto bindings, runs axiom validate (failing fast on errors), then runs go test ./nodes/... with output streamed live. The exit code mirrors the test runner's. Pass native go test flags after --:

axiom test -- -v
axiom test -- -run TestCountWords

The generated nodes/count_words_test.go provides a newTestContext(t) helper that satisfies axiom.Context for unit tests (populate its secretsMap with any secrets the node reads). Replace the TODO in the generated test function with assertions on output fields:

// nodes/count_words_test.go — replace the generated TestCountWords with:
func TestCountWords(t *testing.T) {
	ctx := context.Background()
	ax := newTestContext(t)
	input := &gen.TextRequest{Text: "axiom makes nodes easy"}

	got, err := nodes.CountWords(ctx, ax, input)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got.Count != 4 {
		t.Errorf("Count = %d, want 4", got.Count)
	}
}

Tests are a push gate, not a formality: every node needs at least one test (axiom validate warns on a node with no test), and the push build runs go test ./nodes/... — a failing test fails the push. Assert output fields meaningfully; do not just check for the absence of an error.

Run the node locally

axiom dev

axiom dev generates the same gRPC service the publish pipeline builds, compiles and runs it natively, and starts an HTTP bridge (default port 8083, change with --port) that converts JSON to and from Protobuf. Invoke any node with curl:

curl localhost:8083/nodes/CountWords -d '{"text": "axiom makes nodes easy"}'

Each node is served at POST /nodes/<NodeName>. Unary nodes return a single JSON response; pipeline nodes stream Server-Sent Events, one event per output frame. The bridge also serves GET /openapi.json (a spec for all nodes) and GET /docs (interactive API docs).

The dev server watches nodes/, messages/, and axiom.yaml and recompiles and restarts on change. A failed build leaves the previous service running, so you always have a working server while you fix errors.

Stream output with a pipeline node

Pass --type pipeline to scaffold a streaming node:

axiom create node StreamCounts --input TextRequest --output WordCountResult --type pipeline

A pipeline node receives input frames on a channel and emits any number of output frames via a callback:

// nodes/stream_counts.go — generated shape of a Go pipeline node
func StreamCounts(ctx context.Context, ax axiom.Context, in <-chan *gen.TextRequest, emit func(*gen.WordCountResult) error) error {
	for input := range in {
		_ = input
		if err := emit(&gen.WordCountResult{}); err != nil {
			return err
		}
	}
	return nil
}

For the entry node of a flow running in pipeline mode, the channel yields exactly one item. See execution model for how unary and pipeline mode differ end to end.

Push the package

axiom login   # once
axiom push

axiom push validates the package locally, then deploys it tenant-private: the nodes become available in your own flows but do not appear in the public marketplace. You can push the same version repeatedly — each push overwrites the previous one — and axiom push builds from your git remote, so the current HEAD must be pushed before running it. When you are satisfied, run axiom publish <package>@<version> (or use the Axiom UI) to make it a public, immutable release. Next: compose the node into a flow (build your first flow) and invoke it via the API.