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

Create a node in TypeScript

Scaffold, implement, test, and push a TypeScript node with the Axiom CLI, using google-protobuf message classes and AxiomContext.

View as Markdown

A node is a plain TypeScript function: it takes AxiomContext and a typed input message, and returns a typed output message. This guide scaffolds a TypeScript package, implements and tests a node, runs it locally with hot reload, and pushes it to the platform.

Prerequisites

  • The Axiom CLI installed and logged in (axiom login) — see Installation.
  • Node.js and npm.
  • protoc on your PATH (installation guide), plus the two protoc plugins TypeScript codegen uses:
# protoc-gen-ts comes from ts-protoc-gen; protoc-gen-js emits the JS message runtime
npm install -g ts-protoc-gen protoc-gen-js

axiom generate compiles messages/*.proto with protoc-gen-js (the google-protobuf runtime classes, gen/messages_pb.js) and protoc-gen-ts (the matching type declarations, gen/messages_pb.d.ts). It reports which tool is missing if one is not found.

Scaffold the package and node

Run these commands to go from nothing to a compiling node skeleton:

# from any working directory
axiom init my-org/greeter --language typescript
cd greeter
npm install

axiom create message GreetRequest --fields "name:string"
axiom create message GreetReply --fields "greeting:string"
axiom create node Greet --input GreetRequest --output GreetReply

axiom init creates a greeter/ directory (the part of the package name after the last /) containing axiom.yaml, messages/, nodes/, gen/, a .gitignore, and the TypeScript toolchain files: package.json (with google-protobuf, the @grpc/* stack, and jest/ts-jest dev dependencies), tsconfig.json, and jest.config.js.

axiom create message appends a message block to messages/messages.proto — all messages live in that single file. The --fields flag accepts colon shorthand (name:string) or canonical proto3; omit it to get example placeholder fields (plus a syntax-hints comment block) to replace. Comments written directly above a message or field (no blank line between) are extracted at publish time as registry documentation.

axiom create node requires a PascalCase name and creates nodes/greet.ts (the handler, with a camelCase function name greet) and nodes/greet_test.ts (a jest test with a ready-made mock AxiomContext), then records the node in axiom.yaml:

# axiom.yaml (written by init + create node)
name: my-org/greeter
version: 0.1.0
language: typescript
nodes:
    - name: Greet
      input: GreetRequest
      output: GreetReply

After the scaffold sequence the generated files are already in place: axiom create message runs axiom generate automatically (compiling gen/messages_pb.js and gen/messages_pb.d.ts), and axiom create node writes gen/axiomContext.ts. Both commands accept --no-generate to skip their generate step while batching; run axiom generate afterwards to produce all three files at once. For TypeScript packages, gen/ is committed to git; .axiom/ (build artifacts), node_modules/, and dist/ are ignored. See The type system for how messages define your node's contract.

Implement the handler

Edit the generated file. Input and output are google-protobuf classes with getter/setter accessors:

// nodes/greet.ts
import { GreetRequest, GreetReply } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';

/**
 * Greets the caller by name. This JSDoc directly above the function is
 * extracted at publish time and shown in the Axiom registry as the node's
 * description — edit it before publishing.
 *
 * @param ax - Platform context: ax.log for logging, ax.secrets for secrets.
 */
export function greet(ax: AxiomContext, input: GreetRequest): GreetReply {
  ax.log.info('greeting', { name: input.getName() });
  const out = new GreetReply();
  out.setGreeting(`Hello, ${input.getName()}!`);
  return out;
}

Handlers may also be async — declare the return type as Promise<GreetReply> and the platform awaits the result. This is required when you use the Promise-based memory API (await ax.agent.memory.session(id).history().last(20)).

Use platform capabilities through AxiomContext

