---
title: "Go SDK reference"
description: "Every capability on axiom.Context — the Go form of AxiomContext: structured logging, secrets, execution identity, flow reflection, and flow mutation, with full signatures."
category: reference
surfaces: [sdk]
languages: [go]
related: [guides/create-a-node-go, concepts/sandboxing-and-tenancy, concepts/execution-model, guides/manage-secrets, reference/sdk/python, reference/sdk/typescript, reference/axiom-yaml]
last_reviewed: 2026-07-11
---

# Go SDK reference

In Go, AxiomContext is the `axiom.Context` interface: the single parameter
(conventionally named `ax`) through which a node handler reaches every
platform capability — logging, secrets, execution identity,
flow reflection, and flow mutation. There is no external SDK module to
install: the `axiom` package is generated into your package directory as
`axiom/context.go`, and your business logic imports only that local package
and the generated `gen` message types.

**Prerequisites.** Examples on this page assume a Go package scaffolded with
the Axiom CLI — see [create a node in Go](/docs/guides/create-a-node-go).
They use that guide's `demo/text-tools` package (module path
`demo/text-tools`, messages `TextRequest{text}` and `WordCountResult{count}`);
sections that need other messages state their own `axiom create message`
commands.

## The handler signature

A unary node is one exported function in package `nodes` with this fixed
shape — `axiom validate` checks it:

```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
}
```

- `ctx context.Context` — standard Go context for cancellation; pass it to
  every `ax` call that takes one.
- `ax axiom.Context` — the platform capability injection point, documented
  below.
- `input` / return value — pointers to the generated types of the node's
  input and output messages.
- Returning a non-nil `error` fails the invocation; the error text is
  reported to the platform as the node's error message.

A pipeline node (declared with `--type pipeline`) receives input as a
channel and emits any number of output frames through a callback:

```go
// nodes/stream_counts.go
func StreamCounts(ctx context.Context, ax axiom.Context, in <-chan *gen.TextRequest, emit func(*gen.WordCountResult) error) error {
	for input := range in {
		words := strings.Fields(input.GetText())
		if err := emit(&gen.WordCountResult{Count: int32(len(words))}); 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).

## At-least-once invocation: design for idempotency

Axiom guarantees at-least-once invocation of every node by default (see
[the `axiom.yaml` reference](/docs/reference/axiom-yaml) for the retry
knobs). That means a transient invocation failure (`NODE_ERROR_TRANSPORT` /
`NODE_ERROR_TIMEOUT`) can cause the platform to call your handler again for
the same execution, with the same input. Write handlers so a repeat call is
safe:

- Prefer naturally idempotent operations — an upsert on a stable key, a
  conditional write (`INSERT ... ON CONFLICT DO NOTHING`), a `PUT` instead of
  a bare `POST`.
- For a non-idempotent side effect (charging a customer, sending an email),
  derive a stable idempotency key from `ax.ExecutionID()` and de-duplicate
  against it downstream, or use the target system's own idempotency-key API
  if it has one.
- Returning a non-nil `error` from your handler produces `NODE_ERROR_USER`,
  which the platform **never** retries, at any layer — retrying a flaky call
  inside your own logic is your handler's job (a normal `for`-loop retry
  around the flaky call), not a platform mechanism.

This is a design responsibility, not something the platform enforces.

## Context methods at a glance

`axiom.Context` has seven methods. Adding new platform capabilities never
changes node function signatures — capabilities are always added as methods
here.

| Method | Returns | Purpose |
|---|---|---|
| `ax.Log()` | `Logger` | Structured logging for this invocation |
| `ax.Secrets()` | `Secrets` | Read-only access to tenant secrets |
| `ax.ExecutionID()` | `string` | ID of the current execution |
| `ax.FlowID()` | `string` | Stable ID of the flow's compiled artifact (constant across executions) |
| `ax.TenantID()` | `string` | ID of the tenant that owns this invocation |
| `ax.Reflection()` | `Reflection` | Read-only view of the running flow |
| `ax.Mutation()` | `Mutation` | Append-only mutation of the running flow |

## Logging

`ax.Log()` returns a structured logger pre-configured for the current
invocation. Use it instead of `fmt.Println` for anything you want visible
in production.

```go
// axiom/context.go (generated)
type Logger interface {
	Debug(msg string, args ...any)
	Info(msg string, args ...any)
	Warn(msg string, args ...any)
	Error(msg string, args ...any)
}
```

`args` follows Go's `log/slog` convention — alternating key, value pairs:

```go
// inside any node handler
ax.Log().Info("order processed", "order_id", "abc123", "total", 99.99)
```

Under `axiom dev` the logger writes concise plain-text lines to the
terminal. In the deployed service each log line automatically carries the
node name and `execution_id`, plus the active trace context, so production
logs are searchable by execution. Concurrent invocations each receive their
own `Logger` instance — never share one across calls, and never construct
one yourself.

## Secrets

`ax.Secrets()` returns read-only access to the tenant secrets the platform
resolved for this invocation. Values are plaintext strings; encryption and
decryption are the platform's job.

```go
// axiom/context.go (generated)
type Secrets interface {
	// Get returns the value for the named secret and true when present.
	// Returns ("", false) when the secret was not resolved for this invocation.
	Get(name string) (string, bool)
}

