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 await other Promise-based platform APIs.

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.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 — each re-push refreshes the code, descriptions, and OpenAPI spec (a deployed node's wire signature is frozen; a wire change requires a version bump). 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, run axiom publish <package>@<version> (or use 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.