Skip to main content

ControlForge NATS Protocol Guide

James M. Belcher Founder, JMB Technical Services LLC May 2026 | ControlForge v1.0.814


1. Architecture Overview

ControlForge ships a complete NATS stack — both the client side and a fully-embedded broker — as native Structured Text functions. No sidecar daemon, no separate process, no add-on package. An ST program can publish to a remote NATS broker, receive subscriptions through a bounded ring buffer, do request/reply without blocking the scan loop, persist messages with JetStream, store key-value state, run its own broker, and join other ControlForge nodes into a NATS cluster — all from the same .goplc project.

There are two sides to the implementation, mirroring the MQTT layout:

RoleFunctionsUse Case
ClientNATS_CLIENT_CREATE / NATS_PUBLISH / NATS_SUBSCRIBE / NATS_REQUEST_*Connect to existing NATS infrastructure, publish telemetry, react to commands, do RPC
BrokerNATS_BROKER_CREATE / NATS_BROKER_STARTRun a broker inside ControlForge for edge deployments and goplc-to-controlforge clustering
JetStreamNATS_JS_*Durable streams with replay — like Kafka, but built into the broker
Key/ValueNATS_KV_*Bucketed key-value store on top of JetStream

System Diagram

NATS vs MQTT — Why Both?

NATS and MQTT solve the same primary problem (broker-mediated messaging) but differ in important ways. ControlForge supports both because they're not interchangeable:

AspectNATSMQTT
Address structureSubjects with . separators (plant.line1.temp)Topics with / (plant/line1/temp)
Wildcards* single token, > rest+ single level, # rest
Request/replyNative first-class primitiveNot built in (use response topics)
Queue groupsNative, multiple consumers split loadShared subscriptions (5.0 only)
Durable persistenceJetStream (built in, replayable)Retained messages (last value only)
Key/valueJetStream KV built inNot built in
ClusteringNative peer routes between brokersBridges only
Wire weightFaster (text protocol, no per-message broker storage by default)Smaller for QoS 0
Use the embedded broker forgoplc-to-controlforge mesh, edge isolationLocal IIoT device hub

If you're sharing data with HMI tools, sensors, MQTT-only field devices, or cloud IoT endpoints, use MQTT. If you're connecting ControlForge nodes to each other, doing RPC between programs, or want durable streaming with replay, use NATS.

NATS Concepts Quick Reference

ConceptDescription
SubjectRouting key (plant.line1.temp). Hierarchical, no pre-registration.
Wildcards* = one token (plant.*.temp), > = rest (plant.>).
PublishFire-and-forget message to a subject.
SubscribeRegister interest; messages land in a per-subject bounded ring buffer.
Queue GroupLoad-balanced subscription — N members in the group, only one gets each message.
Request/ReplyBuilt-in: requester gets a temporary inbox subject, listens for one reply. ControlForge implements this asynchronously — see §6.
JetStreamDurable persistence layer. Streams capture published messages; consumers replay.
KV BucketKey/value store on top of JetStream. Get / Put / Delete / Watch.
ClusterTwo or more NATS brokers connected by routes. Subscription interest propagates across the mesh.
NoRespondersNATS 2.x default: a request to a subject with zero subscribers returns immediately with an error rather than timing out. ControlForge surfaces this as ReqError (-2).

2. Quick Start

The simplest possible NATS pipeline: one ST program, one external nats-server, one publish, one subscribe.

Step 1 — Run a NATS server

Anywhere on your network:

nats-server -js

(JetStream isn't needed for plain pub/sub but it's harmless to enable.)

Step 2 — Write an ST program

PROGRAM Main
VAR
init_done : BOOL := FALSE;
received : STRING := '';
END_VAR

(* First-scan setup *)
IF NOT init_done THEN
NATS_CLIENT_CREATE('nc', 'nats://localhost:4222', '');
NATS_CLIENT_CONNECT('nc');
NATS_SUBSCRIBE('nc', 'plant.line1.temp');
init_done := TRUE;
END_IF

(* Every scan: publish *)
NATS_PUBLISH('nc', 'plant.line1.temp', '23.4');

(* Every scan: drain one message if available *)
IF NATS_HAS_MESSAGE('nc', 'plant.line1.temp') THEN
received := NATS_NEXT_MESSAGE('nc', 'plant.line1.temp');
END_IF
END_PROGRAM

Run with the standard controlforge <project>.goplc --api-port 8082 and watch received update on every scan.

Step 3 — Watch from outside

nats sub --server nats://localhost:4222 "plant.>"