// SecretStatusOf reports the typed status of a declared secret:
// SecretStatusAvailable, SecretStatusRevoked, or SecretStatusUnset. It is a
// free helper (not a Secrets method) so the accessor stays backward-compatible.
func SecretStatusOf(s Secrets, name string) SecretStatus
```

```go
// inside any node handler
apiKey, ok := ax.Secrets().Get("ANTHROPIC_API_KEY")
if !ok {
	return nil, fmt.Errorf("secret ANTHROPIC_API_KEY not configured")
}
```

Use `axiom.SecretStatusOf(ax.Secrets(), name)` when you need to tell "revoked"
from "never configured" apart — see
[manage secrets in a flow](/docs/guides/manage-secrets#distinguish-revoked-from-never-configured).

Declare every secret name the node reads under the node's
`required_secrets` list in `axiom.yaml`, so users know what to register in
the console before invoking — see
[manage secrets in a flow](/docs/guides/manage-secrets). Secrets reach the
node only through this interface; they never appear in package source.

> **Agent memory removed for beta.** There is no `ax.Agent()` /
> `Agent`/`AgentMemory`/`SessionMemory` surface in the beta SDK — agentic
> memory is out of scope for beta and has been removed at compile time.

## Execution identity

Three string accessors are declared for execution identity. They are
read-only facts injected by the platform — node code cannot change them,
and tenant isolation is enforced by the sidecar regardless of what the
node does (see
[sandboxing and tenancy](/docs/concepts/sandboxing-and-tenancy)).

| Method | Meaning |
|---|---|
| `ax.ExecutionID()` | ID of the current execution — the same ID the HTTP API, traces, and debug events reference |
| `ax.FlowID()` | Stable ID of the flow's compiled artifact — constant across executions |
| `ax.TenantID()` | ID of the tenant that owns this invocation |

## Flow reflection

`ax.Reflection().Flow()` returns a read-only view of the running flow's
topology and the current invocation's position in it. Field-level message
introspection is not exposed — `InputMessageName`/`OutputMessageName` are
fully-qualified Protocol Buffers message names.

```go
// nodes/describe_flow.go
package nodes

