Build your first C bridge
Compile, wire, and prove a crash-isolated C sink adapter on the Free image without rebuilding CitusIO.
Free-tier copy-paste tutorialYou do not need a paid connector entitlement, a CitusIO source checkout, or a custom CitusIO image to add a sink. Free includes the same public C Bridge SDK and the same bridge process boundary as every self-hosted tier. You compile one shared library outside the runtime image, mount it with ordinary configuration, and route selected events to it.
This tutorial builds a deliberately plain hello-world adapter. Publishing to hello/world/# makes the adapter append a bounded event summary to a file. The destination is intentionally boring: it lets you prove the ABI, process model, routing, and lifecycle before replacing one small function with a real client library.
The completed example has been exercised against both Free images, darkanchor/citusio:1.30 and darkanchor/citusio:1.30-alpine. In each case the installed header alone was enough to compile the adapter; the normal shared guardian loaded it, an isolated worker received a production bridge-ring event, and shutdown called drain cleanly.
What you are building
MQTT client
|
v
nginx MQTT ingress ---> nginz-iotd rule ---> hello-world SHM ring
|
v
one shared guardian
|
isolated bridge worker
|
libhello_world_adapter.so
|
v
destination owned by your code
The shared guardian supervises processes; it never loads your library. Each configured bridge target gets its own worker and address space. If this adapter segfaults or aborts, its worker is replaced with bounded backoff while the guardian, broker, nginx, and sibling bridge workers continue. This contains accidental native faults. It is not a sandbox for hostile code, and host-wide resource exhaustion remains a shared failure domain.
The host also keeps operational policy out of the adapter. nginz-iot-bridge owns backpressure, retry scheduling, dead-letter classification, health, and replay. Free keeps unsettled delivery state in local memory. Pro and higher can select the PostgreSQL outbox when retry and replay must survive restart. The C ABI and custom-adapter ability do not change by tier.
Prerequisites
You need:
- Docker with the Free image already pulled;
- Zig 0.17.0 for the copy-paste cross-libc build below, or another C compiler that targets the image’s CPU and libc;
- any MQTT client for the final publish; and
- about ten minutes for the first run.
The runtime image intentionally has no compiler. Build the customer-owned .so in a workstation or CI job, then mount the result read-only. That keeps compilers and source out of the production image.
Set the image once. Use the Alpine tag if that is what you deploy:
export CITUSIO_IMAGE=darkanchor/citusio:1.30
# export CITUSIO_IMAGE=darkanchor/citusio:1.30-alpine
1. Obtain the complete starter
Create an empty working directory and obtain the maintained source and configuration with your chosen download tools. The following shell example uses curl; no CitusIO-specific client is required. The package uses the public header.
mkdir -p citusio-hello-world
cd citusio-hello-world
base=https://citusio.com/examples/bridge-sdk/hello-world
curl -fsSLO "$base/hello_world_adapter.c"
curl -fsSLO "$base/hello-world.toml"
curl -fsSLO "$base/hello-world.rules"
curl -fsSLO "$base/bridges.toml"
curl -fsSLO "$base/iotd.toml"
curl -fsSLO "$base/compose.yaml"
During local website development, replace the base URL with http://localhost:4321/examples/bridge-sdk/hello-world.
The individual files are also available directly: C source, worker configuration, topic rule, guardian manifest, Free daemon configuration, and Compose topology.
2. Extract the public header from the image
Treat the deployed image as the SDK authority. Extracting its header makes the adapter build repeatable and prevents an accidental header/runtime version mismatch.
docker run --rm --network none \
--entrypoint cat "$CITUSIO_IMAGE" \
/usr/local/include/nginz_iot/nginz_iot_bridge_sdk.h \
> nginz_iot_bridge_sdk.h
Confirm that both the header and bridge daemon exist in Free:
docker run --rm --network none --entrypoint sh "$CITUSIO_IMAGE" -ec '
test -r /usr/local/include/nginz_iot/nginz_iot_bridge_sdk.h
test -x /usr/local/bin/nginz-iot-bridge
echo "Free SDK and bridge daemon: present"
'
Do not copy a header from a different tag just because the major product version looks similar. Pin the image by digest in CI and extract the header from that exact image.
3. Understand the smallest useful adapter
The starter uses ABI v1 because a one-event sink is the shortest correct implementation. It exports one public symbol:
const struct nginz_iot_bridge_sdk_vtable *nginz_iot_bridge_sdk_vtable(void) {
return &hello_world_vtable;
}
The vtable supplies the complete lifecycle:
init -> connect -> send* -> flush -> drain -> close
| Callback | Your responsibility |
|---|---|
init |
Validate the negotiated ABI and capabilities, allocate adapter state, and copy any configuration retained after the call. |
connect |
Establish or verify the destination connection. Keep the operation bounded by the configured timeout. |
send |
Attempt one delivery and fill nginz_iot_bridge_sdk_result with a canonical failure class. |
flush |
Complete buffered client-library work without inventing host retry policy. |
drain |
Stop accepting work and finish the adapter-owned in-flight boundary during clean shutdown. |
close |
Release partial or complete state. It must be safe even when init or connect failed. |
redact_config |
Produce safe diagnostics without endpoints, credentials, tokens, or secret material. |
The key part of the example is send. Event metadata and payload are borrowed only for the callback. The example records topic and byte count, then returns a delivery classification:
line_len = snprintf(
line,
sizeof(line),
"hello-world event_seq=%llu topic=%.*s payload_bytes=%zu\n",
(unsigned long long)event->event_seq,
(int)event->topic_len,
event->topic,
payload_len
);
if (hello_write(state->output_path, line, (size_t)line_len) != 0) {
out->failure_class = NGINZ_IOT_BRIDGE_SDK_RETRYABLE;
snprintf(out->reason, sizeof(out->reason), "hello_write_failed");
return 0;
}
out->failure_class = NGINZ_IOT_BRIDGE_SDK_SUCCESS;
snprintf(out->reason, sizeof(out->reason), "hello_delivered");
return 0;
There are two distinct outcomes here. A nonzero callback return means the adapter could not honor the ABI call. A zero return with out->failure_class means the call was understood and the host should apply the reported delivery result.
Use the classes deliberately:
| Result | Use it when |
|---|---|
SUCCESS |
The destination accepted the event at the boundary promised by your adapter. |
RETRYABLE |
A later attempt can reasonably succeed, such as a temporary connection failure. |
TIMEOUT |
The bounded provider operation exceeded its deadline. |
PERMANENT |
This event can never be accepted, such as an unsupported payload. |
CONFIGURATION |
Operator action is required, such as invalid credentials or endpoint configuration. |
Never retry in an unbounded loop inside send; classify the result and return control to the host. Never put provider response bodies or secrets into reason. The daemon intentionally reduces adapter errors to bounded classifications in records and metrics.
4. Compile for the image’s CPU and libc
The commands below use Zig 0.17.0, matching the CitusIO source-build toolchain. Check the compiler before building:
test "$(zig version)" = "0.17.0"
First inspect the image architecture and libc family:
docker image inspect "$CITUSIO_IMAGE" \
--format 'architecture={{.Architecture}} base={{index .Config.Labels "org.opencontainers.image.base.name"}}'
For the default x86-64 Debian image, compile with:
zig cc -std=c11 -Wall -Wextra -Werror -fPIC -shared \
-target x86_64-linux-gnu.2.17 \
-I . hello_world_adapter.c \
-o libhello_world_adapter.so
For the x86-64 Alpine image, change only the target:
zig cc -std=c11 -Wall -Wextra -Werror -fPIC -shared \
-target x86_64-linux-musl \
-I . hello_world_adapter.c \
-o libhello_world_adapter.so
On arm64, use aarch64-linux-gnu.2.17 or aarch64-linux-musl respectively. The shared object must match both the image CPU architecture and its libc. A library compiled for the host by accident may build successfully and still fail at dlopen.
Check the production entry point before involving the daemon:
file libhello_world_adapter.so
nm -D libhello_world_adapter.so | grep ' nginz_iot_bridge_sdk_vtable$'
5. Describe one isolated worker
hello-world.toml is a worker configuration. Four identities must agree across the topology:
- manifest target:
hello-world; [bridge].bridge_id:hello-world;- rule action:
bridge=hello-world; and - runtime directory:
/run/nginz-iot/bridge/hello-world.
The important adapter fields are:
[bridge]
bridge_id = "hello-world"
sink_kind = external
sdk_adapter_path = "/opt/nginz-iot/hello-world/libhello_world_adapter.so"
sdk_trusted_prefix = "/opt/nginz-iot/hello-world"
sdk_entrypoint = "nginz_iot_bridge_sdk_vtable"
sdk_endpoint = "file:///run/nginz-iot/bridge/hello-world/events.log"
sdk_secret_ref = "env:NGINZ_IOT_HELLO_WORLD_SECRET"
store_provider = local
The daemon canonicalizes both the adapter path and trusted prefix before loading. Keep the directory read-only in production and do not use a world-writable prefix. sdk_secret_ref names a secret source; it is not the secret value. The example does not consume a secret, but retains the production-safe shape.
store_provider = local is the Free setting. It makes custom code usable at no cost, but unsettled work is memory-scoped and can be lost with process or node loss. On Pro or higher, switch to the documented PostgreSQL store and DSN when durable retry/replay is required.
6. Register the worker with the shared guardian
The guardian manifest contains no adapter code and no provider configuration. It only maps a stable target name to a worker file:
[process]
runtime_dir = "/run/nginz-iot/bridge"
pid_file = "/run/nginz-iot/bridge/guardian.pid"
state_file = "/run/nginz-iot/bridge/guardian.state"
metrics_file = "/run/nginz-iot/bridge/guardian.prom"
foreground = true
[bridges]
target = "hello-world=/opt/nginz-iot/hello-world/hello-world.toml"
Add another target line for another bridge. Do not put two unrelated libraries into one worker to save a process: the worker boundary is what prevents one native fault from taking both bridges down.
7. Route only the intended topic tree
The provider-neutral rule action works with a customer SDK adapter in Free:
rule hello-world event=publish topic=hello/world/#
action deliver type=bridge bridge=hello-world required=true on_failure=retry
end
The supplied iotd.toml keeps the ordinary Free live_fast, memory-backed broker profile and changes the rule and bridge references to these two files:
[rules]
enabled = true
config_ref = "file:/opt/nginz-iot/hello-world/hello-world.rules"
[bridges]
enabled = true
config_ref = "file:/opt/nginz-iot/hello-world/hello-world.toml"
The worker and nginz-iotd read the same target configuration, so the bridge ring namespace, generation, writer term, slot size, and capacities cannot drift between two hand-maintained copies.
8. Validate before startup
Run both configuration parsers from the exact image you will start:
docker run --rm --network none \
-v "$PWD:/opt/nginz-iot/hello-world:ro" \
--entrypoint nginz-iotd "$CITUSIO_IMAGE" \
--test-config --config /opt/nginz-iot/hello-world/iotd.toml
docker run --rm --network none \
-v "$PWD:/opt/nginz-iot/hello-world:ro" \
--entrypoint nginz-iot-bridge "$CITUSIO_IMAGE" \
--test-config --manifest /opt/nginz-iot/hello-world/bridges.toml
Both commands must report config ok. Validation catches a missing symbol, ABI/capability mismatch, an adapter outside the trust prefix, unsafe runtime paths, and identity or ring errors before traffic arrives.
If the adapter has external dynamic-library dependencies, inspect them in the target image as an additional build gate:
docker run --rm --network none \
-v "$PWD:/opt/nginz-iot/hello-world:ro" \
--entrypoint sh "$CITUSIO_IMAGE" -ec \
'ldd /opt/nginz-iot/hello-world/libhello_world_adapter.so'
9. Start the Free topology
The supplied Compose file starts three roles from the same immutable image:
iotdowns broker state, rules, and bridge dispatch;gatewayowns MQTT ingress; andbridgestarts the one shared guardian, which starts the isolatedhello-worldworker.
They share the node-local IPC and PID namespaces plus the runtime volume required by the production rings. The PID namespace is required because MPSC recovery verifies producer PID and process-start identity across roles; sharing IPC alone is not a valid topology. The adapter and configuration directory is mounted read-only.
docker compose up -d
docker compose ps
docker compose logs bridge
Wait for both guardian and worker readiness:
docker compose exec bridge sh -ec '
grep "^ready=true$" /run/nginz-iot/bridge/guardian.state
grep "^ready=true$" /run/nginz-iot/bridge/hello-world/bridge.state
grep "nginz_iot_bridge_supervisor_workers_running 1" \
/run/nginz-iot/bridge/guardian.prom
'
If another service already uses host ports 1883 or 8080, change only the left side of the corresponding mapping in compose.yaml.
10. Publish and observe delivery
Publish with any MQTT 3.1.1 or compatible MQTT 5 client. Free does not require authentication in this local Evaluation profile:
mosquitto_pub \
-h 127.0.0.1 -p 1883 \
-i hello-world-tutorial \
-t hello/world/temperature \
-m '{"celsius":23.5}'
Read the adapter-owned side effect and daemon-owned delivery counter:
docker compose exec bridge sh -ec '
cat /run/nginz-iot/bridge/hello-world/events.log
grep nginz_iot_bridge_accepted_total \
/run/nginz-iot/bridge/hello-world/bridge.prom
'
Expect one line shaped like:
hello-world event_seq=1 topic=hello/world/temperature payload_bytes=16
nginz_iot_bridge_accepted_total{bridge="hello-world",mode="dispatch"} 1
The event sequence may differ. The adapter deliberately does not log the payload, tenant, client identifier, endpoint, or secret reference.
Stop cleanly and verify that the host called drain:
docker compose stop -t 10 bridge
docker compose run --rm --no-deps --entrypoint sh bridge -ec \
'tail -n 1 /run/nginz-iot/bridge/hello-world/events.log'
docker compose down -v
The final line before volume removal should be:
hello-world drained=true
Turn the example into a real destination
Keep the lifecycle, result classification, redaction, and exported entry point. Replace the file:// parsing and hello_write call with a bounded provider client. A production adapter should answer these questions explicitly:
- What provider response proves acceptance?
- Which failures are retryable, permanent, timeouts, or configuration errors?
- What is the maximum time and memory for one
send? - Which connection and buffers belong to
flush,drain, andclose? - Which configuration can appear in redacted diagnostics?
- Which dynamic libraries must be present in the target image?
This starter exercises the supported ABI v1 compatibility path. For a new provider integration, use ABI v3 when you need full event identity and the host-generated idempotency key. ABI v2 remains available for existing batching adapters. Implement the negotiated entry point and return one result per event; the host bounds a batch to 32 events and 1 MiB. Preserve its event identity and verify destination commitment and duplicate behavior. The file-sink starter does not establish exactly-once provider effects.
The current public ABI is sink-oriented. It does not yet grant an adapter source ingestion, checkpoint, acknowledgement, or bidirectional authority. Do not simulate those capabilities with background threads hidden from the host lifecycle.
A contract a coding agent can execute
A capable coding agent does not need knowledge of nginx internals or CitusIO private source. Give it the installed header, this starter, the provider’s official client documentation, and a bounded assignment such as:
Implement an out-of-tree CitusIO Bridge SDK sink adapter.
Inputs:
- nginz_iot_bridge_sdk.h extracted from the pinned deployment image
- hello_world_adapter.c as the lifecycle template
- provider client library and its official API contract
Constraints:
- include no CitusIO header except nginz_iot_bridge_sdk.h
- keep every connect/send/flush/drain operation within sdk_timeout_ms
- copy configuration retained after init; treat event and payload pointers as borrowed
- return one SDK failure class for every understood delivery attempt
- never log or return credentials, tokens, payloads, or provider response bodies
- make close safe after partial init and partial connect
- export nginz_iot_bridge_sdk_vtable for ABI v1, or the negotiated v2 entry point
- build as PIC shared library for the deployment image CPU and libc
Acceptance gates:
1. compile with -std=c11 -Wall -Wextra -Werror -fPIC -shared
2. expose exactly the configured SDK entry-point symbol
3. pass nginz-iot-bridge --test-config in the pinned Free image
4. deliver success, retryable, timeout, permanent, and configuration fixtures
5. prove redaction with deliberately secret-bearing configuration
6. prove bounded drain and safe close after every failed lifecycle stage
7. run under the shared guardian and change only its own worker PID on a crash
That assignment keeps generation mechanical where it should be and makes judgment visible in the acceptance gates. A reviewer can inspect the provider acceptance boundary and failure mapping without learning the broker implementation.
Production handoff checklist
- Pin the CitusIO image by digest and save the extracted SDK-header digest with the adapter build.
- Compile separately for every deployed CPU/libc pair; do not relabel one binary for another target.
- Link only libraries present in the target image, or package reviewed dependencies beside the adapter under the read-only trust prefix.
- Supply credentials through
sdk_secret_ref; never embed them in TOML, the.so, logs, reasons, or support output. - Keep provider calls, queues, and memory bounded. Let the daemon own retry and dead-letter policy.
- Validate
nginz-iotdand the guardian manifest before rollout. - Roll out one bridge target at a time and watch its worker state and metrics independently.
- Use Pro or higher with the PostgreSQL outbox when delivery must survive worker or node restart.
- Use process isolation to contain accidental target faults. Only load trusted native code.
For the frozen ABI tables and v2 batch shapes, continue with Public C Bridge SDK. For guardian fields, built-in sinks, and service operation, see nginz-iot-bridge configuration.
This page is a customer-oriented guide. The source contract remains authoritative for exact behavior and release evidence.
examples/bridge-sdk/hello-world and src/processes/bridge/sdk/README.md