---
title: "Create a node in C#"
description: "Scaffold a C# package, implement a unary or pipeline node against IAxiomContext, and run xUnit tests with axiom test — codegen happens at dotnet build via Grpc.Tools."
category: guide
surfaces: [cli, sdk]
languages: [csharp]
related: [getting-started/first-node, getting-started/first-flow, concepts/nodes-packages-flows, concepts/type-system, concepts/execution-model, guides/import-package-types, guides/manage-secrets, reference/axiom-yaml, reference/glossary]
last_reviewed: 2026-06-06
---

# Create a node in C#

A node is the single unit of compute in Axiom: a plain function with a typed
input message and a typed output message. This guide creates a C# package,
implements a unary node and a pipeline node against `IAxiomContext`, and runs
the tests — everything you need before publishing with `axiom push`.

New to Axiom? Do [Write your first node](/docs/getting-started/first-node)
first — it walks the same loop step by step (in Python). This guide covers
what is specific to C#.

## Prerequisites

- The Axiom CLI installed and on your PATH — see
  [Install the Axiom CLI](/docs/getting-started/installation).
- The .NET SDK 8.0 or newer (`dotnet` on your PATH) — the generated projects
  target `net8.0`. Install from <https://dotnet.microsoft.com/download>.
- Docker, only if you run `axiom build` locally.

No protoc and no protobuf plugins are needed: C# proto code generation runs
inside `dotnet build` via the Grpc.Tools MSBuild package, which the generated
project files already reference.

## The fast path

```bash
axiom init my-org/greeter --language csharp
cd greeter
axiom create message GreetRequest --fields "name:string"
axiom create message GreetReply --fields "greeting:string"
axiom create node Greet --input GreetRequest --output GreetReply --type unary
axiom test
```

After these commands you have a package with one node, a generated xUnit test
file, and a passing test run. The sections below explain each artifact and
how to implement the node body.

## Create a C# package

```bash
axiom init my-org/greeter --language csharp
cd greeter
```

`axiom init` creates `./greeter/` (the part of the package name after the
last `/`) containing:

- `axiom.yaml` — the package manifest. See
  [axiom.yaml reference](/docs/reference/axiom-yaml).
- `messages/messages.proto` — the single file where all of the package's
  message types are defined. For C# it carries
  `option csharp_namespace = "Gen";`, so every message compiles into the
  `Gen` namespace.
- `nodes/` — node implementations and their tests.
- `service.csproj` — the deployable service project (generated; do not edit).
  It wires Grpc.Tools so `dotnet build` compiles the `.proto` files; there is
  no separate codegen step.
- `tests/Tests.csproj` — the test project `axiom test` runs with
  `dotnet test`. It compiles the node sources, the `Axiom/` context
  interface, and the message protos directly, and references xUnit.
- `.gitignore` — excludes `.axiom/`, `gen/`, `bin/`, and `obj/`.

