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 MarkdownThis 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
axiomCLI installed and on yourPATH(see installation). - A Go toolchain. Generated Go packages target
go 1.22(the version the generatedgo.moddeclares), so any Go 1.22+ toolchain builds your package. The higher "Go 1.25 or newer" in installation applies only to building theaxiomCLI 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 unaryaxiom 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 unaryThe 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 stubaxiom/context.go— the generatedaxiom.Contextinterface (regenerated idempotently; do not edit)- an entry under
nodes:inaxiom.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 testaxiom 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 TestCountWordsThe 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 devaxiom 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 pipelineA 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 pushaxiom 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.