CIDL is Canopy’s canonical interface definition language. Its language-neutral syntax describes types, interfaces and results independently of the implementation language. C++ is the first runtime implementation; the canonical contract provides a foundation for further language bindings.
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 types in the target language — so they can also be used inside the program. In the current C++ projection 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.
Canonical contracts
CIDL is for the interface, whichever language you use.
Start with a .cidl file to describe a portable contract. You can specify values, collections, interfaces and explicit result types without learning C++ containers, pointers or declaration syntax.
The contract uses types such as int32 and string. A language projection supplies the native representation while preserving the declared wire meaning.
Explicit interfaces and results
The example declares a versioned interface, an error domain and named response values. Those are contract decisions, distinct from how a particular language implements the operation.
Canonical identity
The canonical model supplies version 4 contract fingerprints. Language-specific API spelling does not independently redefine the wire contract.
A path for existing C++ contracts
Existing C++-dialect IDL can remain authoritative or be paired with a reviewed canonical contract. Generation checks their wire-significant meanings during migration.
C++ is the first implementation language, with supported generated JavaScript clients available today. Additional language implementations are planned; authoring a language-neutral contract does not imply that every runtime binding already exists.
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 C++-dialect interface definition
The following examples use Canopy’s C++-specific IDL surface. For portable authoring without C++ syntax, use the canonical CIDL example above.
The current Canopy IDL reads like trimmed-down C++ with a few RPC-specific attributes in square
brackets. Here is each construct used by the current format, building from data types up through
interfaces and remote references.
These examples use C++-flavoured spellings such as
std::string and std::vector. The
language-independent surface described above will retain
the same wire type system while making language-specific native types explicit overrides.
Namespaces and versioning
Namespaces group interfaces and structs, and can nest. We strongly recommend putting every durable
contract inside an explicit numbered namespace such as v1, and using that version in
the fully qualified type name. Callers then state exactly which contract they were built against,
while v1 and v2 can coexist during migration.
Explicit version names make compatibility boundaries visible in code, generated
bindings, fingerprints, diagnostics, and policy. They also prevent a new default version from
silently changing the meaning of an existing unqualified interface name.
namespace calculator
{
namespace v1
{
interface i_calculator
{
error_code add(int a, int b, [out]int& result);
};
}
}
Enums
Enums carry an explicit underlying type, keeping their numeric domain consistent across generated
bindings and serialisers. Enum 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 interface"]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 runtime component 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 objects (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 an interface implementation
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.
Use explicit versioned namespaces such as
v1 from the first published contract. Add v2 beside it when a breaking
change is needed, keep both callable during migration, and retire the old version deliberately.
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(). The streaming transport uses tags for one-way traffic handling, and
custom components can interpret them for application-specific dispatch or telemetry.
[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.
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:
reflection schemas that let a running component describe its interfaces, and configuration
schemas that bind exchanged interfaces to transports and addresses. 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 which interfaces a component
exposes and how to call them correctly. The IDL becomes a contract an LLM can act on
directly — no bespoke, hand-written integration per interface.
CIDL authoring tools
The current VS Code CIDL extension provides syntax highlighting and basic editing support. Formatting tools support contract authoring; autocomplete, diagnostics and a full language server are outside the current extension’s scope.
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.