Because codegen is deferred to `dotnet build`, `axiom generate` is a no-op
for C# — it prints `C# compiles protobufs at build time via Grpc.Tools —
nothing to generate.` and exits successfully.

## Define the input and output messages

Every node input and output is a message defined in
`messages/messages.proto`. `axiom create message` appends a message block to
that file:

```bash
# Run inside the package directory (where axiom.yaml is).
axiom create message GreetRequest --fields "name:string"
axiom create message GreetReply --fields "greeting:string"
```

`--fields` is a semicolon-separated list and accepts canonical proto3
(`string name = 1`), proto3 without field numbers (`string name`), or the
colon shorthand above. Field numbers are auto-assigned when omitted. Without
`--fields` you get placeholder fields plus a HINTS comment block to edit by
hand.

Two C#-specific points:

- Proto field names are `snake_case` in the `.proto` file; the generated C#
  properties are PascalCase (`name` becomes `input.Name`).
- The generated types land in the `Gen` namespace — node files reference them
  with `using Gen;`.

Comments written directly above a message or field (no blank line in
between) are extracted at publish time and shown in the Axiom registry as
documentation. For how messages type-check across a flow, see
[the type system](/docs/concepts/type-system).

## Scaffold the node

```bash
# Run inside the package directory (where axiom.yaml is).
axiom create node Greet --input GreetRequest --output GreetReply --type unary
```

This creates `nodes/greet.cs` and `nodes/greet_test.cs` (file names are the
snake_case of the node name), appends the node entry to `axiom.yaml`, and
writes `Axiom/Context.cs` — the generated `IAxiomContext` interface
(marked `DO NOT EDIT`; it is regenerated idempotently).

Rules and behavior:

- The node name must be PascalCase (letters and digits only). For C# the
  function name is the node name itself.
- `--input` and `--output` must name messages defined in
  `messages/messages.proto` or available from an imported package — see
  [import package types](/docs/guides/import-package-types). Imported
  messages get a `using imports.<proto-package>;` line instead of
  `using Gen;`, where the namespace is the source package name with `/`
  becoming `.` and `-` becoming `_` (e.g.
  `using imports.axiom_official.axiom_text_ops;`).
- At an interactive terminal, omitted flags trigger prompts: a numbered list
  of available messages, and a node type prompt defaulting to `unary`. In
  non-interactive runs `--input` and `--output` are required and `--type`
  defaults to `unary`.
- `--type` is `unary` (one input in, one output out) or `pipeline`
  (streaming) — see [the execution model](/docs/concepts/execution-model).

## Implement the node

Replace the generated stub in `nodes/greet.cs` with a working body. A unary
node is a static method on a static class in the `Nodes` namespace, and its
signature must match exactly — `axiom validate` checks it:

```csharp
// nodes/greet.cs
using Axiom;
using Gen;
using System.Collections.Generic;

namespace Nodes;

public static class GreetNode
{
    /// <summary>
    /// Returns a personalized greeting for the caller's name.
    /// </summary>
    public static GreetReply Greet(IAxiomContext ax, GreetRequest input)
    {
        ax.Log().Info("greeting requested",
            new Dictionary<string, string> { ["name"] = input.Name });
        return new GreetReply { Greeting = $"Hello, {input.Name}!" };
    }
}
```

The XML doc comment (`///`) directly above the method is extracted at
publish time and shown in the Axiom registry as the node's documentation —
write a real description before publishing.

## Use platform capabilities through IAxiomContext

`ax` is the single injection point for every platform capability — node code
never calls platform services directly (the sidecar mediates everything; see
[sandboxing and tenancy](/docs/concepts/sandboxing-and-tenancy)). The
interface lives in the generated `Axiom/Context.cs`:

- **Logging** — `ax.Log()` returns a structured logger with `Debug`, `Info`,
  `Warn`, and `Error`, each taking a message and an optional
  `IDictionary<string, string>` of attributes. The logger type is nested as
  `IAxiomContext.ILogger`, so it never collides with
  `Microsoft.Extensions.Logging.ILogger`.
- **Secrets** — `ax.Secrets().Get("MY_API_KEY")` returns a
  `(string Value, bool Found)` tuple:

```csharp
// Inside a node method body. required_secrets is informational: an
// unregistered secret yields found=false here at read time, not an error.
var (apiKey, found) = ax.Secrets().Get("MY_API_KEY");
if (!found)
{
    ax.Log().Warn("MY_API_KEY not configured");
}
```

  List each secret name under the node's `required_secrets` in `axiom.yaml`;
  `axiom validate` warns when a `Secrets().Get` call references a name not
  listed there. See [manage secrets](/docs/guides/manage-secrets).
- **Agent memory** — `ax.Agent().Memory()` exposes `Search(query, limit)` and
  `Write(content, importance)`, plus `Session(sessionId)` for session-scoped
  memory with conversation `History()` (`Last(n)`, `Append(role, content)`)
  and `End()`. See [memory](/docs/concepts/memory).
- **Identity** — `ax.ExecutionId()`, `ax.FlowId()`, and `ax.TenantId()`
  return the current execution context.
- **Reflection and mutation** — `ax.Reflection().Flow()` is a read-only view
  of the running flow graph (`Nodes()`, `Edges()`, `Position()`);
  `ax.Mutation().Flow()` buffers additive graph changes (`AddNode`,
  `AddEdge`).

