Understanding Cap'n Proto: The Theory Behind Zero-Copy Serialization
Cap'n Proto is a data interchange format and RPC protocol that prioritizes speed through an extreme approach: it eliminates serialization and deserialization entirely at the data level. Unlike Protocol Buffers, FlatBuffers, or JSON, Cap'n Proto arranges data in memory exactly as it will be transmitted over the wire. This means the wire format is the memory format. When you receive a Cap'n Proto message, you can access its contents immediately without unpacking or allocating a separate data structure.
The core insight comes from how modern CPUs access memory. When you perform pointer dereferences and structure accesses, the CPU already does zero-copy traversal of in-memory layouts. Cap'n Proto simply standardizes that layout so it works across process boundaries, network links, and even different machines. The result is that reading a field from a Cap'n Proto message is not fundamentally slower than reading a field from a plain C struct — it is just a pointer offset calculation followed by a memory read.
The system consists of three layers: the data encoding (the binary format for structs, lists, and primitives), the schema language (a rich type system for defining your data structures and RPC interfaces), and the RPC protocol (a full-featured remote procedure call framework built on the same zero-copy principles). Each layer builds on the one below it, and you can use just the serialization format without touching RPC if your use case only needs data persistence or message passing.
Why Cap'n Proto Matters: The Practical Advantages
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Traditional serialization frameworks force you to pay a computational tax: you allocate a message, fill it with data, serialize it to bytes, then on the receiving end you parse those bytes back into allocated objects. This parse-allocate cycle burns CPU cycles and generates memory fragmentation. Cap'n Proto sidesteps this entirely. The benefits cascade through your system architecture:
- Zero parsing overhead — Data is immediately usable upon receipt. A message arriving from the network can be cast to its schema type and accessed directly, with no unpack step. This matters enormously in latency-sensitive systems like real-time trading engines, multiplayer game servers, and embedded device firmware.
- No memory allocation storms — Reading a Cap'n Proto message does not require heap allocations for individual fields or sub-objects. The message lives in a single contiguous buffer. This eliminates the GC pressure and allocator contention that plague high-throughput servers processing millions of messages per second.
- Cache-friendly memory layout — Struct fields are arranged in optimal order for CPU cache line utilization. Pointer fields (like nested structs or lists) are placed after inline data, and the format uses relative pointers that work regardless of where the message resides in memory. You can mmap a large file of Cap'n Proto messages and traverse it without any deserialization step.
- Schema evolution that doesn't break existing code — Like Protocol Buffers, Cap'n Proto supports adding new fields, removing optional fields, and evolving types without breaking backward or forward compatibility. Unknown fields are preserved during read-modify-write cycles.
- Language-agnostic RPC with promise pipelining — The RPC framework supports advanced patterns like passing method results directly as arguments to subsequent calls without round-trip latency. This is a genuinely unique capability that enables composing distributed operations efficiently.
Getting Started: Installation and Your First Schema
Before writing code, you need the Cap'n Proto compiler. On most systems, you can install it via your package manager or build from source:
# macOS
brew install capnp
# Ubuntu/Debian
sudo apt-get install capnproto
# Verify installation
capnp --version
If you need to build from source, clone the repository and use the standard CMake workflow. The compiler is the only required tool; language-specific runtime libraries are separate. For C++, you'll typically depend on the capnp and kj libraries (kj is the underlying C++ utility library that provides arena allocation, async I/O, and other primitives).
Create your first schema file. Let's model a simple address book entry:
# addressbook.capnp
@0xabcdef0123456789; # Unique schema identifier
struct Person {
id @0 : UInt32;
name @1 : Text;
email @2 : Text;
phones @3 : List(PhoneNumber);
struct PhoneNumber {
number @0 : Text;
type @1 : Type;
enum Type {
mobile @0;
home @1;
work @2;
}
}
}
struct AddressBook {
people @0 : List(Person);
}
The @0xabcdef0123456789 is a globally unique 64-bit identifier for this schema file. Every schema file must have one. When you evolve your schema, you keep this ID and increment a version number. The @0, @1, @2 annotations on fields are ordinal numbers that define the field's position in the wire format. They must never change once assigned. If you want to rename a field, keep its ordinal the same. If you want to remove a field, mark it as deprecated or simply stop writing to it — but never reuse its ordinal for a different field.
Schema Language in Depth: Types, Unions, and Interfaces
The Cap'n Proto schema language is expressive enough to model complex domain data while remaining simple to learn. Beyond basic structs and lists, it offers several powerful constructs that deserve detailed attention.
Built-in Types
The primitive types are exactly what you'd expect, with a few notable additions:
# Primitive types
Bool # true or false
Int8, Int16, Int32, Int64 # Signed integers
UInt8, UInt16, UInt32, UInt64 # Unsigned integers
Float32, Float64 # IEEE floating point
Text # UTF-8 string (always valid UTF-8)
Data # Arbitrary binary blob
Void # Zero bits, used in unions
AnyPointer # Untyped pointer, for dynamic data
The Text type is guaranteed valid UTF-8; the compiler and runtime enforce this. Data is raw bytes with no encoding constraints. AnyPointer lets you store values whose types aren't known until runtime — it's the escape hatch for truly dynamic data, but it bypasses schema validation, so use it sparingly.
Lists, Maps, and Generics
struct Inventory {
items @0 : List(Item);
metadata @1 : Map(Text, Text); # Key-value pairs
taggedItems @2 : List(Item) @Tagged; # Tagged union list
}
# Generic structs
struct Pair(T, U) {
first @0 : T;
second @1 : U;
}
# Usage: Pair(Text, Int32) creates a typed pair
Lists in Cap'n Proto are not pointer arrays — they are contiguous blocks of elements, just like C arrays. A List(Int32) is a contiguous sequence of 32-bit integers on the wire and in memory. Lists of structs store the structs inline, one after another. This contiguous layout makes iteration extremely cache-friendly. Map(Text, Text) is syntactic sugar for a list of key-value pair structs with a specific layout; the compiler generates convenience methods for lookups.
Unions for Polymorphism
struct Shape {
union {
circle @0 : Circle;
rectangle @1 : Rectangle;
polygon @2 : Polygon;
empty @3 : Void; # Explicit "none" variant
}
color @4 : Color; # Fields after union are allowed
}
struct Circle {
radius @0 : Float64;
}
Unions are discriminant-based: exactly one variant is active at a time. Fields placed before the union share memory with the union discriminant, but fields after the union (like color above) are stored separately and accessible regardless of which variant is active. This is a clean pattern for tagged data: put the discriminator and variant-specific data in the union, and put common fields after it.
Interfaces for RPC
interface FileSystem {
open @0 (path : Text) -> (handle : FileHandle);
read @1 (handle : FileHandle, offset : Int64, size : Int32) -> (data : Data);
write @2 (handle : FileHandle, offset : Int64, data : Data) -> ();
close @3 (handle : FileHandle) -> ();
}
interface FileHandle {
# FileHandle is a capability — a remote object reference
read @0 (offset : Int64, size : Int32) -> (data : Data);
seek @1 (position : Int64) -> ();
getSize @2 () -> (size : Int64);
}
Interfaces define methods with named parameters and return types. The arrow -> separates input parameters from return values. Methods can have zero return values (indicated by empty parentheses after the arrow). When you call open on a FileSystem, the returned FileHandle is not just data — it's a live capability that lets you invoke methods on a remote object. This is the foundation of Cap'n Proto's RPC model.
Compiling Schemas and Code Generation
The capnp compiler translates your schema file into language-specific source code. For C++, the command is straightforward:
# Compile schema to C++ header and source
capnp compile -oc++ addressbook.capnp
# Output files:
# addressbook.capnp.h (header)
# addressbook.capnp.c++ (source)
The -o flag specifies the output language. Available backends include c++, python, rust, java, c-sharp, go, and several others contributed by the community. The generated C++ code includes type-safe builder and reader classes for every struct, along with RPC stubs and servers if your schema defines interfaces.
In larger projects, you'll want to integrate schema compilation into your build system. For CMake-based C++ projects:
# CMakeLists.txt snippet
find_package(CapnProto REQUIRED)
capnp_generate_cpp(
SCHEMA_SOURCES
SCHEMA_HEADERS
schemas/addressbook.capnp
schemas/filesystem.capnp
)
add_executable(my_app
main.cpp
${SCHEMA_SOURCES}
)
target_link_libraries(my_app
PRIVATE
capnp::capnp
capnp::capnp-rpc
capnp::kj
)
The capnp_generate_cpp CMake function (provided by the Cap'n Proto CMake config) automatically runs the compiler and adds the generated files to your build. The generated headers are placed in your build directory; include them with the .capnp.h extension.
Serialization and Deserialization: Building and Reading Messages
Now we move from theory to practice. This section demonstrates the complete lifecycle: allocating a message, populating it, and reading it back — all without a serialize/parse step.
Building a Message
Cap'n Proto messages live in arena-allocated buffers. The capnp::MallocMessageBuilder manages a heap-allocated arena, growing it as needed. For embedded or performance-critical contexts, you can use capnp::FlatArrayMessageReader on pre-allocated memory or mmap'd files.
#include "addressbook.capnp.h"
#include <iostream>
#include <fstream>
void build_address_book() {
// Create a message builder with a heap arena
capnp::MallocMessageBuilder message;
// Get the root object as an AddressBook builder
// The root is always a struct, never a list or primitive
AddressBook::Builder addressBook = message.initRoot();
// Get the people list (initially empty, need to allocate)
auto people = addressBook.initPeople(2); // Pre-allocate for 2 entries
// First person
Person::Builder person1 = people[0];
person1.setId(1001);
person1.setName("Alice Johnson");
person1.setEmail("alice@example.com");
// Build phone list for person1
auto phones1 = person1.initPhones(1);
phones1[0].setNumber("+1-555-0101");
phones1[0].setType(Person::PhoneNumber::Type::MOBILE);
// Second person
Person::Builder person2 = people[1];
person2.setId(1002);
person2.setName("Bob Smith");
person2.setEmail("bob@example.com");
auto phones2 = person2.initPhones(2);
phones2[0].setNumber("+1-555-0200");
phones2[0].setType(Person::PhoneNumber::Type::HOME);
phones2[1].setNumber("+1-555-0201");
phones2[1].setType(Person::PhoneNumber::Type::WORK);
// Serialize to bytes (this is just a memcpy of the arena)
// Write to a file or send over a socket
auto flatMessage = capnp::messageToFlatArray(message);
std::ofstream out("addressbook.bin", std::ios::binary);
out.write(
reinterpret_cast(flatMessage.asBytes().begin()),
flatMessage.asBytes().size()
);
out.close();
std::cout << "Wrote address book with " << people.size() << " entries\n";
}
Notice what's absent: no SerializeToString() or SerializeToArray() call that does work. The messageToFlatArray function simply copies the arena's contiguous memory region into a flat buffer. The builder's in-memory layout is the wire format. This is the fundamental difference from Protocol Buffers or JSON libraries where serialization involves walking the object graph and encoding it.
Reading a Message
Reading is equally direct. The FlatArrayMessageReader takes a pointer to a contiguous byte buffer and lets you access it as typed data immediately:
void read_address_book(const std::string& filename) {
// Read file into memory
std::ifstream in(filename, std::ios::binary | std::ios::ate);
size_t size = in.tellg();
in.seekg(0, std::ios::beg);
std::vector buffer(size);
in.read(buffer.data(), size);
in.close();
// Create a reader that points directly at the buffer
// No parsing, no allocations — the buffer IS the data structure
capnp::FlatArrayMessageReader reader(
kj::arrayPtr(reinterpret_cast(buffer.data()),
buffer.size() / sizeof(capnp::word))
);
// Get the root AddressBook reader
AddressBook::Reader addressBook = reader.getRoot();
// Iterate people — each access is just pointer arithmetic
for (Person::Reader person : addressBook.getPeople()) {
std::cout << "Person ID: " << person.getId() << "\n";
std::cout << "Name: " << person.getName().cStr() << "\n";
std::cout << "Email: " << person.getEmail().cStr() << "\n";
for (auto phone : person.getPhones()) {
std::cout << " Phone: " << phone.getNumber().cStr() << "\n";
switch (phone.getType()) {
case Person::PhoneNumber::Type::MOBILE:
std::cout << " (mobile)\n";
break;
case Person::PhoneNumber::Type::HOME:
std::cout << " (home)\n";
break;
case Person::PhoneNumber::Type::WORK:
std::cout << " (work)\n";
break;
}
}
std::cout << "---\n";
}
}
The critical detail: reader.getRoot does not parse the buffer. It casts the memory region to the expected layout, validates pointer bounds, and returns a reader that interprets offsets relative to the buffer start. Field access like person.getName() follows a pointer stored at a known offset in the struct, reads the string length, and returns a view of the bytes — still no allocation. The .cStr() call gives you a const char* pointing directly into the buffer.
Working with Nested Structures and AnyPointer
Sometimes you need to handle data whose exact type isn't known at schema definition time. AnyPointer fields let you store arbitrary Cap'n Proto objects and interrogate them at runtime:
void dynamic_field_example() {
capnp::MallocMessageBuilder message;
auto root = message.initRoot(message.getRoot());
// Store an integer
root.get("value").setInt32(42);
// Store a text string
root.get("label").setText("dynamic content");
// Store a nested struct (you can build it inline)
auto nested = root.get("nested").initStruct();
nested.get("x").setFloat64(3.14159);
// Read back dynamically
capnp::FlatArrayMessageReader reader(
capnp::messageToFlatArray(message).asPtr()
);
auto dynamicRoot = reader.getRoot(
reader.getRoot().getAs(capnp::Schema::from())
);
// Check what's inside
for (auto field : dynamicRoot.getSchema().getFields()) {
std::cout << "Field: " << field.getProto().getName() << "\n";
}
}
Dynamic access loses type safety and some performance, but it's essential for generic tools like schema inspectors, protocol bridges, or data migration utilities that operate on messages without compile-time knowledge of their schemas.
Implementing RPC with Cap'n Proto
The RPC framework is where Cap'n Proto truly distinguishes itself from other serialization systems. It's not just a message format with some RPC bolted on — it's a carefully designed distributed object system with capabilities, promise pipelining, and automatic flow control.
The RPC Model: Capabilities and Promises
In Cap'n Proto RPC, an interface is a capability — a permission to call methods on a remote object. When you obtain an interface reference (say, from a method return), you're not getting data about the object; you're getting a live connection to it. The runtime manages the transport, keeping track of outstanding calls and their responses. Promise pipelining means you can chain calls without waiting for intermediate results:
# Schema snippet
interface FileServer {
getRoot @0 () -> (root : Directory);
}
interface Directory {
listFiles @0 () -> (files : List(FileInfo));
open @1 (name : Text) -> (file : File);
}
interface File {
read @0 (offset : Int64, size : Int32) -> (data : Data);
getSize @1 () -> (size : Int64);
}
With promise pipelining, you can call getRoot() and, in the same client code block, immediately call open("config.txt") on the result — before the getRoot response has even arrived. The RPC runtime sends both calls together and wires the result of the first directly into the argument of the second on the server side. This eliminates a full network round-trip per sequential step.
Building an RPC Server
Let's implement a complete file server. The server side requires implementing the interface methods and setting up the RPC infrastructure:
#include "filesystem.capnp.h"
#include <capnp/ez-rpc.h>
#include <iostream>
#include <fstream>
#include <filesystem>
// Implement the File interface
class FileImpl final : public File::Server {
public:
kj::Promise read(ReadContext context) override {
auto params = context.getParams();
int64_t offset = params.getOffset();
int32_t size = params.getSize();
// In a real server, you'd read from an actual file handle
// Here we simulate with a stored buffer
auto result = context.getResults();
std::string content = "File contents from offset " + std::to_string(offset);
result.setData(kj::heapString(content.substr(0, std::min(size, (int32_t)content.length()))));
return kj::READY_NOW; // Synchronous completion
}
kj::Promise getSize(GetSizeContext context) override {
context.getResults().setSize(1024); // Example fixed size
return kj::READY_NOW;
}
};
// Implement the Directory interface
class DirectoryImpl final : public Directory::Server {
private:
std::filesystem::path basePath_;
public:
DirectoryImpl(std::filesystem::path basePath) : basePath_(std::move(basePath)) {}
kj::Promise listFiles(ListFilesContext context) override {
auto results = context.getResults();
auto fileList = results.initFiles(0); // Start empty
// Enumerate actual filesystem
try {
for (const auto& entry : std::filesystem::directory_iterator(basePath_)) {
if (entry.is_regular_file()) {
auto fileInfo = fileList.add();
fileInfo.setName(entry.path().filename().string());
fileInfo.setSize(entry.file_size());
}
}
} catch (const std::filesystem::filesystem_error& e) {
context.getResults().setError(kj::str("Directory listing failed: ", e.what()));
}
return kj::READY_NOW;
}
kj::Promise open(OpenContext context) override {
auto params = context.getParams();
std::string name = params.getName();
// Create a new File capability and return it
// The client can immediately call read() on this capability
context.getResults().setFile(kj::heap());
return kj::READY_NOW;
}
};
// Implement the FileServer interface
class FileServerImpl final : public FileServer::Server {
private:
std::filesystem::path rootPath_;
public:
FileServerImpl(std::filesystem::path rootPath) : rootPath_(std::move(rootPath)) {}
kj::Promise getRoot(GetRootContext context) override {
context.getResults().setRoot(kj::heap(rootPath_));
return kj::READY_NOW;
}
};
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " \n";
std::cerr << "Example: " << argv[0] << " unix:/tmp/fileserver.sock\n";
return 1;
}
// ez-rpc handles the event loop, listening, and capability export
capnp::EzRpcServer server(
kj::heap(std::filesystem::current_path()),
argv[1]
);
std::cout << "File server listening on " << argv[1] << "\n";
// Wait forever (event loop runs in background threads)
kj::NEVER_DONE.wait(server.getWaitScope());
return 0;
}
The server implementation pattern is consistent: inherit from the generated Server class for each interface, override each method's handler, and fill in the results builder. The kj::heap<...>() calls create reference-counted objects that the RPC runtime manages. When you return a capability from a method (like setFile(kj::heap<FileImpl>())), the runtime automatically wires it so the client can call methods on it.
Building an RPC Client
The client side connects to the server, obtains capabilities, and makes calls — potentially with promise pipelining:
#include "filesystem.capnp.h"
#include <capnp/ez-rpc.h>
#include <iostream>
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " \n";
return 1;
}
// Connect to server
capnp::EzRpcClient client(argv[1]);
auto& waitScope = client.getWaitScope();
// Get the FileServer capability
FileServer::Client fileServer = client.getMain();
// Call getRoot() — this returns immediately with a pipelined promise
auto rootPromise = fileServer.getRootRequest().send();
// Pipeline: call open() on the root result BEFORE getRoot() completes
auto filePromise = rootPromise.getRoot().openRequest();
filePromise.setName("example.txt");
auto fileOpenResult = filePromise.send();
// Pipeline again: call getSize() on the file BEFORE open() completes
auto sizePromise = fileOpenResult.getFile().getSizeRequest().send();
// Now wait for the final result — all intermediate calls were sent together
auto sizeResult = sizePromise.wait(waitScope);
std::cout << "File size: " << sizeResult.getSize() << "\n";
// Demonstrate listing files
auto listPromise = fileServer.getRootRequest().send()
.getRoot().listFilesRequest().send()
.wait(waitScope);
for (auto fileInfo : listPromise.getFiles()) {
std::cout << "File: " << fileInfo.getName().cStr()
<< " (" << fileInfo.getSize() << " bytes)\n";
}
return 0;
}
The promise pipelining is visible in the chained calls. rootPromise.getRoot().openRequest() doesn't block waiting for getRoot() to finish — it creates a pending RPC call whose input depends on a future result. The runtime sends getRoot() and open("example.txt") together, and the server processes them as a pipeline. Only at sizePromise.wait(waitScope) do we block until the final answer arrives. This pattern turns sequential RPC chains into single-round-trip operations.
Handling Errors in RPC
Cap'n Proto RPC integrates error handling through KJ's exception system. Methods can fail, and failures propagate as exceptions on the client side:
// Server side: throwing an exception
kj::Promise open(OpenContext context) override {
auto params = context.getParams();
std::string name = params.getName();
if (name.find("..") != std::string::npos) {
// Reject path traversal attempts
KJ_FAIL_REQUIRE("Path traversal detected in filename");
}
if (!std::filesystem::exists(basePath_ / name)) {
KJ_FAIL_REQUIRE("File not found: " + name);
}
context.getResults().setFile(kj::heap(basePath_ / name));
return kj::READY_NOW;
}
// Client side: catching errors
try {
auto file = fileServer.getRootRequest().send()
.getRoot().openRequest().setName("nonexistent.txt").send()
.wait(waitScope);
} catch (const kj::Exception& e) {
std::cerr << "RPC error: " << e.getDescription().cStr() << "\n";
// Handle gracefully — the capability is invalid now
}
KJ exceptions carry rich metadata including the failure reason, a stack trace (in debug builds), and optionally a custom error code. They integrate with the event loop so that a failed promise automatically propagates the error through all dependent promises.
Best Practices for Production Cap'n Proto Systems
Schema Design Principles
Assign ordinals deliberately. Never change a field's ordinal once your schema is deployed. If you need to rename a field, keep its ordinal. If you need to remove a field, either leave a gap (marked with a comment) or use the reserved keyword. Reusing an ordinal for a different field will corrupt data for anyone using an older schema version.
# Evolving a schema correctly
struct UserProfile {
username @0 : Text;
email @1 : Text;
# Field @2 was "legacyField" — removed in v2, do not reuse
displayName @3 : Text; # New field, new ordinal
avatarUrl @4 : Text;
joinedAt @5 : Int64; # Unix timestamp, added in v3
}
Prefer struct fields over AnyPointer. AnyPointer bypasses type checking and makes your data opaque to tools. Reserve it for truly polymorphic containers where the type varies at runtime in ways your schema cannot express. Even then, consider using unions with a finite set of known variants first.
Use unions for mutually exclusive data. If exactly one of several fields will be set at a time, use a union. This saves memory (the fields share storage) and makes the exclusivity explicit in the type system. The compiler enforces that only one variant is active.
Keep structs focused and shallow. Deeply nested structures with many levels of inline structs can create large messages that are expensive to traverse. While Cap'n Proto handles nesting efficiently, designing flat, focused structs improves cache locality and makes your schema easier to understand.
Performance Optimization
Use arena allocation strategically. The MallocMessageBuilder uses a doubling strategy for arena growth. If you know the approximate message size in advance, allocate a capnp::FlatArrayMessageBuilder with a pre-sized buffer to avoid reallocations:
// Pre-allocate a 4KB buffer
capnp::FlatArrayMessageBuilder builder(4096);
auto root = builder.initRoot();
// Fill in fields — no reallocations if it fits in 4KB
Reuse message builders. If you're sending many messages in a loop (e.g., a game server sending state updates), reuse a single MallocMessageBuilder and reset it between uses. This recycles the arena memory and avoids repeated allocation overhead:
capnp::MallocMessageBuilder reusableMessage;
while (serverRunning) {
// Reset to empty state (reuses existing arena memory)
reusableMessage.reset();
auto root = reusableMessage.initRoot();
// Populate with current state...
sendToClients(capnp::messageToFlatArray(reusableMessage));
}
Read messages without copying when possible. If you receive data into a buffer that persists (e.g., a ring buffer in a network library), create a FlatArrayMessageReader that points directly at the received bytes without copying them into a separate arena. For mmap'd files, point the reader at the mapped region and traverse the entire file as Cap'n Proto structures with zero copy overhead.
RPC Architecture Patterns
Design for promise pipelining. When designing RPC interfaces, think about call chains. If a client typically calls getFoo() and then immediately calls a method on the returned Foo, structure your interfaces so the client can pipeline the second call without waiting. Avoid designs that force the client to process intermediate results before knowing what to call next.
Manage capability lifetimes explicitly. Capabilities (interface references) consume resources on both client and server. When you're done with a capability, release it. The RPC runtime handles garbage collection of unreferenced capabilities, but explicit release gives predictable cleanup:
// Explicitly release a capability
auto file = directory.openRequest().setName("temp.txt").send().wait(waitScope).getFile();
// ... use the file ...
file = nullptr; // Release the capability, server can clean up
Use streaming for large data transfers. For multi-gigabyte file transfers or continuous data streams, Cap'n Proto's RPC supports streaming methods (declared with stream keyword in the schema). Streaming lets you send data in chunks with backpressure, rather than allocating a single enormous message:
# Schema with streaming
interface FileTransfer {
upload @0 (metadata : FileMetadata) -> (stream : Data) -> (checksum : Data);
# Client sends metadata, then streams data chunks, finally server returns checksum
}