Two systems that talk to each other must agree on their types. Canopy IDL is where those
types are defined — once — and from that single definition they flow outward: across the
wire, into the code that never crosses a boundary, and down into storage.
The types don't stop at the boundary
The moment two systems share an interface, they share its types. But the generated types are
ordinary C++ types — so they don't stay penned at the edge of the program. They are used
directly by the code that never marshals anything, which means there is no second set of
"domain" types to translate to and from. And because the same machinery that marshals a call
can serialise a value to bytes, the very same definitions can describe how data is stored.
One schema ends up governing how a system talks, computes, and persists.
This is the long game. The further the same definitions reach — past the
wire, into the logic, down into the database — the more the IDL stops being "the marshalling
format" and becomes the canonical type system the whole system is built on.
Imperative code, functional-grade plumbing
C++ is an imperative language, and hand-written boundary glue is imperative at its most
error-prone: mutable buffers, manual ordering, lifetimes juggled by hand — and subtly
different on every boundary. It is exactly the code most likely to hide a bug that surfaces on
one transport and not another.
The IDL is declarative. You state the contract — the methods, the types, the
direction of each parameter — and nothing about how it is carried. Declarative definitions
remove boilerplate by construction, and generating the boundary from one means the whole path
from proxy call to stub dispatch is produced for you: serialise the arguments, move the bytes,
deserialise them, invoke your implementation. Because that path is generated rather than typed
out by hand, it carries guarantees that functional programming usually needs a dedicated
language to enforce — while your runtime stays plain imperative C++:
Deterministic. The same contract generates the same path every time — no
drift between boundaries, no marshalling bug on one transport but not another.
Referentially transparent. Given the same inputs the boundary behaves the
same way, whichever transport or serialiser sits underneath it.
Free of incidental state. The generated layer does one thing — call your
implementation correctly — and keeps no hidden state of its own.
Total. Every declared method and type is handled exhaustively; there is no
half-written case left to be forgotten.
It borrows the principle behind functional core, imperative shell — separate the
deterministic part from the effectful part — and bakes it into the boundary itself: a
generated, deterministic contract layer kept cleanly apart from the imperative implementation it
wraps. You write your business logic the way you always have; the plumbing earns the
discipline.
Only two things are allowed to break that determinism, and both are
deliberate: logging and telemetry layered into the generated path for
observability, and the expected failures of an unreliable wire — a dropped
connection, a timeout — which the contract surfaces as errors rather than hiding. Everything
else is the same code, doing the same thing, on every boundary.
The anatomy of an interface definition
Canopy IDL reads like trimmed-down C++ with a few RPC-specific attributes in square brackets.
Here is each construct you will actually write, building from the data types up through
interfaces and remote references.
The syntax today is deliberately C++-flavoured — std::string,
std::vector, namespaces — because C++ is the primary runtime. A more
language-neutral surface is on the way, so ports to other languages need not inherit C++
idioms.
Namespaces and versioning
Namespaces group interfaces and structs, and can nest. Marking one [inline] makes
it transparent — callers write calculator::i_calculator, not
calculator::v1::i_calculator — so you can add a v2 beside
v1 later without breaking code compiled against the old version.
namespace calculator
{
[inline]namespace v1
{
interface i_calculator
{
error_code add(int a, int b, [out]int& result);
};
}
}
Enums
Enums carry an explicit underlying type, so the wire size is fixed across every language and
serialiser. enum class values are also how [tag=…] method routing is
declared.
Structs are plain data: members, defaults, vectors and maps, and nesting. They are ordinary
generated C++ types, so the same definition is used directly in your in-process code — there is
no separate DTO layer to translate to and from.
struct config
{
std::string name = "default";
int timeout = 30;
std::vector<std::string> tags;
std::map<std::string, int> name_to_id;
};
Optional and variant
Some types use Canopy's own wrappers, rpc::optional<T> and
rpc::variant<Ts…>. The generator deliberately rejects
std::optional and std::variant so that YAS binary, Protobuf, Nanopb,
and JSON all agree on one wire shape. In JSON an absent optional is simply omitted, and an
explicit null loads back as empty.
Interfaces are the RPC contracts: methods returning an error_code (a typedef you
define, conventionally 0 = OK), with parameter direction in brackets. They can
inherit — including multiple inheritance — carry [description] and
[status] documentation, and mark one-way methods with [post]. The full
attribute set is listed under the annotation vocabulary below.
[status=production, description="Calculator service"]interface i_calculator
{
error_code add(int a, int b, [out]int& result);
// fire-and-forget: returns immediately, ordered, no [out] params[post] error_code log_event(conststd::string& message);
};
Remote references: shared_ptr vs optimistic_ptr
An interface can be passed as a parameter or returned — that is how one service hands another a
callable object across a boundary. Two pointer types express the ownership:
rpc::shared_ptr<i_foo>
An owning handle. It extends C++ RAII across the network: while you hold it the remote
object is kept alive, and releasing it releases the remote reference. Use it for objects
whose lifetime you own.
rpc::optimistic_ptr<i_foo>
A non-owning, callable reference — a weak pointer that keeps a channel open without
pinning the remote object's lifetime. It breaks the ownership cycles that two-way
callbacks and long-lived services (a database, an LLM) would otherwise create.
interface i_host
{
// hand back an owning reference to a created object
error_code create_example([out]rpc::shared_ptr<i_example>& target);
// accept an object owned by the caller
error_code set_app(conststd::string& name,
[in]constrpc::shared_ptr<i_example> app);
};
Smart-pointer parameters are [in]or[out],
never [in, out]; and [post] methods cannot pass interface pointers at
all. Raw C++ pointers are rejected too — an address has no meaning in another address space.
Composing across files — #import vs #include
Almost always reach for #import: it makes the generator aware of types defined
elsewhere and reuses their marshalling, without regenerating them. #include
copy-pastes the file like the C preprocessor — handy for shared #defines, but a
source of duplicate-symbol errors if the same types land twice.
#import"shared/types.idl"// preferred: reference, don't regenerate#include"macros.idl"// copy-paste; use sparingly (#defines etc.)
What gets generated
Proxy
Caller-side typed C++ methods. Holds an rpc::shared_ptr<i_foo> or
rpc::optimistic_ptr<i_foo> — callers
invoke it exactly like a local object.
Stub
Callee-side dispatch layer. Deserialises parameters and calls the real implementation;
serialises the return values back.
Long-lived & callback interfaces
When a server and client both need to call each other, or a service outlives its
callers — a database, an LLM — a non-owning reference is needed.
rpc::optimistic_ptr<i_foo> is that: a callable weak pointer that
keeps the channel open without locking the remote object's lifetime. It breaks the
circular dependencies that two-way interfaces would otherwise create.
[post] one-way calls
Fire-and-forget methods. The caller sends and continues immediately — no reply is
awaited beyond local errors. The basis for high-rate feeds: price data, telemetry,
media frames, and streamed LLM tokens.
The definition also supports versioning via
[inline] namespace, so an interface can evolve without breaking callers
compiled against an earlier version.
The annotation vocabulary
Attributes in square brackets add RPC-specific meaning to a declaration without changing the
types themselves. They are how an interface carries documentation, lifecycle, dispatch
behaviour, and parameter direction — all of which the generator and the reflection metadata
pick up.
[description="…"]Human-readable documentation on a namespace, interface, method, struct, or member.
It travels into the reflection metadata that powers MCP and IDE tooling — the docs
you write stay attached to the type.
[status=…]Declares maturity: development, demo,
production, or deprecated. A production
interface is fingerprint-checked so its wire contract cannot silently drift;
deprecated marks it for removal.
[deprecated]On a method: emits C++ [[deprecated]] so every caller gets a
compile-time warning, without removing the method.
[post]A fire-and-forget, one-way method — returns immediately, expects no reply,
preserves send order. The basis for high-rate feeds.
[tag=…]Attaches a value carried through to i_marshaller::send() /
post(), so a transport can apply per-method special handling —
authentication, priority, routing, encryption, or audit.
[const]Marks a method const. It is an attribute because the parser does not accept a
trailing const after the parameter list.
[in] / [out]Parameter direction. [in] is the default; [out] returns
a value to the caller through a reference parameter.
[inline] namespaceInline-namespace versioning: an earlier version stays callable as the default while
a new one is added beside it.
member = valueDefault values for struct members and rpc::optional fields — an
omitted optional is filled with its default.
One definition also powers your tools
Beyond proxy and stub code, the same IDL emits machine-readable metadata:
the reflection schemas that let a service describe its own interfaces at runtime, and the
configuration schemas that wire services together. Both are derived from the one source of
truth, so the tools that consume them are never out of step with the running system.
MCP → LLMs
AI agents read the reflection metadata over MCP to discover what a service exposes
and how to call it correctly. The IDL becomes a contract an LLM can act on directly —
no bespoke, hand-written integration per interface.
Language services → IDEs
The same schemas drive editor language services: autocomplete, type checking, and
inline validation while authoring against an interface or its configuration — the
IntelliSense experience comes for free from the definition.
CMake integration
The CanopyGenerate CMake command takes an IDL file and produces
C++ proxy, stub, and serialisation glue for each requested format. Multiple
formats from the same IDL are supported.