## Write a pipeline node

A pipeline node streams: it consumes an async stream of input frames and
yields output frames. Scaffold one with `--type pipeline`; the required
signature is an async iterator:

```csharp
// nodes/stream_greetings.cs
using Axiom;
using Gen;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;

namespace Nodes;

public static class StreamGreetingsNode
{
    /// <summary>
    /// Emits one greeting per incoming request frame.
    /// </summary>
    public static async IAsyncEnumerable<GreetReply> StreamGreetings(
        IAxiomContext ax,
        IAsyncEnumerable<GreetRequest> inputs,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        await foreach (var input in inputs.WithCancellation(cancellationToken))
        {
            yield return new GreetReply { Greeting = $"Hello, {input.Name}!" };
        }
    }
}
```

For the entry node of a pipeline flow, the stream yields exactly one item.
`axiom create node --type pipeline` generates a pipeline-shaped test file
alongside the implementation. See
[the execution model](/docs/concepts/execution-model) for when to choose
pipeline over unary.

## Run the tests

`axiom test` validates the package, then runs
`dotnet test tests/Tests.csproj` in place, streaming output live; the exit
code mirrors the test runner's. (The generate step it runs first is a no-op
for C#.)

`axiom create node` generated `nodes/greet_test.cs` containing a
`TestContext` — a no-op `IAxiomContext` you can edit to drive a scenario —
and one xUnit `[Fact]`. Replace the generated test body so it asserts real
output values:

```csharp
// nodes/greet_test.cs — replace the body of the generated [Fact] method
[Fact]
public void TestGreet()
{
    IAxiomContext ax = new TestContext();
    var input = new GreetRequest { Name = "Ada" };
    var result = GreetNode.Greet(ax, input);
    Assert.Equal("Hello, Ada!", result.Greeting);
}
```

```bash
axiom test
```

Arguments after `--` go straight to `dotnet test`, e.g.
`axiom test -- --filter TestGreet`.

`axiom validate` warns — without blocking — when a node has no test, and
`axiom test` runs the suite with `dotnet test`. The push build compiles only
the service project — tests never enter the deploy image — so a green
`axiom test` before pushing is the quality gate that matters. Assert output
fields meaningfully — not just null-checked.

## Build and publish

`axiom dev` supports C# via a rebuild-on-save-restart loop: each saved change
runs `dotnet build` and restarts the service (compiled languages recompile
rather than hot-swap, so the first build is slower while the SDK warms up; a
failed build leaves the previous server running). The HTTP bridge (default
`:8083`) converts JSON to and from Protobuf so you can `curl` your nodes. Use
`axiom build` to verify the deployable artifact.

`axiom build` produces the same Docker image the publish pipeline builds:
it validates the package, generates the service artifacts, and runs
`docker build`. The resulting image runs the service on the ASP.NET 8 runtime;
the test project is never included, so no test framework ships in the image.

```bash
axiom build
```

When you are ready to deploy:

```bash
axiom push
```

`axiom push` validates locally, then pushes the package to the Axiom
platform, visible only to your own tenant. It requires a prior `axiom login`,
and the current git HEAD must be pushed to the remote — the platform builds
from the repository, not your working tree. Pushing the same version again
overwrites the previous push. See
[push the package and build your first flow](/docs/getting-started/first-flow).

## Next steps

- [Push the package and build your first flow](/docs/getting-started/first-flow).
- [Invoke a flow via the API](/docs/getting-started/invoke-via-api).
- [Import package types](/docs/guides/import-package-types) — use messages
  from other packages as node inputs and outputs.
- [Manage secrets in a flow](/docs/guides/manage-secrets).
- Writing in another language? See the guides for
  [Python](/docs/guides/create-a-node-python),
  [Go](/docs/guides/create-a-node-go),
  [TypeScript](/docs/guides/create-a-node-typescript),
  [Rust](/docs/guides/create-a-node-rust), and
  [Java](/docs/guides/create-a-node-java).
