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