Every time the ST program publishes, the CLI prints the message — proving the ControlForge client is talking to a real broker, not a simulation.


3. Function Reference

Client lifecycle

NATS_CLIENT_CREATE(name, url, [client_id]) : BOOL
NATS_CLIENT_CREATE_AUTH(name, url, user, password) : BOOL
NATS_CLIENT_CREATE_TOKEN(name, url, token) : BOOL
NATS_CLIENT_CONNECT(name) : BOOL
NATS_CLIENT_DISCONNECT(name) : BOOL
NATS_CLIENT_IS_CONNECTED(name) : BOOL
NATS_CLIENT_DELETE(name) : BOOL
NATS_CLIENT_LIST() : STRING (CSV)
NATS_CLIENT_STATUS(name) : STRING

The first argument to every function is the local handle name you chose at create time. URL accepts comma-separated multi-server lists for high-availability: 'nats://primary:4222,nats://secondary:4222'.

Pub/Sub

NATS_PUBLISH(name, subject, payload) : BOOL
NATS_SUBSCRIBE(name, subject) : BOOL
NATS_QUEUE_SUBSCRIBE(name, subject, queue) : BOOL
NATS_UNSUBSCRIBE(name, subject) : BOOL
NATS_NEXT_MESSAGE(name, subject) : STRING (empty if none)
NATS_HAS_MESSAGE(name, subject) : BOOL
NATS_PENDING(name, subject) : DINT
NATS_DROPPED(name, subject) : DINT

Bounded ring buffer: Each subscription has a per-subject ring (default depth 1024). When the ring fills, the oldest message is dropped and NATS_DROPPED() increments. This is the right behavior for control loops — fresh data is more useful than ancient data — but if you're seeing drops you should either increase scan rate, drain in batches, or use JetStream pull consumers (§7) for at-least-once delivery.

Wildcards in subscribe: NATS_SUBSCRIBE('nc', 'plant.>') captures everything under plant.. Each delivered message includes its actual subject, available through NATS_NEXT_MESSAGE.

Async Request/Reply Triplet

NATS_REQUEST_SEND(name, subject, payload, [timeout_ms]) : DINT (request id, 0 on err)
NATS_REQUEST_RESULT(name, request_id) : INT
(0=pending, 1=ready, -1=timeout, -2=error/no-responders)
NATS_REQUEST_DATA(name, request_id) : STRING (drains handle)
NATS_REQUEST_DROP(name, request_id) : BOOL

This is the only correct way to do request/reply from ST. A naive synchronous call would block the scan loop while waiting for the responder; control logic would freeze. The triplet runs the wait in a background goroutine and lets your scan poll for the result.

VAR
req_id : DINT := 0;
reply : STRING := '';
state : INT := 0; (* 0=idle, 1=waiting, 2=done *)
END_VAR

CASE state OF
0: (* Send *)
req_id := NATS_REQUEST_SEND('nc', 'sensor.read', 'temp', 2000);
IF req_id > 0 THEN state := 1; END_IF
1: (* Poll *)
CASE NATS_REQUEST_RESULT('nc', req_id) OF
1: reply := NATS_REQUEST_DATA('nc', req_id);
state := 2;
-1, -2: state := 0; (* retry next cycle *)
END_CASE
2: (* Done — use reply, then back to idle *)
state := 0;
END_CASE

NATS_REQUEST_DATA auto-releases the handle on the first successful read; calling it again returns ''. NATS_REQUEST_DROP discards a pending request without reading.

JetStream (durable streams)

NATS_JS_PUBLISH(name, subject, payload) : BOOL
NATS_JS_PULL_SUBSCRIBE(name, stream, durable, [subject_filter]) : BOOL
NATS_JS_NEXT_MESSAGE(name, durable) : STRING
NATS_JS_PENDING(name, durable) : DINT
NATS_JS_DROPPED(name, durable) : DINT
NATS_JS_UNSUBSCRIBE(name, durable) : BOOL

JetStream gives you at-least-once delivery, replay, and rate-decoupling (publishers can fire faster than consumers). The stream and the durable consumer must be created out-of-band with the nats CLI or admin tooling — ST code only binds to existing infrastructure.

# Operator setup, once
nats stream add ALARMS --subjects "alarms.>" --storage memory --retention limits
nats consumer add ALARMS WORKER --filter "" --ack explicit --deliver all --pull
NATS_JS_PUBLISH('nc', 'alarms.high_temp', 'panel-3 over limit');
NATS_JS_PULL_SUBSCRIBE('nc', 'ALARMS', 'WORKER', 'alarms.>');
IF NATS_JS_PENDING('nc', 'WORKER') > 0 THEN
msg := NATS_JS_NEXT_MESSAGE('nc', 'WORKER');
END_IF

