External Topic Boundaries
Most of a Holoscan application lives inside one graph: operators exchange data
through typed ports, and add_flow() wires them together. But real systems
rarely end at the graph boundary. A sensor daemon in another process feeds you
samples; a control loop elsewhere on the machine consumes your output. This
page shows how a graph declares those boundaries — external topics — and
how the compiled plan turns them into a precise, inspectable contract that
external processes can attach to.
The key idea is separation of concerns across the compose → compile → run
phases. During compose, you declare a boundary with import_topic<T>() or
export_topic<T>(), exactly as cold and typed as any other authoring call.
During compile, the plan records every boundary in an endpoint catalog.
During run, the graph side binds automatically — you never write transport
code inside the graph. Everything an external process needs to know lives in
the catalog, and nothing else crosses the boundary.
Complete graph examples: examples/camera_imu_control/cpp,
examples/external_clients/sensor_daemon/cpp,
examples/external_clients/recorder/cpp,
and examples/flatbuffer_schemas/descriptor_tensor_external_topic
- How to declare inbound and outbound boundaries with
import_topic<T>()andexport_topic<T>()usingholomq://URIs - How
TopicOptionsand itsendpoint_namegive each boundary a deployment-stable identity - Why the graph side binds automatically at run — no channel code in the graph
- How
plan.endpoint_catalog()serves as the single contract an external process sees - Where the line falls between what stays inside the graph and what is visible outside it
Declaring a boundary
An external topic is declared on the Graph during composition, alongside your
operators and flows. import_topic<T>() brings data into the graph from an
external producer; export_topic<T>() sends data out to an external
consumer. Both take an exact, nonempty holomq:// URI that identifies the
topic, plus boundary options:
C++
Python
Python supplies a registered payload token instead of a C++ template argument.
Topic options may be passed as keywords or as an owned hs.topics.TopicOptions
value:
The returned handles are typed: import_topic<T> returns an
ImportedTopic<T>, and export_topic<T> returns an ExportedTopic<T>. Each
exposes exactly one graph-side port that you wire like any operator port:
ImportedTopic<T>::output()— the graph-sideOutput<T>that feeds your operators. External data enters here, so from inside the graph it looks like a source output.ExportedTopic<T>::input()— the graph-sideInput<T>that your operators feed. Data leaves here, so from inside the graph it looks like a sink input.
Wiring uses ordinary add_flow(), with the same validation as any other edge:
C++
Python
Like the rest of the authoring surface, import_topic and export_topic
fail immediately when a declaration is invalid — a malformed URI, a bad option
combination, or a payload type the boundary cannot carry. C++ throws
holoscan::RuntimeError; Python raises the corresponding hs.HoloscanError
subclass under hs.errors. Compilation returns rejected plans, and run fails
closed.
The URI is an identity, not a location. The holomq:// URI is an exact,
case-sensitive topic identity — the scheme selects the interprocess
transport, and the string must match exactly on both sides. It is
descriptive: declaring it does not grant any process runtime attachment
authority, and there is no host/port to tweak inside it.
Ordinary inline payload capacity
Scalar, POD, and ordinary generated-schema topics use the fixed-inline HoloMQ family. The complete catalog data frame is limited to 32,768 bytes, including 24 bytes of catalog attribution. An ordinary generated schema also carries a 40-byte schema envelope. The maximum application capacities are therefore:
For a generated schema, the serialized bound comes from
generated_schema_max_serialized_size_v<T>; it is a capacity, not the size of
every message. Catalog construction and graph compilation reject a bound whose
complete framed size exceeds 32,768 bytes. The transport sizes each service’s
preallocated slots exactly for that framed bound, while each message still
records its actual serialized size.
Python resolves SDK schemas from the provider-owned schema catalog. The same topic API records the schema identity in the compiled endpoint catalog:
Descriptor-backed Tensor schemas use a separate envelope path. Their Tensor data remains handle-backed and does not count toward this ordinary inline limit.
Python external-topic authoring supports scalar, inline-schema, raw Tensor, and descriptor-backed Tensor payloads. Tensor topics require the matching payload token and complete boundary contracts described below.
Descriptor-backed schema topics
An application-owned FlatBuffer schema may carry one or more direct Tensor
fields together with ordinary metadata. Such a boundary needs a complete
field-qualified DescriptorTensorTopicContract<T> in addition to its common
TopicOptions:
Each field contract fixes its representation, finite shape/byte bounds, and
external transfer policy. The schema envelope carries ordinary fields and
Tensor descriptors; the Tensor data remains in separately retained or imported
backing. HoloMQ therefore does not serialize Tensor bytes into the FlatBuffer
or make transport cost depend on Tensor size. A bounded staging copy occurs
only when an authored kCopyIfNeeded policy permits it and the supplied
backing cannot be exported directly.
The
descriptor_tensor_external_topic
example shows a two-Tensor schema entering and leaving a Graph, including the
standalone catalog client for both directions.
Python Tensor topic authoring
Python uses the same Core Tensor boundary contract as C++. The wrapper owns the shape and stride arrays, so temporary Python lists remain valid through compile.
Descriptor-backed schemas take one contract per Tensor field. Field names come from the generated schema traits rather than a Python-side schema model.
The Graph-side operator port must use the exact descriptor schema. Missing or unknown field names are rejected during authoring, and an external peer whose Tensor transport profile differs from the catalog is rejected at attachment.
Naming the endpoint with TopicOptions
TopicOptions is the complete cold contract for one boundary. Its most
important field is endpoint_name — the explicit, deployment-stable name under
which the boundary appears in the endpoint catalog. External clients look
endpoints up by this name, so choose it the way you would choose a public
API name: stable across builds, meaningful to the peer team, never derived
from internal wiring details.
Beyond the name, TopicOptions carries the rest of the boundary contract, all
declared cold at compose time:
capture_time— aRequireCaptureTime{clock}requirement that inbound samples carry capture timestamps in a declared clock domain (see Time and clocks).clock_health— an optional ordered status-report contract. A monitored import can require a fresh healthy report before admitting data; standalone scalar, raw-Tensor, and descriptor-schema publishers use the samereport_clock_health()operation.attach— aTopicAttachPolicyselecting whether an absent external peer is acceptable. The default is optional: composition and graph start never wait for an external process.bounds— finite publisher/subscriber-side bounds (queue depths, claim counts), keeping the boundary bounded by construction like everything else in the runtime.- Tensor- and schema-specific fields (
tensor,schema,memory_conversion) that constrain what a Tensor or schema payload may look like at the boundary — see Payload type rules.
There is deliberately no backend option bag, raw handle, or device ordinal in
TopicOptions: the boundary contract stays backend-neutral and cold.
For descriptor-backed schema fields, kRequireExportable enforces the
declared Tensor storage kind and layout, but it does not constrain the exact
CUDA device ordinal. The exported buffer handle carries the actual device
identity needed for import. An imported CUDA-device Tensor, raw or a
descriptor field, therefore needs a copy-capable policy such as
kCopyIfNeeded: it adds the planned staging path and an explicit device, see
Binding a device for an imported device topic.
Compilation rejects an imported CUDA-device Tensor that uses
kRequireExportable.
Binding a device for an imported device topic
An imported topic whose Tensor storage is kCudaDevice or kCudaManaged, or
whose copy-capable storage is kPinnedHost (including a descriptor-schema
Tensor field with that storage kind), must be placed on an explicit CUDA
device. The compiler never selects device zero, and
an imported topic has no owning operator, so
bind_tensor_output_device cannot name it. Bind it by endpoint name through
CompileOptions::deployment instead:
The key is the endpoint_name given in TopicOptions, or the topic URI when
no endpoint name was set. In Python, use
options.deployment.bind_imported_topic_device(endpoint_name=..., device=hs.execution.DeviceId(0)).
The device lives in CompileOptions rather than TopicOptions because it is a
deployment decision, not part of the boundary contract: the same graph can run
on different GPUs without changing the topic declaration, and the bound device
is part of the plan identity. Compilation rejects an unknown or ambiguous
endpoint, an exported topic, a non-Tensor payload, an invalid device, a
duplicate binding, and a bound pinned-host Tensor that uses kRequireExportable
with PHYSICAL_TENSOR_DEVICE_BINDING_INVALID. A CUDA topic with a copy-capable
policy and no binding is rejected with IMPLICIT_DEVICE_ZERO_REJECTED, whose
diagnostic names the endpoint and this API. Host-memory topics take no binding.
An imported device topic must use a copy-capable transfer policy such as
kCopyIfNeeded. That policy still keeps zero-copy for storage the publisher can
export; it adds a staging pool, which is where the planned device and the
consumer’s CUDA stream come from. The default kRequireExportable policy
creates no staging pool. Compilation therefore rejects an imported CUDA Tensor,
raw or a descriptor field, that uses it with
PHYSICAL_TENSOR_LOGICAL_REQUIREMENTS_INVALID, whether or not a binding is
supplied, instead of compiling a plan that cannot deliver a sample. The check is
per field, so a copied field cannot hide an unstaged one.
The graph side binds automatically
Inside the graph, an external topic is just another port. Your operators
receive imported samples through their ordinary Input<T> ports and emit
through ordinary Output<T> ports; their contract() and compute() neither
know nor care that a sample crossed a process boundary. When you compile the
graph and start it, the runtime materializes the boundary transport for you:
You never construct a publisher, subscriber, or channel object on the graph side. Declaring the topic is the entire graph-side involvement; binding is the runtime’s job at run.
One graph, one vocabulary. Inside a graph, operators communicate through
typed ports (Input<T>/Output<T>) wired with add_flow() — the runtime
materializes the channels for you. The ChannelRuntime client API exists
only for standalone processes outside any graph. Never mix the two: using
ChannelRuntime inside an operator bypasses the compiled plan and its
guarantees.
The endpoint catalog: the boundary contract
When compile() accepts a graph, the resulting plan carries an endpoint
catalog describing every declared boundary — payload identity, endpoint
names, capacities, and queue policy. You reach it with
plan.endpoint_catalog():
The catalog is the only thing a graph shares with an external process. The
peer loads the same JSON (ChannelEndpointCatalog::load_json()) and looks up
endpoints by the endpoint_name you declared; it inherits no compiled plan,
no in-memory objects, and no transport handles. This makes “what crosses the
boundary” a single inspectable artifact: if you can read the catalog JSON, you
can see the complete external surface of the application. Because the catalog
serialization is canonical, the same boundary always produces the same bytes,
and either process may start first.
Two practical consequences follow:
- A differently built peer can attach. The peer binary does not need to link against your application or share its build — only the catalog.
- A stale catalog fails at attach, not at compile. If you change a boundary (type, name, bounds), regenerate and re-share the catalog; a peer holding the old JSON is rejected when it tries to attach.
What stays inside, what the outside sees
The boundary is asymmetric by design. Everything about how your pipeline works stays private to the graph; only the declared endpoints are visible outside.
An external process cannot observe or influence your internal topology. It sees a named, typed endpoint with declared bounds — nothing more. Conversely, your operators cannot tell an external peer from an internal one: an imported topic delivers samples through the same typed port machinery, subject to the same temporal contracts, as any internal flow.
This is why the boundary belongs in composing applications rather than in a networking chapter: you are not opening a socket, you are extending the typed dataflow contract of the graph to one carefully named edge.
Where to go deeper
This page covers declaring the boundary. Two advanced pages cover the far side:
- Running across processes — sharing the catalog with a peer process, generation-fenced producer restart, and cross-process Tensor transport: Interprocess channels.
- Writing the peer — the standalone
ChannelRuntimeclient, publishers and subscribers, and attach/retry patterns: Standalone channel clients.
For a complete working boundary — an external IMU producer feeding a graph
import and a graph export feeding an external control consumer — see the
camera_imu_control example in
Camera + IMU + control.
Recap
import_topic<T>("holomq://…")andexport_topic<T>("holomq://…")declare typed external boundaries during composition; invalid declarations throw, like all authoring.TopicOptions::endpoint_namegives each boundary its deployment-stable public name; the rest ofTopicOptionsdeclares the cold boundary contract.- Ordinary scalar, POD, and serialized-schema frames are bounded to 32,768 bytes including catalog attribution and any schema envelope.
- Inside the graph, a boundary is just a port: wire
imported.output()/exported.input()withadd_flow(), and the runtime binds it automatically at run. plan.endpoint_catalog()is the entire external contract — serialize it withsave_json()and share nothing else with peer processes.- Internal topology, contracts, and wiring stay private; external processes see only named, typed, bounded endpoints.
Next: Payload type rules — what types may cross ports and boundaries, and how POD, schema, and Tensor payloads differ.