IDL

The IDL is a type system, not just a wire format.

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.

IDL types one definition Communication both sides share the exact same types, generated — never hand-synced Computation used directly in ordinary code — no separate domain-object layer Persistence serialised to storage with the same schema and versioning

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.

enum encoding : uint64_t
{
    yas_binary = 1,
    yas_json = 8,
    protocol_buffers = 16
};

Structs

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.

struct request_options
{
    rpc::optional<std::string> label;
    rpc::variant<int32_t, std::string> selector;
};

Interfaces

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(const std::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(const std::string& name,
                       [in] const rpc::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] namespace Inline-namespace versioning: an earlier version stays callable as the default while a new one is added beside it.
member = value Default 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.

CanopyGenerate(
  foo
  foo/foo.idl
  ${CMAKE_CURRENT_SOURCE_DIR}
  ${CMAKE_BINARY_DIR}/generated
  ""
  yas_binary
  yas_json
  protocol_buffers
  include_paths ${CMAKE_CURRENT_SOURCE_DIR}/.
  install_dir   ${GENERATED_INSTALL_DIR})