---
title: "Create a node in Go"
description: "Scaffold a Go node with the Axiom CLI, implement the handler against generated protobuf types, test it, and run it locally with hot reload."
category: guide
surfaces: [cli, sdk]
languages: [go]
related: [getting-started/first-node, concepts/nodes-packages-flows, concepts/type-system, reference/sdk/go, reference/axiom-yaml, reference/cli/axiom-create-node, guides/manage-secrets, guides/import-package-types]
last_reviewed: 2026-06-06
---

# Create a node in Go

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](/docs/getting-started/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](/docs/getting-started/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:

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

```bash
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](/docs/guides/import-package-types).

## Scaffold the node

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

```go
// 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](/docs/guides/manage-secrets)),
agent memory (`ax.Agent().Memory()` — see [memory](/docs/concepts/memory)),
and execution identity (`ax.ExecutionID()`, `ax.FlowID()`,
`ax.TenantID()`). The full Go surface is documented in the
[Go SDK reference](/docs/reference/sdk/go).

## Test the node

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

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

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

```go
// 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

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

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

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

```go
// 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](/docs/concepts/execution-model) for
how unary and pipeline mode differ end to end.

## Push the package

```bash
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](/docs/getting-started/first-flow)) and
[invoke it via the API](/docs/getting-started/invoke-via-api).