Pull-consume is the right model for ST: a background goroutine fetches in batches and fills a per-durable ring identical to the plain-subscribe ring. Messages are auto-acked on read.

Key/Value

NATS_KV_GET(name, bucket, key) : STRING (empty if missing)
NATS_KV_HAS(name, bucket, key) : BOOL
NATS_KV_PUT(name, bucket, key, value) : BOOL
NATS_KV_DELETE(name, bucket, key) : BOOL
NATS_KV_KEYS(name, bucket) : STRING (CSV)

Buckets are created out-of-band: nats kv add CONFIG. KV ops are short round-trips against the broker (sub-millisecond on a healthy LAN) so they're exposed as synchronous calls — no async triplet needed.

NATS_KV_HAS exists because NATS_KV_GET returns '' for both "key missing" and "key set to empty string." When the difference matters, check _HAS first.

Embedded Broker

NATS_BROKER_CREATE(name, port) : BOOL
NATS_BROKER_CREATE_AUTH(name, port, user, password) : BOOL
NATS_BROKER_CREATE_JS(name, port, store_dir) : BOOL
NATS_BROKER_START(name) : BOOL
NATS_BROKER_STOP(name) : BOOL
NATS_BROKER_DELETE(name) : BOOL
NATS_BROKER_IS_RUNNING(name) : BOOL
NATS_BROKER_URL(name) : STRING
NATS_BROKER_LIST() : STRING (CSV)
NATS_BROKER_NUM_CONNECTIONS(name) : DINT
NATS_BROKER_NUM_ROUTES(name) : DINT
NATS_BROKER_STATS(name) : STRING

CREATE only validates and constructs — call START to actually bind ports. Monitoring HTTP is disabled by default (set explicitly via Go-side BrokerConfig.MonitorPort if needed).


4. Worked Example: 11-Node Cluster with Chained Counter

This is the canonical demo for the embedded broker — and a real ControlForge project under projects/nats-cluster-demo/.

Topology:

Each minion appends ->N@<timestamp_ms> to the message it receives, so the boss's final string is a complete trace of the chain plus per-hop latency stamps.

Boss (node 0)

PROGRAM Boss
VAR
init_done : BOOL := FALSE;
delay_count : DINT := 0;
kicked : BOOL := FALSE;
final_value : STRING := '';
start_us : LINT := 0;
end_us : LINT := 0;
total_us : LINT := 0;
END_VAR

IF NOT init_done THEN
NATS_BROKER_CREATE('cluster_broker', 4226);
NATS_BROKER_START('cluster_broker');

NATS_CLIENT_CREATE('boss_client', 'nats://127.0.0.1:4226', '');
NATS_CLIENT_CONNECT('boss_client');
NATS_SUBSCRIBE('boss_client', 'chain.done');
init_done := TRUE;
END_IF

(* Wait ~3s for all minions to subscribe before kicking the chain *)
delay_count := delay_count + 1;
IF init_done AND NOT kicked AND delay_count > 60 THEN
start_us := NOW_US();
NATS_PUBLISH('boss_client', 'chain.1',
CONCAT('start@', LINT_TO_STRING(start_us)));
kicked := TRUE;
END_IF

IF NATS_HAS_MESSAGE('boss_client', 'chain.done') THEN
final_value := NATS_NEXT_MESSAGE('boss_client', 'chain.done');
end_us := NOW_US();
total_us := end_us - start_us;
END_IF
END_PROGRAM

Minion N (1..10)

PROGRAM MinionN
VAR
init_done : BOOL := FALSE;
last_in : STRING := '';
last_out : STRING := '';
END_VAR

IF NOT init_done THEN
NATS_CLIENT_CREATE('mN_client', 'nats://127.0.0.1:4226', '');
NATS_CLIENT_CONNECT('mN_client');
NATS_SUBSCRIBE('mN_client', 'chain.N');
init_done := TRUE;
END_IF

IF NATS_HAS_MESSAGE('mN_client', 'chain.N') THEN
last_in := NATS_NEXT_MESSAGE('mN_client', 'chain.N');
last_out := CONCAT(CONCAT(CONCAT(last_in, '->N@'),
LINT_TO_STRING(NOW_US())), '');
(* Last minion publishes to chain.done; others to chain.<N+1> *)
NATS_PUBLISH('mN_client', 'chain.<NEXT>', last_out);
END_IF
END_PROGRAM

