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 MarkdownA 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.
protocon 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-jsaxiom 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 GreetReplyaxiom 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: GreetReplyAfter 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 ofconsole.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_KEYax.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 devThen, 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 pushaxiom 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 pipelineThe 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.