Every platform capability arrives through the first parameter, ax — node code never calls platform services directly (see Sandboxing and tenancy):

  • ax.log.debug/info/warn/error(msg, attrs?) — structured logging. Use it instead of console.log(): in production every line carries the execution ID and trace IDs.
  • ax.secrets.get(name) — returns a [value, ok] tuple:
// nodes/greet.ts — inside the handler
const [apiKey, ok] = ax.secrets.get('OPENAI_API_KEY');
if (!ok) {
  ax.log.warn('OPENAI_API_KEY is not configured');
}

Declare each secret a node reads under required_secrets in axiom.yaml so it is validated at publish time and shown in the marketplace — see Manage secrets in a flow:

# axiom.yaml — node entry with a declared secret
nodes:
    - name: Greet
      input: GreetRequest
      output: GreetReply
      required_secrets:
        - OPENAI_API_KEY
  • ax.agent.memory — session history and agent memory (see Agent memory).
  • ax.executionId, ax.flowId, ax.tenantId — identifiers for the current invocation.

The full interface is documented in the TypeScript SDK reference.

Test the node

axiom test runs three steps: axiom generate, axiom validate (manifest schema, proto compilation, node signatures — fails fast), then jest (preferring the locally installed node_modules/.bin/jest). Tests live in nodes/*_test.ts; the scaffolded nodes/greet_test.ts already defines a mock testContext you can pass to the handler — replace its TODO assertion with real ones:

// nodes/greet_test.ts — replace the generated TODO with real assertions
describe('Greet', () => {
  it('greets by name', () => {
    const input = new GreetRequest();
    input.setName('Ada');
    const result = greet(testContext, input);
    expect(result).toBeInstanceOf(GreetReply);
    expect(result.getGreeting()).toBe('Hello, Ada!');
  });
});
axiom test                              # full suite
axiom test -- --testNamePattern Greet   # pass-through args after --

axiom validate warns — without blocking — when a node has no test, and axiom test runs the suite with jest. The push build compiles the service but does not run tests, so a green axiom test before pushing is the quality gate that matters. Assert output fields meaningfully — not just type-checked.

Run the node locally

axiom dev generates the gRPC service, runs it with npx ts-node, and starts an HTTP bridge (default port 8083, change with --port) that converts JSON to and from Protocol Buffers. It watches nodes/, messages/, and axiom.yaml and recompiles on change; a failed build leaves the previous service running while you fix the error.

axiom dev

Then, in another terminal:

# invoke the node over the HTTP bridge
curl localhost:8083/nodes/Greet -d '{"name":"Ada"}'
# → {"greeting":"Hello, Ada!"}

The bridge also serves GET /openapi.json (importable into Postman, Insomnia, or Bruno) and an interactive try-it page at GET /docs.

Push to the platform

axiom push

axiom push validates the package locally, then pushes it. A pushed package is visible only to your own tenant — it does not appear in the public marketplace, and you can push the same version repeatedly while iterating. It requires a prior axiom login, and your current git HEAD must be pushed to the remote (the platform builds from it). When you are satisfied, publish through the Axiom UI to make an immutable public release. Next: place the node in a flow — see Your first flow.

Streaming nodes (pipeline)

For streaming, scaffold a pipeline node with --type pipeline:

axiom create node Tokenize --input GreetRequest --output GreetReply --type pipeline

The handler is an async generator: it consumes an AsyncIterable of input frames and yields output frames. When a pipeline node is the entry node of a flow, the iterable yields exactly one item.

// nodes/tokenize.ts
import { GreetRequest, GreetReply } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';

/**
 * Emits one greeting frame per input frame.
 */
export async function* tokenize(
  ax: AxiomContext,
  inputs: AsyncIterable<GreetRequest>,
): AsyncGenerator<GreetReply> {
  for await (const input of inputs) {
    const out = new GreetReply();
    out.setGreeting(`Hello, ${input.getName()}!`);
    yield out;
  }
}

Flows containing pipeline nodes run in pipeline mode — see Execution model.