import (
	"context"
	"fmt"
	"strings"

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

// DescribeFlow returns a text summary of the running flow's topology.
func DescribeFlow(ctx context.Context, ax axiom.Context, input *gen.ChatRequest) (*gen.ChatReply, error) {
	flow := ax.Reflection().Flow()
	var b strings.Builder
	fmt.Fprintf(&b, "graph %s: %d nodes, %d edges, at instance %d\n",
		flow.GraphID(), len(flow.Nodes()), len(flow.Edges()),
		flow.Position().CurrentInstance)
	for _, n := range flow.Nodes() {
		fmt.Fprintf(&b, "[%d] %s (%s@%s) %s -> %s\n",
			n.InstanceID, n.Name, n.PackageName, n.PackageVersion,
			n.InputMessageName, n.OutputMessageName)
	}
	return &gen.ChatReply{Text: b.String()}, nil
}
```

### Reflection interfaces and types

```go
// axiom/context.go (generated)
type Reflection interface {
	Flow() FlowReflection
}

type FlowReflection interface {
	Nodes() []ReflectionNode
	Edges() []ReflectionEdge
	LoopEdges() []ReflectionEdge
	Position() FlowPosition
	GraphID() string
}

type ReflectionNode struct {
	InstanceID        uint32
	NodeULID          string
	Name              string
	PackageName       string
	PackageVersion    string
	NodeType          string // "node" | "subflow" | "pipeline"
	InputMessageName  string
	OutputMessageName string
	CanvasNodeID      string
}

type ReflectionEdge struct {
	SrcInstance      uint32
	DstInstance      uint32
	CanvasEdgeID     string
	HasCondition     bool
	HasAdapter       bool
	MaxIterations    uint32
	ConditionSummary *ConditionSummary
}

type ConditionSummary struct {
	Field    string   // dotted field path, e.g. "tools"
	Op       string   // short op name (EQ | NEQ | CONTAINS | ...)
	Operands []string // comparison operand(s)
}

type FlowPosition struct {
	CurrentInstance      uint32
	Depth                uint32            // 0 at the root flow
	LoopIterations       map[uint32]uint32 // keyed by loop-head dst_instance
	SubflowStackGraphIDs []string          // root → immediate parent
}
```

`HasCondition` and `HasAdapter` are structural flags only — the compiled
condition and adapter recipes are not exposed by design. When an edge is
conditional, `ConditionSummary` digests its leaf predicate (a leaf on a
repeated field has ANY semantics: `Field="tools", Op="EQ",
Operands=["ToolX"]` means "ToolX" is in `tools`), so a node can make
idempotent decisions like skipping a tool that is already wired.
`MaxIterations` is meaningful only on entries returned by `LoopEdges()`.

## Flow mutation

`ax.Mutation().Flow()` lets a node add nodes and edges to the running flow
mid-execution. Only nodes declared with `mutation_capable: true` in
`axiom.yaml` may call it. Calls buffer locally during the handler and are
submitted to the platform when the handler returns.

```go
// nodes/add_tool.go — this node needs mutation_capable: true in axiom.yaml
package nodes

import (
	"context"

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

// AddTool wires a calculator node into the running flow behind a conditional edge.
func AddTool(ctx context.Context, ax axiom.Context, input *gen.ChatRequest) (*gen.ChatReply, error) {
	flow := ax.Mutation().Flow()
	toolInstance := flow.AddNode("demo/calculator", "1.0.0", &axiom.CanvasPosition{X: 400, Y: 200})
	self := ax.Reflection().Flow().Position().CurrentInstance
	flow.AddEdge(self, toolInstance, &axiom.EdgeCondition{
		Op:    "EQ",
		Field: "tools",
		Value: "Calculator",
	})
	return &gen.ChatReply{Text: "calculator wired in"}, nil
}
```

### Mutation interfaces and types

```go
// axiom/context.go (generated)
type Mutation interface {
	Flow() FlowMutation
}

type FlowMutation interface {
	AddNode(packageName, packageVersion string, canvasPosition *CanvasPosition) uint32
	AddEdge(srcInstance, dstInstance uint32, condition *EdgeCondition)
}

type CanvasPosition struct {
	X float64
	Y float64
}

type EdgeCondition struct {
	Op    string // EQ | NEQ | LT | LTE | GT | GTE | CONTAINS | ... Empty -> EQ.
	Field string // dotted field path on the source node's output message
	Value string // comparison operand (string form)
}
```

- `AddNode` returns the batch-local instance ID assigned to the new node —
  use it as `dstInstance` in `AddEdge` calls within the same handler.
  `canvasPosition` is an optional layout hint; pass `nil` to omit it.
- `AddEdge` with a non-nil `condition` wires a conditional dispatch edge
  that fires only when the predicate holds on the source node's output
  message (a condition on a repeated field has ANY semantics). Pass `nil`
  for an unconditional edge. Conditions are structural only — no CEL
  expressions, no adapters.

### Mutation rejection

The platform validates buffered mutations after the handler returns. A
rejected mutation surfaces as an error message with the deterministic
prefix `axiom: mutation rejected: `; the generated `MutationError` type
carries the human-readable reason after that prefix:

```go
// axiom/context.go (generated)
type MutationError struct{ Message string }

func (e *MutationError) Error() string { return e.Message }
```

The structured engine rejection code is not exposed to node code. To stay
idempotent across invocations, check the existing topology via
`ax.Reflection().Flow()` (including each edge's `ConditionSummary`) before
re-adding nodes or edges.

## Where the interface comes from

`axiom/context.go` is generated by the Axiom CLI — `axiom create node`
writes it (and regenerates it idempotently on every run), and the file
carries the banner `Code generated by Axiom CLI; DO NOT EDIT`. Do not edit
it: your changes are overwritten on the next scaffold. The same interface
is implemented by the local `axiom dev` server and by the deployed service
that `axiom push` builds, so handler code behaves identically in both. The
generated test file for each node includes a `newTestContext(t)` helper for
unit-testing handlers — see the testing section of
[create a node in Go](/docs/guides/create-a-node-go).