Result

After all 11 nodes are running and the boss kicks the chain, its final_value contains the full 11-stop trail with µs-precision timestamps. Two live runs at different scan rates:

50 ms periodic scan (10 hops):

hop delta
1 +33.61 ms
2 +33.92 ms
3 +1.44 ms ← scan caught it immediately
4 +25.40 ms
5 +15.15 ms
6 +20.52 ms
7 +5.47 ms
8 +47.17 ms ← just missed a scan
9 +20.92 ms
10 +13.74 ms
─────────────────
10-hop total: 217.33 ms (mean 21.7 ms/hop)
boss.total_us: 250.18 ms (incl. return hop to chain.done)

5 ms periodic scan (same code, scan_time_ms=5):

hop delta
1 +1.57 ms
2 +1.54 ms
3 +14.45 ms ← jitter outlier (likely GC pause)
4 +3.11 ms
5 +3.25 ms
6 +6.24 ms
7 +1.40 ms
8 +6.58 ms
9 +3.79 ms
10 +4.46 ms
─────────────────
10-hop total: 46.39 ms (mean 4.64 ms/hop)
boss.total_us: 50.66 ms

1 ms periodic scan (same code, scan_time_ms=1):

hop delta
1 +0.28 ms ← lucky: scan caught it immediately
2 +1.27 ms ← unlucky: missed a scan
3 +0.17 ms ← lucky
4 +1.21 ms ← unlucky
5 +1.27 ms
6 +1.29 ms
7 +1.27 ms
8 +0.17 ms
9 +0.19 ms
10 +1.24 ms
─────────────────
10-hop total: 8.36 ms (mean 0.84 ms/hop)
boss.total_us: 9.67 ms (sub-10-ms 11-hop round trip)

Notice the bimodal distribution at 1 ms scan: every delta is either ~0.2 ms (the message arrived just before a scan) or ~1.2 ms (it just missed and had to wait a full period). This is exactly what "uniform-random 0–1 ms wait plus ~0.3 ms broker round-trip" predicts.

Scaling table:

Requested scanActual achieved10-hop totalmean/hopround-trip
50 ms50 ms217.33 ms21.70 ms250.18 ms
5 ms5 ms46.39 ms4.64 ms50.66 ms
1 ms1 ms8.36 ms0.84 ms9.67 ms
500 µs~1 ms5.49 ms0.55 ms6.40 ms

Each 10× scan reduction yields ~5× chain reduction — the latency floor is the ~0.2–0.3 ms broker round-trip plus ring-buffer hand-off, which doesn't scale with scan period. Minion avg_scan_us stayed at ~30–55 µs across all four runs; the ST work itself is nowhere near even the 500 µs budget.

The 500 µs run is interesting: we asked for 500 µs scan via scan_time_us:500 in the task config, but the actual achieved scan period is ~1 ms because Go's time.NewTicker on a stock Linux kernel can't reliably fire faster than the kernel's HZ tick — the timer wakes up on millisecond boundaries. Net result is a ~550 µs mean per-hop latency, the bimodal "lucky" / "unlucky" pattern still holds.

If you need sub-500 µs reliable per-hop latency on a real PLC, the broker is no longer the bottleneck — it's the OS scheduler. Real-time options:

LeverEffect
scan_time_us < 1000Half-helps: actual period clamped to ~1 ms by stock kernel HZ
PREEMPT_RT kernelTrue µs precision; controlforge supports the preempt-rt snap plug
SCHED_FIFO + CPU pinningRemoves the worst jitter spikes (GC pauses still bite)
Event-triggered task (wake on ring-buffer fill)Eliminates the scan-phase wait entirely; runtime hooks are present, ST surface not yet wired

The full .goplc project files for boss and all 10 minions are in projects/nats-cluster-demo/ — drop them into separate controlforge instances, point each at its assigned API port (8084 for boss, 8085-8094 for minions), and watch the chain run.


5. Cluster Mode (Multi-Broker Mesh)

When you want true broker-to-broker routing — multiple controlforge nodes each running their own broker, subscription interest auto-propagating between them — the embedded broker's cluster routing is the answer. This is configured at the Go level today (the ST broker functions don't expose cluster routes yet); see pkg/protocols/nats/broker_test.go TestBrokerCluster for the canonical Go API. A future revision of the ST surface will add NATS_BROKER_CLUSTER_*.

