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

Go SDK reference

Every capability on axiom.Context — the Go form of AxiomContext: structured logging, secrets, agent memory, execution identity, flow reflection, and flow mutation, with full signatures.

View as Markdown

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, agent memory, 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. 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:

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

// 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.

Context methods at a glance

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

MethodReturnsPurpose
ax.Log()LoggerStructured logging for this invocation
ax.Secrets()SecretsRead-only access to tenant secrets
ax.Agent()AgentAgent capabilities — today, memory
ax.ExecutionID()stringID of the current execution
ax.TenantID()stringID of the tenant that owns this invocation
ax.Reflection()ReflectionRead-only view of the running flow
ax.Mutation()MutationAppend-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.

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

// 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.

// 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)
}
// inside any node handler
apiKey, ok := ax.Secrets().Get("ANTHROPIC_API_KEY")
if !ok {
	return nil, fmt.Errorf("secret ANTHROPIC_API_KEY not 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. Secrets reach the node only through this interface; they never appear in package source.

Agent memory

ax.Agent().Memory() is the entry point to the memory layer (see memory for the model). Memory is namespaced under Agent() because it is an agent capability, separate from general-purpose utilities like logging.

This example needs messages with a session ID field — run these in the package directory first:

axiom create message ChatRequest --fields "session_id:string;text:string"
axiom create message ChatReply --fields "text:string"
axiom create node Remember --input ChatRequest --output ChatReply --type unary
// nodes/remember.go
package nodes

import (
	"context"
	"fmt"

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

// Remember appends the user turn to session history and recalls related memories.
func Remember(ctx context.Context, ax axiom.Context, input *gen.ChatRequest) (*gen.ChatReply, error) {
	session := ax.Agent().Memory().Session(input.GetSessionId())

	if err := session.History().Append(ctx, "user", input.GetText()); err != nil {
		return nil, err
	}
	turns, err := session.History().Last(ctx, 20)
	if err != nil {
		return nil, err
	}
	memories, err := session.Search(ctx, input.GetText(), 5)
	if err != nil {
		return nil, err
	}
	return &gen.ChatReply{
		Text: fmt.Sprintf("%d turns in session, %d related memories", len(turns), len(memories)),
	}, nil
}

The session ID always comes from the typed input message — the platform never infers it.

Memory interfaces

// axiom/context.go (generated)
type Agent interface {
	Memory() AgentMemory
}

type AgentMemory interface {
	// Session returns memory scoped to a specific session ID.
	Session(sessionID string) SessionMemory
	// Search queries semantic memory across all sessions for this flow.
	Search(ctx context.Context, query string, limit int) ([]MemoryEntry, error)
	// Write stores a durable memory at the flow scope (persists across sessions).
	Write(ctx context.Context, content string, importance float32) (string, error)
}

type SessionMemory interface {
	// Search finds the most relevant memories for this session and its flow.
	Search(ctx context.Context, query string, limit int) ([]MemoryEntry, error)
	// Write stores a semantic memory scoped to this session.
	Write(ctx context.Context, content string, importance float32) (string, error)
	// History returns the episodic history accessor for this session.
	History() SessionHistory
	// End formally closes the session and triggers consolidation.
	End(ctx context.Context) error
}

type SessionHistory interface {
	// Last returns the most recent n turns, oldest-first (for LLM context injection).
	Last(ctx context.Context, n int) ([]ConversationTurn, error)
	// Append adds a new turn to this session's history.
	// role is "user" | "assistant" | "tool" | "system"
	Append(ctx context.Context, role, content string) error
}

Write returns the ID of the stored memory. End triggers consolidation — the promotion of episodic records into durable semantic facts.

Memory data types

// axiom/context.go (generated)
type ConversationTurn struct {
	ID         string
	SessionID  string
	Role       string // "user" | "assistant" | "tool" | "system"
	Content    string
	ToolName   string
	ToolCallID string
	CreatedAt  int64 // unix millis
}

type MemoryEntry struct {
	ID         string
	ScopeLevel string
	MemoryType string
	Content    string
	Importance float32
	Confidence float32
	Score      float32 // relevance score, populated on retrieval
	CreatedAt  int64
}

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).

MethodMeaning
ax.ExecutionID()ID of the current execution — the same ID the HTTP API, traces, and debug events reference
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.

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

// 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.

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

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

// 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.