---
title: "Create a node in TypeScript"
description: "Scaffold, implement, test, and push a TypeScript node with the Axiom CLI, using google-protobuf message classes and AxiomContext."
category: guide
surfaces: [cli, sdk]
languages: [typescript]
related: [getting-started/first-node, getting-started/first-flow, concepts/nodes-packages-flows, concepts/type-system, reference/sdk/typescript, guides/manage-secrets, reference/axiom-yaml]
last_reviewed: 2026-06-06
---

# Create a node in TypeScript

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](../getting-started/installation.md).
- Node.js and npm.
- `protoc` on your PATH ([installation guide](https://grpc.io/docs/protoc-installation/)),
  plus the two protoc plugins TypeScript codegen uses:

```bash
# 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:

```bash
# 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`:

```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](../concepts/type-system.md)
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:

```typescript
// 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](../concepts/sandboxing-and-tenancy.md)):

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

```typescript
// 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](manage-secrets.md):

```yaml
# 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](../reference/sdk/typescript.md).

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

```typescript
// 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!');
  });
});
```

```bash
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.

```bash
axiom dev
```

Then, in another terminal:

```bash
# 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

```bash
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](../getting-started/first-flow.md).

## Streaming nodes (pipeline)

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

```bash
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.

```typescript
// 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](../concepts/execution-model.md).