For pure ST-driven clustering, the pattern in §4 (one broker, many clients) gets you most of the way there. The dedicated cluster mode is for broker-to-broker redundancy and load distribution.


6. Authentication

Client side

NATS_CLIENT_CREATE_AUTH(name, url, user, password) — basic user/password (recommended for most deployments).

NATS_CLIENT_CREATE_TOKEN(name, url, token) — opaque bearer token (rotate via KV bucket, etc.).

Broker side

NATS_BROKER_CREATE_AUTH(name, port, user, password) enforces a single user. For multi-user setups with per-user pub/sub ACLs, use the Go-level BrokerConfig.Auth struct directly — see pkg/protocols/nats/broker.go. ST-level multi-user NATS_BROKER_ADD_USER is a future addition.


7. Performance

Ballpark numbers from the development laptop (Dell Precision 7740, Linux, Go 1.25):

OperationLatency
Embedded broker → ST client → embedded broker (loopback)<0.5 ms
Single-broker chain across 10 ST programs1–5 ms total
KV Put/Get on local broker<1 ms
JetStream publish + ack on local broker2–10 ms

Network latency dominates over LAN; local-loopback cluster numbers are essentially CPU-bound.


8. Comparison vs MQTT — When to Use Which

WorkloadPick
Talk to MQTT-only field devices, sensors, HMI toolsMQTT
Cloud telemetry to AWS IoT / Azure IoT / Google IoT CoreMQTT
ControlForge-to-ControlForge mesh, edge clusteringNATS (embedded broker)
Synchronous-feeling RPC between programsNATS (request/reply triplet)
Durable streaming + replay (alarm history, audit log)NATS JetStream
Distributed key/value config without a separate DBNATS KV
Lowest possible wire footprint for QoS 0MQTT
Subject ACLs and per-user pub/sub permissionsNATS (more flexible)

You can run both simultaneously — they're independent stacks. A common pattern: NATS internally between controlforge nodes, MQTT externally to dashboards and devices.


9. Industrial Context — Where NATS Lives

Quick history. NATS was created by Derek Collison in 2010, originally in Ruby and rewritten in Go around 2012. It's been MIT-licensed from day one and is now a CNCF incubating project, with Synadia as the commercial steward. JetStream (durable streams) and accounts/multi-tenancy landed in NATS 2.0 (2019). So it's roughly the same vintage as MQTT 3.1.1 (the 2014 standard) and considerably older than Kafka was when Kafka first showed up in OT environments.

The "is NATS industrial?" question. It's the wrong shape of question. NATS isn't trying to compete with Sparkplug B for the device-to-SCADA tier. The protocols sit at different layers:

TierTypical incumbentWhere NATS fits
Field bus (sensor → PLC)Modbus, EtherNet/IP, PROFINET, CAN, BACnetNo — wrong physics; you need real-time deterministic I/O
PLC → SCADA / historianSparkplug B over MQTT, OPC UAPossible, but Sparkplug B has the data model (UDTs, birth/death, host state) and the SCADA vendor support
Edge gateway → cloudMQTT, NATS, Kafka, AMQPSweet spot. Higher fan-out than MQTT, lower per-broker storage cost than Kafka
Cross-plant / cross-site meshNATS, Kafka, RabbitMQSweet spot. Native broker-to-broker routing makes multi-site trivial
OT/IT bridge microservicesNATS, gRPC, KafkaSweet spot. This is what NATS was actually designed for
Workload telemetry, fleet opsNATS, KafkaSweet spot. Sub-ms request/reply makes RPC-style integration practical

The honest summary: if a sensor talks to a PLC, NATS isn't in that conversation. If a PLC talks to a SCADA, Sparkplug B usually wins. But if a fleet of PLCs / edge gateways needs to talk to each other, to the cloud, or to plant-wide services — that's where NATS earns its keep.

Real industrial deployments using NATS:

UserWhat they use it for
TeslaVehicle telemetry plane, factory data plane
Siemens MindSphereInternal messaging fabric inside the IIoT cloud platform
General ElectricPredix and successor industrial cloud platforms
Bosch (the ctrlX vendor)Several internal industrial-cloud platforms
WalmartStore-to-cloud event mesh across 4,500+ stores
Ericsson 5G coreInter-microservice messaging in network functions
Synadia customersProcess control, oil & gas, utilities, smart-city

This is far from exhaustive — NATS is widely deployed; it's just quietly deployed inside platforms rather than as a marquee device-protocol the way Sparkplug B is.

The cultural gap. OT engineers usually haven't heard of NATS; IT/ cloud engineers usually haven't heard of Sparkplug B. Each side assumes its own messaging is what "real" systems use. ControlForge's value proposition in this conversation is that it speaks both fluently in the same runtime: Sparkplug B for the SCADA-facing path, NATS for the goplc-to-controlforge mesh and gateway-to-cloud path. That pairing is genuinely rare in the field today.

When to reach for NATS over Sparkplug B in goplc:

GoalPick
Talk to Ignition, AVEVA, Wonderware, Inductive — any SCADA that consumes Sparkplug BSparkplug B
Multiple controlforge nodes need to share state (boss/minion, cell controllers, redundant pairs)NATS
Send telemetry to a cloud event bus (Synadia NGS, self-hosted nats-server cluster)NATS
Durable command queue with replay-from-offset semanticsNATS JetStream
Distributed key/value config without standing up etcd or ConsulNATS KV
Sub-millisecond request/reply between control programsNATS (the async triplet — see §3)
Anything that an OT historian or HMI vendor will plug intoSparkplug B

The two stacks are independent and can run side-by-side in the same controlforge instance. The most common high-value pattern: NATS internally for mesh coordination and edge-to-cloud, Sparkplug B externally for SCADA integration. They don't fight.


10. Troubleshooting

SymptomCauseFix
NATS_CLIENT_CONNECT returns FALSEBroker not reachableVerify with nats sub --server <url> ">" from the same host
NATS_REQUEST_RESULT returns -2 immediatelyNATS 2.x no-responders shortcut: nobody is subscribed to that subjectCheck the responder is up before requesting; consider a small startup delay
Messages dropped (NATS_DROPPED rising)Ring buffer overflow (default 1024)Drain faster, increase scan rate, or raise Config.InboxBufferDepth at create time (Go-side)
NATS_BROKER_START returns FALSEPort collision (4222 by default) — usually monitoring port 8222Pick a different broker port; embedded broker disables monitoring by default to avoid 8222 collision
JetStream pull-subscribe returns FALSEStream or durable consumer doesn't existCreate with nats stream add and nats consumer add first
NATS_KV_GET returns '' for a key you just wroteEither the key is genuinely empty, or it's missingUse NATS_KV_HAS to disambiguate

Diagnostic commands

# What's listening on the broker port?
nats server check connection --server nats://localhost:4222

# What's in this stream?
nats stream info <stream-name>

# What does a consumer's pending count look like?
nats consumer info <stream-name> <durable>

# All KV buckets
nats kv ls

11. Function Index

44 entries total — see /api/docs/functions?search=NATS for the live signature list.

Client (9): NATS_CLIENT_CREATE, NATS_CLIENT_CREATE_AUTH, NATS_CLIENT_CREATE_TOKEN, NATS_CLIENT_CONNECT (alias NATS_CONNECT), NATS_CLIENT_DISCONNECT (alias NATS_DISCONNECT), NATS_CLIENT_IS_CONNECTED (alias NATS_IS_CONNECTED), NATS_CLIENT_DELETE, NATS_CLIENT_LIST, NATS_CLIENT_STATUS

Pub/Sub (8): NATS_PUBLISH, NATS_SUBSCRIBE, NATS_QUEUE_SUBSCRIBE, NATS_UNSUBSCRIBE, NATS_NEXT_MESSAGE, NATS_HAS_MESSAGE, NATS_PENDING, NATS_DROPPED

Async req/reply (4): NATS_REQUEST_SEND, NATS_REQUEST_RESULT, NATS_REQUEST_DATA, NATS_REQUEST_DROP

JetStream (6): NATS_JS_PUBLISH, NATS_JS_PULL_SUBSCRIBE, NATS_JS_NEXT_MESSAGE, NATS_JS_PENDING, NATS_JS_DROPPED, NATS_JS_UNSUBSCRIBE

KV (5): NATS_KV_GET, NATS_KV_HAS, NATS_KV_PUT, NATS_KV_DELETE, NATS_KV_KEYS

Embedded broker (12): NATS_BROKER_CREATE, NATS_BROKER_CREATE_AUTH, NATS_BROKER_CREATE_JS, NATS_BROKER_START, NATS_BROKER_STOP, NATS_BROKER_DELETE, NATS_BROKER_IS_RUNNING, NATS_BROKER_URL, NATS_BROKER_LIST, NATS_BROKER_NUM_CONNECTIONS, NATS_BROKER_NUM_ROUTES, NATS_BROKER_STATS