Repository: CQCL/hugr
Pull request #1958: feat!: Generic HUGR serialization with envelopes
Author: login: ss2165
Created: 2025-03-10T09:39:14Z | Merged: 2025-03-14T15:53:30Z

Adds a "hugr envelope" serialization format composed by a 10-byte header and some payload.

The header contains a magic string at the start, some flags, and a format identifier.
The payload can then be encoded using either the old package json encoding, or hugr-module binary format. There's also a flag to do zstd compression.

I added new methods to `Package` both on the rust and python API, and deprecated the json-based ones. We should be able to add new serialization options and formats in the future without changing this new API.

`hugr-cli` now expects these envelopes by default. Since we still support hugr jsons for some use cases (e.g. non-module rooted hugrs that specify rewrites) I added a `--hugr-json` flag to read these jsons instead.

drive-by: Fix #1874 

BREAKING CHANGE: hugr-cli now expects hugr envelopes. Use `--hugr-json` to load raw hugrs.

---

Override for python CHANGELOG (to make them non-breaking)
BEGIN_COMMIT_OVERRIDE
feat: Generic HUGR serialization with envelopes
END_COMMIT_OVERRIDE

Linked issue #1874: `Package::to_json_file` is wrong by inspection
It opens the file in read mode then writes to it. No users(or tests), but it's a landmine.

Locally I now have:
```
    /// Write the Package into a json file.
    pub fn to_json_file(&self, path: impl AsRef<Path>) -> Result<(), PackageEncodingError> {
        let file = fs::OpenOptions::new().write(true).truncate(true).create(true).open(path)?;
        let writer = io::BufWriter::new(file);
        self.to_json_writer(writer)
    }
```

Diff:
diff --git a/Cargo.lock b/Cargo.lock
index e1d1bcdfd6..f49a5b5c4d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -355,6 +355,8 @@ version = "1.2.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af"
 dependencies = [
+ "jobserver",
+ "libc",
  "shlex",
 ]
 
@@ -741,9 +743,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
 
 [[package]]
 name = "erased-serde"
-version = "0.4.5"
+version = "0.4.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24e2389d65ab4fab27dc2a5de7b191e1f6617d1f1c8855c0dc569c94a4cbb18d"
+checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7"
 dependencies = [
  "serde",
  "typeid",
@@ -1134,9 +1136,11 @@ dependencies = [
  "serde_json",
  "serde_yaml",
  "smol_str",
+ "static_assertions",
  "strum",
  "thiserror 2.0.12",
  "typetag",
+ "zstd",
 ]
 
 [[package]]
@@ -1470,9 +1474,9 @@ dependencies = [
 
 [[package]]
 name = "inventory"
-version = "0.3.19"
+version = "0.3.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "54b12ebb6799019b044deaf431eadfe23245b259bba5a2c0796acec3943a3cdb"
+checksum = "ab08d7cd2c5897f2c949e5383ea7c7db03fb19130ffcfbf7eda795137ae3cb83"
 dependencies = [
  "rustversion",
 ]
@@ -1533,6 +1537,15 @@ version = "1.0.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
 
+[[package]]
+name = "jobserver"
+version = "0.1.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "js-sys"
 version = "0.3.77"
@@ -1912,6 +1925,12 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
 
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
 [[package]]
 name = "plotters"
 version = "0.3.7"
@@ -2520,6 +2539,12 @@ version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
 
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
 [[package]]
 name = "strsim"
 version = "0.11.1"
@@ -2756,9 +2781,9 @@ checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
 
 [[package]]
 name = "typeid"
-version = "1.0.2"
+version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e13db2e0ccd5e14a544e8a246ba2312cd25223f616442d7f2cb0e3db614236e"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
 
 [[package]]
 name = "typenum"
@@ -3324,3 +3349,31 @@ dependencies = [
  "quote",
  "syn",
 ]
+
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3051792fbdc2e1e143244dc28c60f73d8470e93f3f9cbd0ead44da5ed802722"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.14+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fb060d4926e4ac3a3ad15d864e99ceb5f343c6b34f5bd6d81ae6ed417311be5"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
diff --git a/Cargo.toml b/Cargo.toml
index a84e37fa32..58081c14c1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,7 +22,12 @@ license = "Apache-2.0"
 # authors
 
 [workspace.lints.rust]
-unexpected_cfgs = { level = "warn", check-cfg = ['cfg(ci_run)'] }
+unexpected_cfgs = { level = "warn", check-cfg = [
+    # Set by our CI
+    'cfg(ci_run)',
+    # Set by codecov
+    'cfg(coverage,coverage_nightly)',
+] }
 
 missing_docs = "warn"
 [workspace.lints.clippy]
@@ -53,10 +58,12 @@ proptest-derive = "0.5.0"
 regex = "1.9.5"
 regex-syntax = "0.8.3"
 rstest = "0.24.0"
+semver = "1.0.26"
 serde = "1.0"
 serde_json = "1.0.140"
 serde_yaml = "0.9.19"
 smol_str = "0.3.1"
+static_assertions = "1.1.0"
 strum = "0.27.0"
 thiserror = "2.0.12"
 typetag = "0.2.20"
@@ -68,7 +75,7 @@ assert_fs = "1.1.1"
 predicates = "3.1.0"
 indexmap = "2.3.0"
 fxhash = "0.2.1"
-bumpalo = { version = "3.16.0" }
+bumpalo = "3.16.0"
 pathsearch = "0.2.0"
 base64 = "0.22.1"
 ordered-float = "5.0.0"
@@ -76,6 +83,7 @@ pest = "2.7.12"
 pest_derive = "2.7.12"
 pretty = "0.12.3"
 pretty_assertions = "1.4.1"
+zstd = "0.13.2"
 
 [profile.dev.package]
 insta.opt-level = 3
diff --git a/hugr-cli/src/hugr_io.rs b/hugr-cli/src/hugr_io.rs
new file mode 100644
index 0000000000..14e8588c21
--- /dev/null
+++ b/hugr-cli/src/hugr_io.rs
@@ -0,0 +1,85 @@
+//! Input/output arguments for the HUGR CLI.
+
+use clio::Input;
+use hugr::envelope::read_envelope;
+use hugr::extension::ExtensionRegistry;
+use hugr::package::Package;
+use hugr::{Extension, Hugr};
+use std::io::BufReader;
+use std::path::PathBuf;
+
+use crate::CliError;
+
+/// Arguments for reading a HUGR input.
+#[derive(Debug, clap::Args)]
+pub struct HugrInputArgs {
+    /// Input file. Defaults to `-` for stdin.
+    #[arg(value_parser, default_value = "-", help_heading = "Input")]
+    pub input: Input,
+
+    /// No standard extensions.
+    #[arg(
+        long,
+        help_heading = "Input",
+        help = "Don't use standard extensions when validating hugrs. Prelude is still used."
+    )]
+    pub no_std: bool,
+    /// Extensions paths.
+    #[arg(
+        short,
+        long,
+        help_heading = "Input",
+        help = "Paths to serialised extensions to validate against."
+    )]
+    pub extensions: Vec<PathBuf>,
+    /// Read the input as a HUGR JSON file instead of an envelope.
+    ///
+    /// This is a legacy option for reading old HUGR files.
+    #[clap(long, help_heading = "Input")]
+    pub hugr_json: bool,
+}
+
+impl HugrInputArgs {
+    /// Read a hugr envelope from the input and return the package encoded
+    /// within.
+    ///
+    /// If [HugrInputArgs::hugr_json] is `true`, [HugrInputArgs::get_hugr] should be called instead as
+    /// reading the input as a package will fail.
+    pub fn get_package(&mut self) -> Result<Package, CliError> {
+        let extensions = self.load_extensions()?;
+        let buffer = BufReader::new(&mut self.input);
+        let (_, pkg) = read_envelope(buffer, &extensions)?;
+        Ok(pkg)
+    }
+
+    /// Read a hugr JSON file from the input.
+    ///
+    /// If [HugrInputArgs::hugr_json] is `false`, [HugrInputArgs::get_package] should be called instead as
+    /// reading an input envelope as a HUGR json will fail.
+    pub fn get_hugr(&mut self) -> Result<Hugr, CliError> {
+        let extensions = self.load_extensions()?;
+        let mut buffer = BufReader::new(&mut self.input);
+        let hugr = Hugr::load_json(&mut buffer, &extensions)?;
+        Ok(hugr)
+    }
+
+    /// Return a register with the selected extensions.
+    ///
+    /// This includes the standard extensions if [HugrInputArgs::no_std] is `false`,
+    /// and the extensions loaded from the paths in [HugrInputArgs::extensions].
+    pub fn load_extensions(&self) -> Result<ExtensionRegistry, CliError> {
+        let mut reg = if self.no_std {
+            hugr::extension::PRELUDE_REGISTRY.to_owned()
+        } else {
+            hugr::std_extensions::STD_REG.to_owned()
+        };
+
+        for ext in &self.extensions {
+            let f = std::fs::File::open(ext)?;
+            let ext: Extension = serde_json::from_reader(f)?;
+            reg.register_updated(ext);
+        }
+
+        Ok(reg)
+    }
+}
diff --git a/hugr-cli/src/lib.rs b/hugr-cli/src/lib.rs
index 553174876f..2162a86e7b 100644
--- a/hugr-cli/src/lib.rs
+++ b/hugr-cli/src/lib.rs
@@ -1,21 +1,18 @@
 //! Standard command line tools, used by the hugr binary.
 
 use clap::{crate_version, Parser};
+use clap_verbosity_flag::log::Level;
 use clap_verbosity_flag::{InfoLevel, Verbosity};
-use clio::Input;
-use derive_more::{Display, Error, From};
-use hugr::extension::ExtensionRegistry;
+use hugr::envelope::EnvelopeError;
+use hugr::hugr::LoadHugrError;
 use hugr::package::{PackageEncodingError, PackageValidationError};
-use hugr::Hugr;
-use std::io::{Cursor, Read, Seek, SeekFrom};
-use std::{ffi::OsString, path::PathBuf};
+use std::ffi::OsString;
 
 pub mod extensions;
+pub mod hugr_io;
 pub mod mermaid;
 pub mod validate;
 
-use hugr::package::Package;
-
 /// CLI arguments.
 #[derive(Parser, Debug)]
 #[clap(version = crate_version!(), long_about = None)]
@@ -35,7 +32,7 @@ pub enum CliArgs {
 }
 
 /// Error type for the CLI.
-#[derive(Debug, Display, Error, From)]
+#[derive(Debug, derive_more::Display, derive_more::Error, derive_more::From)]
 #[non_exhaustive]
 pub enum CliError {
     /// Error reading input.
@@ -44,107 +41,31 @@ pub enum CliError {
     /// Error parsing input.
     #[display("Error parsing package: {_0}")]
     Parse(serde_json::Error),
-    /// Hugr load error.
+    /// Package load error.
     #[display("Error parsing package: {_0}")]
-    HUGRLoad(PackageEncodingError),
+    PackageLoad(PackageEncodingError),
+    /// Hugr load error.
+    #[display("Error loading hugr: {_0}")]
+    HugrLoad(LoadHugrError),
     #[display("Error validating HUGR: {_0}")]
     /// Errors produced by the `validate` subcommand.
     Validate(PackageValidationError),
+    #[display("Error decoding HUGR envelope: {_0}")]
+    /// Errors produced by the `validate` subcommand.
+    Envelope(EnvelopeError),
 }
 
-/// Validate and visualise a HUGR file.
+/// Other arguments affecting the HUGR CLI runtime.
 #[derive(Parser, Debug)]
-pub struct HugrArgs {
-    /// Input HUGR file, use '-' for stdin
-    #[clap(value_parser, default_value = "-")]
-    pub input: Input,
+pub struct OtherArgs {
     /// Verbosity.
     #[command(flatten)]
     pub verbose: Verbosity<InfoLevel>,
-    /// No standard extensions.
-    #[arg(
-        long,
-        help = "Don't use standard extensions when validating. Prelude is still used."
-    )]
-    pub no_std: bool,
-    /// Extensions paths.
-    #[arg(
-        short,
-        long,
-        help = "Paths to serialised extensions to validate against."
-    )]
-    pub extensions: Vec<PathBuf>,
-}
-
-/// A simple enum containing either a package or a single hugr.
-///
-/// This is required since `Package`s can only contain module-rooted hugrs.
-#[derive(Debug, Clone, PartialEq)]
-pub enum PackageOrHugr {
-    /// A package with module-rooted HUGRs and some required extensions.
-    Package(Package),
-    /// An arbitrary HUGR.
-    Hugr(Hugr),
-}
-
-impl PackageOrHugr {
-    /// Returns the list of hugrs in the package.
-    pub fn into_hugrs(self) -> Vec<Hugr> {
-        match self {
-            PackageOrHugr::Package(pkg) => pkg.modules,
-            PackageOrHugr::Hugr(hugr) => vec![hugr],
-        }
-    }
-
-    /// Validates the package or hugr.
-    pub fn validate(&self) -> Result<(), PackageValidationError> {
-        match self {
-            PackageOrHugr::Package(pkg) => pkg.validate(),
-            PackageOrHugr::Hugr(hugr) => Ok(hugr.validate()?),
-        }
-    }
-}
-
-impl AsRef<[Hugr]> for PackageOrHugr {
-    fn as_ref(&self) -> &[Hugr] {
-        match self {
-            PackageOrHugr::Package(pkg) => &pkg.modules,
-            PackageOrHugr::Hugr(hugr) => std::slice::from_ref(hugr),
-        }
-    }
-}
-
-impl HugrArgs {
-    /// Read either a package or a single hugr from the input.
-    pub fn get_package_or_hugr(
-        &mut self,
-        extensions: &ExtensionRegistry,
-    ) -> Result<PackageOrHugr, CliError> {
-        // We need to read the input twice; once to try to load it as a HUGR, and if that fails, as a package.
-        // If `input` is a file, we can reuse the reader by seeking back to the start.
-        // Else, we need to read the file into a buffer.
-        match self.input.can_seek() {
-            true => get_package_or_hugr_seek(&mut self.input, extensions),
-            false => {
-                let mut buffer = Vec::new();
-                self.input.read_to_end(&mut buffer)?;
-                get_package_or_hugr_seek(Cursor::new(buffer), extensions)
-            }
-        }
-    }
 }
 
-/// Load a package or hugr from a seekable input.
-fn get_package_or_hugr_seek<I: Seek + Read>(
-    mut input: I,
-    extensions: &ExtensionRegistry,
-) -> Result<PackageOrHugr, CliError> {
-    match Hugr::load_json(&mut input, extensions) {
-        Ok(hugr) => Ok(PackageOrHugr::Hugr(hugr)),
-        Err(_) => {
-            input.seek(SeekFrom::Start(0))?;
-            let pkg = Package::from_json_reader(input, extensions)?;
-            Ok(PackageOrHugr::Package(pkg))
-        }
+impl OtherArgs {
+    /// Test whether a `level` message should be output.
+    pub fn verbosity(&self, level: Level) -> bool {
+        self.verbose.log_level_filter() >= level
     }
 }
diff --git a/hugr-cli/src/main.rs b/hugr-cli/src/main.rs
index 4b489a952e..8c4eed018f 100644
--- a/hugr-cli/src/main.rs
+++ b/hugr-cli/src/main.rs
@@ -42,7 +42,7 @@ fn run_mermaid(mut args: mermaid::MermaidArgs) {
     let result = args.run_print();
 
     if let Err(e) = result {
-        if args.hugr_args.verbosity(Level::Error) {
+        if args.other_args.verbosity(Level::Error) {
             eprintln!("{}", e);
         }
         std::process::exit(1);
diff --git a/hugr-cli/src/mermaid.rs b/hugr-cli/src/mermaid.rs
index 5974dbd169..c81ed793b0 100644
--- a/hugr-cli/src/mermaid.rs
+++ b/hugr-cli/src/mermaid.rs
@@ -2,9 +2,14 @@
 use std::io::Write;
 
 use clap::Parser;
+use clap_verbosity_flag::log::Level;
 use clio::Output;
+use hugr::package::PackageValidationError;
 use hugr::HugrView;
 
+use crate::hugr_io::HugrInputArgs;
+use crate::OtherArgs;
+
 /// Dump the standard extensions.
 #[derive(Parser, Debug)]
 #[clap(version = "1.0", long_about = None)]
@@ -12,9 +17,10 @@ use hugr::HugrView;
 #[group(id = "hugr")]
 #[non_exhaustive]
 pub struct MermaidArgs {
-    /// Common arguments
+    /// Hugr input.
     #[command(flatten)]
-    pub hugr_args: crate::HugrArgs,
+    pub input_args: HugrInputArgs,
+
     /// Validate package.
     #[arg(
         long,
@@ -24,23 +30,50 @@ pub struct MermaidArgs {
     /// Output file '-' for stdout
     #[clap(long, short, value_parser, default_value = "-")]
     output: Output,
+
+    /// Additional arguments
+    #[command(flatten)]
+    pub other_args: OtherArgs,
 }
 
 impl MermaidArgs {
     /// Write the mermaid diagram to the output.
     pub fn run_print(&mut self) -> Result<(), crate::CliError> {
-        let hugrs = if self.validate {
-            self.hugr_args.validate()?
-        } else {
-            let extensions = self.hugr_args.extensions()?;
-            self.hugr_args
-                .get_package_or_hugr(&extensions)?
-                .into_hugrs()
-        };
-
-        for hugr in hugrs {
-            write!(self.output, "{}", hugr.mermaid_string())?;
+        match self.input_args.hugr_json {
+            true => self.run_print_hugr(),
+            false => self.run_print_envelope(),
+        }
+    }
+
+    /// Write the mermaid diagram for a HUGR envelope.
+    pub fn run_print_envelope(&mut self) -> Result<(), crate::CliError> {
+        let package = self.input_args.get_package()?;
+
+        if self.validate {
+            package.validate()?;
+        }
+
+        for hugr in package.modules {
+            writeln!(self.output, "{}", hugr.mermaid_string())?;
         }
         Ok(())
     }
+
+    /// Write the mermaid diagram for a legacy HUGR json.
+    pub fn run_print_hugr(&mut self) -> Result<(), crate::CliError> {
+        let hugr = self.input_args.get_hugr()?;
+
+        if self.validate {
+            hugr.validate()
+                .map_err(PackageValidationError::Validation)?;
+        }
+
+        writeln!(self.output, "{}", hugr.mermaid_string())?;
+        Ok(())
+    }
+
+    /// Test whether a `level` message should be output.
+    pub fn verbosity(&self, level: Level) -> bool {
+        self.other_args.verbosity(level)
+    }
 }
diff --git a/hugr-cli/src/validate.rs b/hugr-cli/src/validate.rs
index a47f731911..d3ea32a423 100644
--- a/hugr-cli/src/validate.rs
+++ b/hugr-cli/src/validate.rs
@@ -2,9 +2,11 @@
 
 use clap::Parser;
 use clap_verbosity_flag::log::Level;
-use hugr::{extension::ExtensionRegistry, Extension, Hugr};
+use hugr::package::PackageValidationError;
+use hugr::Hugr;
 
-use crate::{CliError, HugrArgs};
+use crate::hugr_io::HugrInputArgs;
+use crate::{CliError, OtherArgs};
 
 /// Validate and visualise a HUGR file.
 #[derive(Parser, Debug)]
@@ -13,9 +15,13 @@ use crate::{CliError, HugrArgs};
 #[group(id = "hugr")]
 #[non_exhaustive]
 pub struct ValArgs {
+    /// Hugr input.
     #[command(flatten)]
-    /// common arguments
-    pub hugr_args: HugrArgs,
+    pub input_args: HugrInputArgs,
+
+    /// Additional arguments
+    #[command(flatten)]
+    pub other_args: OtherArgs,
 }
 
 /// String to print when validation is successful.
@@ -24,51 +30,29 @@ pub const VALID_PRINT: &str = "HUGR valid!";
 impl ValArgs {
     /// Run the HUGR cli and validate against an extension registry.
     pub fn run(&mut self) -> Result<Vec<Hugr>, CliError> {
-        let result = self.hugr_args.validate()?;
-        if self.verbosity(Level::Info) {
-            eprintln!("{}", VALID_PRINT);
-        }
-        Ok(result)
-    }
-
-    /// Test whether a `level` message should be output.
-    pub fn verbosity(&self, level: Level) -> bool {
-        self.hugr_args.verbosity(level)
-    }
-}
-
-impl HugrArgs {
-    /// Load the package and validate against an extension registry.
-    ///
-    /// Returns the validated modules and the extension registry the modules
-    /// were validated against.
-    pub fn validate(&mut self) -> Result<Vec<Hugr>, CliError> {
-        let reg = self.extensions()?;
-        let package = self.get_package_or_hugr(&reg)?;
-
-        package.validate()?;
-        Ok(package.into_hugrs())
-    }
-
-    /// Return a register with the selected extensions.
-    pub fn extensions(&self) -> Result<ExtensionRegistry, CliError> {
-        let mut reg = if self.no_std {
-            hugr::extension::PRELUDE_REGISTRY.to_owned()
-        } else {
-            hugr::std_extensions::STD_REG.to_owned()
+        let result = match self.input_args.hugr_json {
+            true => {
+                let hugr = self.input_args.get_hugr()?;
+                hugr.validate()
+                    .map_err(PackageValidationError::Validation)?;
+                vec![hugr]
+            }
+            false => {
+                let package = self.input_args.get_package()?;
+                package.validate()?;
+                package.modules
+            }
         };
 
-        for ext in &self.extensions {
-            let f = std::fs::File::open(ext)?;
-            let ext: Extension = serde_json::from_reader(f)?;
-            reg.register_updated(ext);
+        if self.verbosity(Level::Info) {
+            eprintln!("{}", VALID_PRINT);
         }
 
-        Ok(reg)
+        Ok(result)
     }
 
     /// Test whether a `level` message should be output.
     pub fn verbosity(&self, level: Level) -> bool {
-        self.verbose.log_level_filter() >= level
+        self.other_args.verbosity(level)
     }
 }
diff --git a/hugr-cli/tests/validate.rs b/hugr-cli/tests/validate.rs
index 3038533920..da49cdd84e 100644
--- a/hugr-cli/tests/validate.rs
+++ b/hugr-cli/tests/validate.rs
@@ -7,6 +7,7 @@
 use assert_cmd::Command;
 use assert_fs::{fixture::FileWriteStr, NamedTempFile};
 use hugr::builder::{DFGBuilder, DataflowSubContainer, ModuleBuilder};
+use hugr::envelope::EnvelopeConfig;
 use hugr::package::Package;
 use hugr::types::Type;
 use hugr::{
@@ -14,7 +15,6 @@ use hugr::{
     extension::prelude::{bool_t, qb_t},
     std_extensions::arithmetic::float_types::float64_type,
     types::Signature,
-    Hugr,
 };
 use hugr_cli::validate::VALID_PRINT;
 use predicates::{prelude::*, str::contains};
@@ -51,24 +51,15 @@ fn test_package(#[default(bool_t())] id_type: Type) -> Package {
     Package::new(vec![hugr]).unwrap()
 }
 
-/// A DFG-rooted HUGR.
 #[fixture]
-fn test_hugr(#[default(bool_t())] id_type: Type) -> Hugr {
-    let mut df = DFGBuilder::new(Signature::new_endo(id_type)).unwrap();
-    let [i] = df.input_wires_arr();
-    df.set_outputs([i]).unwrap();
-    df.hugr().clone() // unvalidated
-}
-
-#[fixture]
-fn test_hugr_string(test_hugr: Hugr) -> String {
-    serde_json::to_string(&test_hugr).unwrap()
+fn test_envelope_str(test_package: Package) -> String {
+    test_package.store_str(EnvelopeConfig::text()).unwrap()
 }
 
 #[fixture]
-fn test_hugr_file(test_hugr_string: String) -> NamedTempFile {
+fn test_envelope_file(test_envelope_str: String) -> NamedTempFile {
     let file = assert_fs::NamedTempFile::new("sample.hugr").unwrap();
-    file.write_str(&test_hugr_string).unwrap();
+    file.write_str(&test_envelope_str).unwrap();
     file
 }
 
@@ -82,23 +73,23 @@ fn test_doesnt_exist(mut val_cmd: Command) {
 }
 
 #[rstest]
-fn test_validate(test_hugr_file: NamedTempFile, mut val_cmd: Command) {
-    val_cmd.arg(test_hugr_file.path());
+fn test_validate(test_envelope_file: NamedTempFile, mut val_cmd: Command) {
+    val_cmd.arg(test_envelope_file.path());
     val_cmd.assert().success().stderr(contains(VALID_PRINT));
 }
 
 #[rstest]
-fn test_stdin(test_hugr_string: String, mut val_cmd: Command) {
-    val_cmd.write_stdin(test_hugr_string);
+fn test_stdin(test_envelope_str: String, mut val_cmd: Command) {
+    val_cmd.write_stdin(test_envelope_str);
     val_cmd.arg("-");
 
     val_cmd.assert().success().stderr(contains(VALID_PRINT));
 }
 
 #[rstest]
-fn test_stdin_silent(test_hugr_string: String, mut val_cmd: Command) {
+fn test_stdin_silent(test_envelope_str: String, mut val_cmd: Command) {
     val_cmd.args(["-", "-q"]);
-    val_cmd.write_stdin(test_hugr_string);
+    val_cmd.write_stdin(test_envelope_str);
 
     val_cmd
         .assert()
@@ -107,10 +98,10 @@ fn test_stdin_silent(test_hugr_string: String, mut val_cmd: Command) {
 }
 
 #[rstest]
-fn test_mermaid(test_hugr_file: NamedTempFile, mut cmd: Command) {
-    const MERMAID: &str = "graph LR\n    subgraph 0 [\"(0) DFG\"]";
+fn test_mermaid(test_envelope_file: NamedTempFile, mut cmd: Command) {
+    const MERMAID: &str = "graph LR";
     cmd.arg("mermaid");
-    cmd.arg(test_hugr_file.path());
+    cmd.arg(test_envelope_file.path());
     cmd.assert().success().stdout(contains(MERMAID));
 }
 
@@ -126,6 +117,7 @@ fn bad_hugr_string() -> String {
 fn test_mermaid_invalid(bad_hugr_string: String, mut cmd: Command) {
     cmd.arg("mermaid");
     cmd.arg("--validate");
+    cmd.arg("--hugr-json");
     cmd.write_stdin(bad_hugr_string);
     cmd.assert()
         .failure()
@@ -135,6 +127,7 @@ fn test_mermaid_invalid(bad_hugr_string: String, mut cmd: Command) {
 #[rstest]
 fn test_bad_hugr(bad_hugr_string: String, mut val_cmd: Command) {
     val_cmd.write_stdin(bad_hugr_string);
+    val_cmd.arg("--hugr-json");
     val_cmd.arg("-");
 
     val_cmd
@@ -145,29 +138,31 @@ fn test_bad_hugr(bad_hugr_string: String, mut val_cmd: Command) {
 
 #[rstest]
 fn test_bad_json(mut val_cmd: Command) {
-    val_cmd.write_stdin(r#"{"foo": "bar"}"#);
+    const TEXT_HEADER: &str = "HUGRiHJv?@";
+    val_cmd.write_stdin(format!("{TEXT_HEADER}{}", r#"{"foo": "bar"}"#));
     val_cmd.arg("-");
 
     val_cmd
         .assert()
         .failure()
-        .stderr(contains("Error parsing package"));
+        .stderr(contains("Error decoding HUGR envelope"));
 }
 
 #[rstest]
 fn test_bad_json_silent(mut val_cmd: Command) {
-    val_cmd.write_stdin(r#"{"foo": "bar"}"#);
+    const TEXT_HEADER: &str = "HUGRiHJv?@";
+    val_cmd.write_stdin(format!("{TEXT_HEADER}{}", r#"{"foo": "bar"}"#));
     val_cmd.args(["-", "-qqq"]);
 
     val_cmd
         .assert()
         .failure()
-        .stderr(contains("Error parsing package").not());
+        .stderr(contains("Error decoding HUGR envelope").not());
 }
 
 #[rstest]
-fn test_no_std(test_hugr_string: String, mut val_cmd: Command) {
-    val_cmd.write_stdin(test_hugr_string);
+fn test_no_std(test_envelope_str: String, mut val_cmd: Command) {
+    val_cmd.write_stdin(test_envelope_str);
     val_cmd.arg("-");
     val_cmd.arg("--no-std");
     // test hugr doesn't have any standard extensions, so this should succeed
@@ -176,20 +171,8 @@ fn test_no_std(test_hugr_string: String, mut val_cmd: Command) {
 }
 
 #[fixture]
-fn float_hugr_string(#[with(float64_type())] test_hugr: Hugr) -> String {
-    serde_json::to_string(&test_hugr).unwrap()
-}
-
-#[rstest]
-fn test_no_std_fail(float_hugr_string: String, mut val_cmd: Command) {
-    val_cmd.write_stdin(float_hugr_string);
-    val_cmd.arg("-");
-    val_cmd.arg("--no-std");
-
-    val_cmd
-        .assert()
-        .failure()
-        .stderr(contains(" requires extension arithmetic.float.types"));
+fn float_hugr_string(#[with(float64_type())] test_package: Package) -> String {
+    test_package.store_str(EnvelopeConfig::text()).unwrap()
 }
 
 #[rstest]
@@ -204,7 +187,7 @@ fn test_float_extension(float_hugr_string: String, mut val_cmd: Command) {
 }
 #[fixture]
 fn package_string(#[with(float64_type())] test_package: Package) -> String {
-    serde_json::to_string(&test_package).unwrap()
+    test_package.store_str(EnvelopeConfig::text()).unwrap()
 }
 
 #[rstest]
diff --git a/hugr-core/Cargo.toml b/hugr-core/Cargo.toml
index 829feef006..71d73a1b11 100644
--- a/hugr-core/Cargo.toml
+++ b/hugr-core/Cargo.toml
@@ -20,6 +20,7 @@ workspace = true
 extension_inference = []
 declarative = ["serde_yaml"]
 model_unstable = ["hugr-model"]
+zstd = ["dep:zstd"]
 
 [lib]
 bench = false
@@ -29,30 +30,33 @@ name = "model"
 required-features = ["model_unstable"]
 
 [dependencies]
-portgraph = { workspace = true, features = ["serde", "petgraph"] }
-thiserror = { workspace = true }
-regex = { workspace = true }
+hugr-model = { version = "0.17.1", path = "../hugr-model", optional = true }
+
 cgmath = { workspace = true, features = ["serde"] }
-downcast-rs = { workspace = true }
-# Rc used here for Extension, but unfortunately we must turn the feature on globally
-serde = { workspace = true, features = ["derive", "rc"] }
-serde_yaml = { workspace = true, optional = true }
-typetag = { workspace = true }
-smol_str = { workspace = true, features = ["serde"] }
+delegate = { workspace = true }
 derive_more = { workspace = true, features = ["display", "error", "from"] }
-itertools = { workspace = true }
-html-escape = { workspace = true }
+downcast-rs = { workspace = true }
 enum_dispatch = { workspace = true }
+fxhash.workspace = true
+html-escape = { workspace = true }
+indexmap.workspace = true
+itertools = { workspace = true }
 lazy_static = { workspace = true }
+paste = { workspace = true }
 petgraph = { workspace = true }
+portgraph = { workspace = true, features = ["serde", "petgraph"] }
+regex = { workspace = true }
+# Rc used here for Extension, but unfortunately we must turn the feature on globally
+serde = { workspace = true, features = ["derive", "rc"] }
 serde_json = { workspace = true }
-delegate = { workspace = true }
-paste = { workspace = true }
-strum = { workspace = true , features = ["derive"]}
-semver = { version = "1.0.26", features = ["serde"] }
-hugr-model = { version = "0.17.1", path = "../hugr-model", optional = true }
-indexmap.workspace = true
-fxhash.workspace = true
+serde_yaml = { workspace = true, optional = true }
+smol_str = { workspace = true, features = ["serde"] }
+static_assertions = { workspace = true }
+strum = { workspace = true, features = ["derive"] }
+thiserror = { workspace = true }
+typetag = { workspace = true }
+semver = { workspace = true, features = ["serde"] }
+zstd = { workspace = true, optional = true }
 
 [dev-dependencies]
 rstest = { workspace = true }
diff --git a/hugr-core/src/builder.rs b/hugr-core/src/builder.rs
index 6d0cf08650..5ba14fcf23 100644
--- a/hugr-core/src/builder.rs
+++ b/hugr-core/src/builder.rs
@@ -241,6 +241,7 @@ pub(crate) mod test {
     use crate::extension::prelude::{bool_t, usize_t};
     use crate::hugr::{views::HugrView, HugrMut};
     use crate::ops;
+    use crate::package::Package;
     use crate::types::{PolyFuncType, Signature};
     use crate::Hugr;
 
@@ -302,6 +303,19 @@ pub(crate) mod test {
         cfg_builder.finish_hugr().unwrap()
     }
 
+    #[fixture]
+    pub(crate) fn simple_package() -> Package {
+        let hugr = simple_module_hugr();
+        Package::new([hugr]).unwrap()
+    }
+
+    #[fixture]
+    pub(crate) fn multi_module_package() -> Package {
+        let hugr0 = simple_module_hugr();
+        let hugr1 = simple_module_hugr();
+        Package::new([hugr0, hugr1]).unwrap()
+    }
+
     /// A helper method which creates a DFG rooted hugr with Input and Output node
     /// only (no wires), given a function type with extension delta.
     // TODO consider taking two type rows and using TO_BE_INFERRED
diff --git a/hugr-core/src/envelope.rs b/hugr-core/src/envelope.rs
new file mode 100644
index 0000000000..8734127c02
--- /dev/null
+++ b/hugr-core/src/envelope.rs
@@ -0,0 +1,436 @@
+//! Envelope format for HUGR packages.
+//!
+//! The format is designed to be extensible and backwards-compatible. It
+//! consists of a header declaring the format used to encode the HUGR, followed
+//! by the encoded HUGR itself.
+//!
+//! Use [`read_envelope`] and [`write_envelope`] for reading and writing
+//! envelopes from/to readers and writers, or call [Package::load] and
+//! [Package::store] directly.
+//!
+//! ## Payload formats
+//!
+//! The envelope may encode the HUGR in different formats, listed in
+//! [`EnvelopeFormat`]. The payload may also be compressed with zstd.
+//!
+//! Some formats can be represented as ASCII, as indicated by the
+//! [`EnvelopeFormat::ascii_printable`] method. When this is the case, the
+//! whole envelope can be stored in a string.
+//!
+//! ## Envelope header
+//!
+//! The binary header format is 10 bytes, with the following fields:
+//!
+//! | Field  | Size (bytes) | Description |
+//! |--------|--------------|-------------|
+//! | Magic  | 8            | [MAGIC_NUMBERS] constant identifying the envelope format. |
+//! | Format | 1            | [EnvelopeFormat] describing the payload format. |
+//! | Flags  | 1            | Additional configuration flags. |
+//!
+//! Flags:
+//!
+//! - Bit 0: Whether the payload is compressed with zstd.
+//! - Bits 1-5: Reserved for future use.
+//! - Bit 7,6: Constant "01" to make some headers ascii-printable.
+//!
+
+mod header;
+
+pub use header::{EnvelopeConfig, EnvelopeFormat, ZstdConfig, MAGIC_NUMBERS};
+
+use crate::{
+    extension::ExtensionRegistry,
+    package::{Package, PackageEncodingError, PackageError},
+};
+use header::EnvelopeHeader;
+use std::io::BufRead;
+use std::io::Write;
+
+#[allow(unused_imports)]
+use itertools::Itertools as _;
+
+#[cfg(feature = "model_unstable")]
+use crate::import::ImportError;
+
+/// Read a HUGR envelope from a reader.
+///
+/// Returns the deserialized package and the configuration used to encode it.
+///
+/// Parameters:
+/// - `reader`: The reader to read the envelope from.
+/// - `registry`: An extension registry with additional extensions to use when
+///     decoding the HUGR, if they are not already included in the package.
+pub fn read_envelope(
+    mut reader: impl BufRead,
+    registry: &ExtensionRegistry,
+) -> Result<(EnvelopeConfig, Package), EnvelopeError> {
+    let header = EnvelopeHeader::read(&mut reader)?;
+
+    let package = match header.zstd {
+        #[cfg(feature = "zstd")]
+        true => read_impl(
+            std::io::BufReader::new(zstd::Decoder::new(reader)?),
+            header,
+            registry,
+        ),
+        #[cfg(not(feature = "zstd"))]
+        true => Err(EnvelopeError::ZstdUnsupported),
+        false => read_impl(reader, header, registry),
+    }?;
+    Ok((header.config(), package))
+}
+
+/// Write a HUGR package into an envelope, using the specified configuration.
+///
+/// It is recommended to use a buffered writer for better performance.
+/// See [`std::io::BufWriter`] for more information.
+pub fn write_envelope(
+    mut writer: impl Write,
+    package: &Package,
+    config: EnvelopeConfig,
+) -> Result<(), EnvelopeError> {
+    let header = config.make_header();
+    header.write(&mut writer)?;
+
+    match config.zstd {
+        #[cfg(feature = "zstd")]
+        Some(zstd) => {
+            let writer = zstd::Encoder::new(writer, zstd.level())?.auto_finish();
+            write_impl(writer, package, config)?;
+        }
+        #[cfg(not(feature = "zstd"))]
+        Some(_) => return Err(EnvelopeError::ZstdUnsupported),
+        None => write_impl(writer, package, config)?,
+    }
+
+    Ok(())
+}
+
+/// Error type for envelope operations.
+#[derive(derive_more::Display, derive_more::Error, Debug, derive_more::From)]
+#[non_exhaustive]
+pub enum EnvelopeError {
+    /// Bad magic number.
+    #[display(
+        "Bad magic number. expected 0x{:X} found 0x{:X}",
+        u64::from_be_bytes(*expected),
+        u64::from_be_bytes(*found)
+    )]
+    #[from(ignore)]
+    MagicNumber {
+        /// The expected magic number.
+        ///
+        /// See [`MAGIC_NUMBERS`].
+        expected: [u8; 8],
+        /// The magic number in the envelope.
+        found: [u8; 8],
+    },
+    /// The specified payload format is invalid.
+    #[display("Format descriptor {descriptor} is invalid.")]
+    #[from(ignore)]
+    InvalidFormatDescriptor {
+        /// The unsupported format.
+        descriptor: usize,
+    },
+    /// The specified payload format is not supported.
+    #[display("Payload format {format} is not supported.{}",
+        match feature {
+            Some(f) => format!(" This requires the '{f}' feature for `hugr`."),
+            None => "".to_string()
+        },
+    )]
+    #[from(ignore)]
+    FormatUnsupported {
+        /// The unsupported format.
+        format: EnvelopeFormat,
+        /// Optionally, the feature required to support this format.
+        feature: Option<&'static str>,
+    },
+    /// Not all envelope formats can be represented as ASCII.
+    ///
+    /// This error is used when trying to store the envelope into a string.
+    #[display("Envelope format {format} cannot be represented as ASCII.")]
+    #[from(ignore)]
+    NonASCIIFormat {
+        /// The unsupported format.
+        format: EnvelopeFormat,
+    },
+    /// Envelope encoding required zstd compression, but the feature is not enabled.
+    #[display("Zstd compression is not supported. This requires the 'zstd' feature for `hugr`.")]
+    #[from(ignore)]
+    ZstdUnsupported,
+    /// Tried to encode a package with multiple HUGRs, when only 1 was expected.
+    #[display(
+        "Packages with multiple HUGRs are currently unsupported. Tried to encode {count} HUGRs, when 1 was expected."
+    )]
+    #[from(ignore)]
+    MultipleHugrs {
+        /// The number of HUGRs in the package.
+        count: usize,
+    },
+    /// JSON serialization error.
+    SerdeError {
+        /// The source error.
+        source: serde_json::Error,
+    },
+    /// IO read/write error.
+    IO {
+        /// The source error.
+        source: std::io::Error,
+    },
+    /// Error decoding a package from the payload.
+    Package {
+        /// The source error.
+        source: PackageError,
+    },
+    /// Error writing a json package to the payload.
+    PackageEncoding {
+        /// The source error.
+        source: PackageEncodingError,
+    },
+    /// Error importing a HUGR from a hugr-model payload.
+    #[cfg(feature = "model_unstable")]
+    ModelImport {
+        /// The source error.
+        source: ImportError,
+    },
+    /// Error reading a HUGR model payload.
+    #[cfg(feature = "model_unstable")]
+    ModelRead {
+        /// The source error.
+        source: hugr_model::v0::binary::ReadError,
+    },
+    /// Error writing a HUGR model payload.
+    #[cfg(feature = "model_unstable")]
+    ModelWrite {
+        /// The source error.
+        source: hugr_model::v0::binary::WriteError,
+    },
+}
+
+/// Internal implementation of [`read_envelope`] to call with/without the zstd decompression wrapper.
+fn read_impl(
+    payload: impl BufRead,
+    header: EnvelopeHeader,
+    registry: &ExtensionRegistry,
+) -> Result<Package, EnvelopeError> {
+    match header.format {
+        #[allow(deprecated)]
+        EnvelopeFormat::PackageJson => Ok(Package::from_json_reader(payload, registry)?),
+        #[cfg(feature = "model_unstable")]
+        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
+            decode_model(payload, registry, header.format)
+        }
+        #[cfg(not(feature = "model_unstable"))]
+        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
+            Err(EnvelopeError::FormatUnsupported {
+                format: header.format,
+                feature: Some("model_unstable"),
+            })
+        }
+    }
+}
+
+/// Read a HUGR model payload from a reader.
+///
+/// Parameters:
+/// - `stream`: The reader to read the envelope from.
+/// - `extension_registry`: An extension registry with additional extensions to use when
+///   decoding the HUGR, if they are not already included in the package.
+/// - `format`: The format of the payload.
+#[cfg(feature = "model_unstable")]
+fn decode_model(
+    mut stream: impl BufRead,
+    extension_registry: &ExtensionRegistry,
+    format: EnvelopeFormat,
+) -> Result<Package, EnvelopeError> {
+    use crate::{import::import_hugr, Extension};
+    use hugr_model::v0::bumpalo::Bump;
+
+    if format.model_version() != Some(0) {
+        return Err(EnvelopeError::FormatUnsupported {
+            format,
+            feature: None,
+        });
+    }
+
+    let bump = Bump::default();
+    let module_list = hugr_model::v0::binary::read_from_reader(&mut stream, &bump)?;
+
+    let mut extension_registry = extension_registry.clone();
+    if format.append_extensions() {
+        let extra_extensions: Vec<Extension> =
+            serde_json::from_reader::<_, Vec<Extension>>(stream)?;
+        for ext in extra_extensions {
+            extension_registry.register_updated(ext);
+        }
+    }
+
+    // TODO: Import multiple hugrs from the model?
+    let hugr = import_hugr(&module_list, &extension_registry)?;
+    Ok(Package::new([hugr])?)
+}
+
+/// Internal implementation of [`write_envelope`] to call with/without the zstd compression wrapper.
+fn write_impl(
+    writer: impl Write,
+    package: &Package,
+    config: EnvelopeConfig,
+) -> Result<(), EnvelopeError> {
+    match config.format {
+        #[allow(deprecated)]
+        EnvelopeFormat::PackageJson => package.to_json_writer(writer)?,
+        #[cfg(feature = "model_unstable")]
+        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
+            encode_model(writer, package, config.format)?
+        }
+        #[cfg(not(feature = "model_unstable"))]
+        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
+            return Err(EnvelopeError::FormatUnsupported {
+                format: config.format,
+                feature: Some("model_unstable"),
+            })
+        }
+    }
+    Ok(())
+}
+
+#[cfg(feature = "model_unstable")]
+fn encode_model(
+    mut writer: impl Write,
+    package: &Package,
+    format: EnvelopeFormat,
+) -> Result<(), EnvelopeError> {
+    use crate::export::export_hugr;
+    use hugr_model::v0::{binary::write_to_writer, bumpalo::Bump};
+
+    if format.model_version() != Some(0) {
+        return Err(EnvelopeError::FormatUnsupported {
+            format,
+            feature: None,
+        });
+    }
+
+    // TODO: Export multiple hugrs to the model?
+    if package.modules.len() != 1 {
+        return Err(EnvelopeError::MultipleHugrs {
+            count: package.modules.len(),
+        });
+    }
+    let bump = Bump::default();
+    let module = export_hugr(&package.modules[0], &bump);
+    write_to_writer(&module, &mut writer)?;
+
+    if format.append_extensions() {
+        serde_json::to_writer(writer, &package.extensions.iter().collect_vec())?;
+    }
+
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use cool_asserts::assert_matches;
+    use rstest::rstest;
+    use std::io::BufReader;
+
+    use crate::builder::test::{multi_module_package, simple_package};
+    use crate::extension::PRELUDE_REGISTRY;
+
+    #[rstest]
+    fn errors() {
+        let package = simple_package();
+        assert_matches!(
+            package.store_str(EnvelopeConfig::binary()),
+            Err(EnvelopeError::NonASCIIFormat { .. })
+        );
+    }
+
+    #[rstest]
+    #[case::empty(Package::default())]
+    #[case::simple(simple_package())]
+    #[case::multi(multi_module_package())]
+    fn text_roundtrip(#[case] package: Package) {
+        let envelope = package.store_str(EnvelopeConfig::text()).unwrap();
+        let new_package = Package::load_str(&envelope, None).unwrap();
+        assert_eq!(package, new_package);
+    }
+
+    #[rstest]
+    #[case::empty(Package::default())]
+    #[case::simple(simple_package())]
+    #[case::multi(multi_module_package())]
+    fn compressed_roundtrip(#[case] package: Package) {
+        let mut buffer = Vec::new();
+        let config = EnvelopeConfig {
+            format: EnvelopeFormat::PackageJson,
+            zstd: Some(ZstdConfig::default()),
+        };
+        let res = package.store(&mut buffer, config);
+
+        match cfg!(feature = "zstd") {
+            true => res.unwrap(),
+            false => {
+                assert_matches!(res, Err(EnvelopeError::ZstdUnsupported));
+                return;
+            }
+        }
+
+        let (decoded_config, new_package) =
+            read_envelope(BufReader::new(buffer.as_slice()), &PRELUDE_REGISTRY).unwrap();
+
+        assert_eq!(config.format, decoded_config.format);
+        assert_eq!(config.zstd.is_some(), decoded_config.zstd.is_some());
+        assert_eq!(package, new_package);
+    }
+
+    #[rstest]
+    //#[case::empty(Package::default())] // Not currently supported
+    #[case::simple(simple_package())]
+    //#[case::multi(multi_module_package())] // Not currently supported
+    #[cfg(feature = "model_unstable")]
+    fn module_exts_roundtrip(#[case] package: Package) {
+        let mut buffer = Vec::new();
+        let config = EnvelopeConfig {
+            format: EnvelopeFormat::ModelWithExtensions,
+            ..Default::default()
+        };
+        package.store(&mut buffer, config).unwrap();
+        let (decoded_config, new_package) =
+            read_envelope(BufReader::new(buffer.as_slice()), &PRELUDE_REGISTRY).unwrap();
+
+        assert_eq!(config.format, decoded_config.format);
+        assert_eq!(config.zstd.is_some(), decoded_config.zstd.is_some());
+        assert_eq!(package, new_package);
+    }
+
+    #[rstest]
+    //#[case::empty(Package::default())] // Not currently supported
+    #[case::simple(simple_package())]
+    //#[case::multi(multi_module_package())] // Not currently supported
+    fn module_roundtrip(#[case] package: Package) {
+        let mut buffer = Vec::new();
+        let config = EnvelopeConfig {
+            format: EnvelopeFormat::Model,
+            ..Default::default()
+        };
+        let res = package.store(&mut buffer, config);
+
+        match cfg!(feature = "model_unstable") {
+            true => res.unwrap(),
+            false => {
+                assert_matches!(res, Err(EnvelopeError::FormatUnsupported { .. }));
+                return;
+            }
+        }
+
+        let (decoded_config, new_package) =
+            read_envelope(BufReader::new(buffer.as_slice()), &PRELUDE_REGISTRY).unwrap();
+
+        assert_eq!(config.format, decoded_config.format);
+        assert_eq!(config.zstd.is_some(), decoded_config.zstd.is_some());
+
+        assert_eq!(package, new_package);
+    }
+}
diff --git a/hugr-core/src/envelope/header.rs b/hugr-core/src/envelope/header.rs
new file mode 100644
index 0000000000..444a6a9888
--- /dev/null
+++ b/hugr-core/src/envelope/header.rs
@@ -0,0 +1,248 @@
+//! Definitions for the header of an envelope.
+
+use std::io::{Read, Write};
+use std::num::NonZeroU8;
+
+use super::EnvelopeError;
+
+/// Magic number identifying the start of an envelope.
+///
+/// In ascii, this is "HUGRiHJv". The second half is a randomly generated string
+/// to avoid accidental collisions with other file formats.
+pub const MAGIC_NUMBERS: &[u8] = "HUGRiHJv".as_bytes();
+
+/// Header at the start of a binary envelope file.
+///
+/// See the [crate::envelope] module documentation for the binary format.
+#[derive(Clone, Copy, Eq, PartialEq, Debug, Default, derive_more::Display)]
+#[display("EnvelopeHeader({format}{})",
+    if *zstd { ", zstd compressed" } else { "" },
+)]
+pub(super) struct EnvelopeHeader {
+    /// The format used for the payload.
+    pub format: EnvelopeFormat,
+    /// Whether the payload is compressed with zstd.
+    pub zstd: bool,
+}
+
+/// Encoded format of an envelope payload.
+#[derive(
+    Clone, Copy, Eq, PartialEq, Debug, Default, Hash, derive_more::Display, strum::FromRepr,
+)]
+#[non_exhaustive]
+pub enum EnvelopeFormat {
+    /// `hugr-model` v0 binary capnproto message.
+    Model = 1,
+    /// `hugr-model` v0 binary capnproto message followed by a json-encoded [crate::extension::ExtensionRegistry].
+    //
+    // This is a temporary format required until the model adds support for extensions.
+    ModelWithExtensions = 2,
+    /// Json-encoded [crate::package::Package]
+    ///
+    /// Uses a printable ascii value as the discriminant so the envelope can be
+    /// read as text.
+    #[default]
+    PackageJson = 63, // '?' in ascii
+}
+
+// We use a u8 to represent EnvelopeFormat in the binary format, so we should not
+// add any non-unit variants or ones with discriminants > 255.
+static_assertions::assert_eq_size!(EnvelopeFormat, u8);
+
+impl EnvelopeFormat {
+    /// Returns whether to encode the extensions as json after the hugr payload.
+    pub fn append_extensions(self) -> bool {
+        matches!(self, Self::ModelWithExtensions)
+    }
+
+    /// If the format is a model format, returns its version number.
+    pub fn model_version(self) -> Option<u32> {
+        match self {
+            Self::Model | Self::ModelWithExtensions => Some(0),
+            _ => None,
+        }
+    }
+
+    /// Returns whether the encoding format is ASCII-printable.
+    ///
+    /// If true, the encoded envelope can be read as text.
+    pub fn ascii_printable(self) -> bool {
+        matches!(self, Self::PackageJson)
+    }
+}
+
+/// Configuration for encoding an envelope.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[non_exhaustive]
+pub struct EnvelopeConfig {
+    /// The format to use for the payload.
+    pub format: EnvelopeFormat,
+    /// Whether to compress the payload with zstd, and the compression level to
+    /// use.
+    pub zstd: Option<ZstdConfig>,
+}
+
+impl Default for EnvelopeConfig {
+    fn default() -> Self {
+        let format = Default::default();
+        let zstd = if cfg!(feature = "zstd") {
+            Some(ZstdConfig::default())
+        } else {
+            None
+        };
+        Self { format, zstd }
+    }
+}
+
+impl EnvelopeConfig {
+    /// Create a new envelope header with the specified configuration.
+    pub(super) fn make_header(&self) -> EnvelopeHeader {
+        EnvelopeHeader {
+            format: self.format,
+            zstd: self.zstd.is_some(),
+        }
+    }
+
+    /// Default configuration for a plain-text envelope.
+    pub const fn text() -> Self {
+        Self {
+            format: EnvelopeFormat::PackageJson,
+            zstd: None,
+        }
+    }
+
+    /// Default configuration for a binary envelope.
+    ///
+    /// If the `zstd` feature is enabled, this will use zstd compression.
+    pub const fn binary() -> Self {
+        Self {
+            format: EnvelopeFormat::Model,
+            zstd: Some(ZstdConfig { level: None }),
+        }
+    }
+}
+
+/// Configuration for zstd compression.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+#[non_exhaustive]
+pub struct ZstdConfig {
+    /// The compression level to use.
+    ///
+    /// The current range is 1-22, where 1 is fastest and 22 is best
+    /// compression. Values above 20 should be used with caution, as they
+    /// require additional memory.
+    ///
+    /// If `None`, zstd's default level is used.
+    pub level: Option<NonZeroU8>,
+}
+
+impl ZstdConfig {
+    /// Returns the zstd compression level to pass to the zstd library.
+    ///
+    /// Uses [zstd::DEFAULT_COMPRESSION_LEVEL] if the level is not set.
+    pub fn level(&self) -> i32 {
+        #[allow(unused_assignments, unused_mut)]
+        let mut default = 0;
+        #[cfg(feature = "zstd")]
+        {
+            default = zstd::DEFAULT_COMPRESSION_LEVEL;
+        }
+        self.level.map_or(default, |l| l.get() as i32)
+    }
+}
+
+impl EnvelopeHeader {
+    /// Returns the envelope configuration corresponding to this header.
+    ///
+    /// Note that zstd compression level is not stored in the header.
+    pub fn config(&self) -> EnvelopeConfig {
+        EnvelopeConfig {
+            format: self.format,
+            zstd: match self.zstd {
+                true => Some(ZstdConfig { level: None }),
+                false => None,
+            },
+        }
+    }
+
+    /// Write an envelope header to a writer.
+    ///
+    /// See the [crate::envelope] module documentation for the binary format.
+    pub fn write(&self, writer: &mut impl Write) -> Result<(), EnvelopeError> {
+        // The first 8 bytes are the magic number in little-endian.
+        writer.write_all(MAGIC_NUMBERS)?;
+        // Next is the format descriptor.
+        let format_bytes = [self.format as u8];
+        writer.write_all(&format_bytes)?;
+        // Next is the flags byte.
+        let mut flags = 0b01000000u8;
+        flags |= self.zstd as u8;
+        writer.write_all(&[flags])?;
+
+        Ok(())
+    }
+
+    /// Reads an envelope header from a reader.
+    ///
+    /// Consumes exactly 10 bytes from the reader.
+    /// See the [crate::envelope] module documentation for the binary format.
+    pub fn read(reader: &mut impl Read) -> Result<EnvelopeHeader, EnvelopeError> {
+        // The first 8 bytes are the magic number in little-endian.
+        let mut magic = [0; 8];
+        reader.read_exact(&mut magic)?;
+        if magic != MAGIC_NUMBERS {
+            return Err(EnvelopeError::MagicNumber {
+                expected: MAGIC_NUMBERS.try_into().unwrap(),
+                found: magic,
+            });
+        }
+
+        // Next is the format descriptor.
+        let mut format_bytes = [0; 1];
+        reader.read_exact(&mut format_bytes)?;
+        let format_discriminant = format_bytes[0] as usize;
+        let Some(format) = EnvelopeFormat::from_repr(format_discriminant) else {
+            return Err(EnvelopeError::InvalidFormatDescriptor {
+                descriptor: format_discriminant,
+            });
+        };
+
+        // Next is the flags byte.
+        let mut flags_bytes = [0; 1];
+        reader.read_exact(&mut flags_bytes)?;
+        let zstd = flags_bytes[0] & 0x1 != 0;
+
+        Ok(Self { format, zstd })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use rstest::rstest;
+
+    #[rstest]
+    #[case(EnvelopeFormat::Model)]
+    #[case(EnvelopeFormat::ModelWithExtensions)]
+    #[case(EnvelopeFormat::PackageJson)]
+    fn header_round_trip(#[case] format: EnvelopeFormat) {
+        // With zstd compression
+        let header = EnvelopeHeader { format, zstd: true };
+
+        let mut buffer = Vec::new();
+        header.write(&mut buffer).unwrap();
+        let read_header = EnvelopeHeader::read(&mut buffer.as_slice()).unwrap();
+        assert_eq!(header, read_header);
+
+        // Without zstd compression
+        let header = EnvelopeHeader {
+            format,
+            zstd: false,
+        };
+
+        let mut buffer = Vec::new();
+        header.write(&mut buffer).unwrap();
+        let read_header = EnvelopeHeader::read(&mut buffer.as_slice()).unwrap();
+        assert_eq!(header, read_header);
+    }
+}
diff --git a/hugr-core/src/lib.rs b/hugr-core/src/lib.rs
index 879ce57441..e32b623f2d 100644
--- a/hugr-core/src/lib.rs
+++ b/hugr-core/src/lib.rs
@@ -7,8 +7,11 @@
 // https://github.com/rust-lang/rust/issues/120363
 // https://github.com/proptest-rs/proptest/issues/447
 #![cfg_attr(test, allow(non_local_definitions))]
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
+
 pub mod builder;
 pub mod core;
+pub mod envelope;
 #[cfg(feature = "model_unstable")]
 pub mod export;
 pub mod extension;
diff --git a/hugr-core/src/package.rs b/hugr-core/src/package.rs
index 8204052db5..1b96f1ebde 100644
--- a/hugr-core/src/package.rs
+++ b/hugr-core/src/package.rs
@@ -6,8 +6,9 @@ use std::path::Path;
 use std::{fs, io, mem};
 
 use crate::builder::{Container, Dataflow, DataflowSubContainer, ModuleBuilder};
+use crate::envelope::{read_envelope, write_envelope, EnvelopeConfig, EnvelopeError};
 use crate::extension::resolution::ExtensionResolutionError;
-use crate::extension::{ExtensionId, ExtensionRegistry};
+use crate::extension::{ExtensionId, ExtensionRegistry, PRELUDE_REGISTRY};
 use crate::hugr::internal::HugrMutInternals;
 use crate::hugr::{ExtensionError, HugrView, ValidationError};
 use crate::ops::{FuncDefn, Module, NamedOp, OpTag, OpTrait, OpType};
@@ -122,9 +123,63 @@ impl Package {
         Ok(())
     }
 
+    /// Read a Package from a HUGR envelope.
+    pub fn load(
+        reader: impl io::BufRead,
+        extensions: Option<&ExtensionRegistry>,
+    ) -> Result<Self, EnvelopeError> {
+        let extensions = extensions.unwrap_or(&PRELUDE_REGISTRY);
+        let (_, pkg) = read_envelope(reader, extensions)?;
+        Ok(pkg)
+    }
+
+    /// Read a Package from a HUGR envelope encoded in a string.
+    ///
+    /// Note that not all envelopes are valid strings. In the general case,
+    /// it is recommended to use `Package::load` with a bytearray instead.
+    pub fn load_str(
+        envelope: impl AsRef<str>,
+        extensions: Option<&ExtensionRegistry>,
+    ) -> Result<Self, EnvelopeError> {
+        Self::load(envelope.as_ref().as_bytes(), extensions)
+    }
+
+    /// Store the Package in a HUGR envelope.
+    pub fn store(
+        &self,
+        writer: impl io::Write,
+        config: EnvelopeConfig,
+    ) -> Result<(), EnvelopeError> {
+        write_envelope(writer, self, config)
+    }
+
+    /// Store the Package in a HUGR envelope encoded in a string.
+    ///
+    /// Note that not all envelopes are valid strings. In the general case,
+    /// it is recommended to use `Package::store` with a bytearray instead.
+    /// See [EnvelopeFormat::ascii_printable][crate::envelope::EnvelopeFormat::ascii_printable].
+    pub fn store_str(&self, config: EnvelopeConfig) -> Result<String, EnvelopeError> {
+        if !config.format.ascii_printable() {
+            return Err(EnvelopeError::NonASCIIFormat {
+                format: config.format,
+            });
+        }
+
+        let mut buf = Vec::new();
+        self.store(&mut buf, config)?;
+        Ok(String::from_utf8(buf).expect("Envelope is valid utf8"))
+    }
+
     /// Read a Package in json format from an io reader.
     ///
     /// If the json encodes a single [Hugr] instead, it will be inserted in a new [Package].
+    //
+    // TODO: Make this a private method only used by the envelope reader, and remove the automatic HUGR fallback.
+    #[deprecated(
+        since = "0.14.5",
+        note = "Json encoding of packages is deprecated. Use `Package::load` instead"
+    )]
+    #[cfg_attr(coverage_nightly, coverage(off))]
     pub fn from_json_reader(
         reader: impl io::Read,
         extension_registry: &ExtensionRegistry,
@@ -182,26 +237,43 @@ impl Package {
     /// Read a Package from a json string.
     ///
     /// If the json encodes a single [Hugr] instead, it will be inserted in a new [Package].
+    #[deprecated(
+        since = "0.14.5",
+        note = "Json encoding of packages is deprecated. Use `Package::load_str` instead"
+    )]
+    #[cfg_attr(coverage_nightly, coverage(off))]
     pub fn from_json(
         json: impl AsRef<str>,
         extension_registry: &ExtensionRegistry,
     ) -> Result<Self, PackageEncodingError> {
+        #[allow(deprecated)]
         Self::from_json_reader(json.as_ref().as_bytes(), extension_registry)
     }
 
     /// Read a Package from a json file.
     ///
     /// If the json encodes a single [Hugr] instead, it will be inserted in a new [Package].
+    #[deprecated(
+        since = "0.14.5",
+        note = "Json encoding of packages is deprecated. Use `Package::load` instead"
+    )]
+    #[cfg_attr(coverage_nightly, coverage(off))]
     pub fn from_json_file(
         path: impl AsRef<Path>,
         extension_registry: &ExtensionRegistry,
     ) -> Result<Self, PackageEncodingError> {
         let file = fs::File::open(path)?;
         let reader = io::BufReader::new(file);
+        #[allow(deprecated)]
         Self::from_json_reader(reader, extension_registry)
     }
 
     /// Write the Package in json format into an io writer.
+    #[deprecated(
+        since = "0.14.5",
+        note = "Json encoding of packages is deprecated. Use `Package::store` instead"
+    )]
+    #[cfg_attr(coverage_nightly, coverage(off))]
     pub fn to_json_writer(&self, writer: impl io::Write) -> Result<(), PackageEncodingError> {
         serde_json::to_writer(writer, self)?;
         Ok(())
@@ -210,15 +282,30 @@ impl Package {
     /// Write the Package into a json string.
     ///
     /// If the json encodes a single [Hugr] instead, it will be inserted in a new [Package].
+    #[deprecated(
+        since = "0.14.5",
+        note = "Json encoding of packages is deprecated. Use `Package::store_str` instead"
+    )]
+    #[cfg_attr(coverage_nightly, coverage(off))]
     pub fn to_json(&self) -> Result<String, PackageEncodingError> {
         let json = serde_json::to_string(self)?;
         Ok(json)
     }
 
     /// Write the Package into a json file.
+    #[deprecated(
+        since = "0.14.5",
+        note = "Json encoding of packages is deprecated. Use `Package::store` instead"
+    )]
+    #[cfg_attr(coverage_nightly, coverage(off))]
     pub fn to_json_file(&self, path: impl AsRef<Path>) -> Result<(), PackageEncodingError> {
-        let file = fs::File::open(path)?;
+        let file = fs::OpenOptions::new()
+            .write(true)
+            .truncate(true)
+            .create(true)
+            .open(path)?;
         let writer = io::BufWriter::new(file);
+        #[allow(deprecated)]
         self.to_json_writer(writer)
     }
 }
@@ -362,29 +449,11 @@ mod test {
     use super::*;
     use rstest::{fixture, rstest};
 
-    #[fixture]
-    fn simple_package() -> Package {
-        let hugr0 = simple_module_hugr();
-        let hugr1 = simple_module_hugr();
-        Package::new([hugr0, hugr1]).unwrap()
-    }
-
     #[fixture]
     fn simple_input_node() -> Hugr {
         Hugr::new(Input::new(vec![]))
     }
 
-    #[rstest]
-    #[case::empty(Package::default())]
-    #[case::simple(simple_package())]
-    fn package_roundtrip(#[case] package: Package) {
-        use crate::extension::PRELUDE_REGISTRY;
-
-        let json = package.to_json().unwrap();
-        let new_package = Package::from_json(&json, &PRELUDE_REGISTRY).unwrap();
-        assert_eq!(package, new_package);
-    }
-
     #[rstest]
     #[case::module("module", simple_module_hugr(), false)]
     #[case::funcdef("funcdef", simple_funcdef_hugr(), false)]
diff --git a/hugr-py/src/hugr/envelope.py b/hugr-py/src/hugr/envelope.py
new file mode 100644
index 0000000000..b239ef5094
--- /dev/null
+++ b/hugr-py/src/hugr/envelope.py
@@ -0,0 +1,184 @@
+"""Generic serialization of HUGRs and Packages.
+
+The format is designed to be extensible and backwards-compatible. It
+consists of a header declaring the format used to encode the HUGR, followed
+by the encoded HUGR itself.
+
+## Payload formats
+
+The envelope may encode the HUGR in different formats, listed in
+:class:`hugr.envelope.EnvelopeFormat`. The payload may also be compressed with zstd.
+
+Some formats can be represented as ASCII, as indicated by the
+:meth:`hugr.envelope.EnvelopeFormat.ascii_printable` method. When this is the case, the
+whole envelope can be stored in a string.
+
+## Envelope header
+
+The binary header format is 10 bytes, with the following fields:
+
+| Field  | Size (bytes) | Description |
+|--------|--------------|-------------|
+| Magic  | 8            | :class:`hugr.envelope.MAGIC_NUMBERS` constant identifying the envelope format. |
+| Format | 1            | :class:`hugr.envelope.EnvelopeFormat` describing the payload format. |
+| Flags  | 1            | Additional configuration flags. |
+
+Flags:
+
+- Bit 0: Whether the payload is compressed with zstd.
+- Bits 1-5: Reserved for future use.
+- Bit 7,6: Constant "01" to make some headers ascii-printable.
+"""  # noqa: E501
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import TYPE_CHECKING, ClassVar
+
+import hugr._serialization.extension as ext_s
+
+if TYPE_CHECKING:
+    from hugr.package import Package
+
+# This is a hard-coded magic number that identifies the start of a HUGR envelope.
+MAGIC_NUMBERS = b"HUGRiHJv"
+
+
+def make_envelope(package: Package, config: EnvelopeConfig) -> bytes:
+    """Encode a HUGR package into an envelope, using the given configuration."""
+    envelope = bytearray(config._make_header().to_bytes())
+
+    if config.zstd is not None:
+        msg = "Zstd compression is not supported yet."
+        raise ValueError(msg)
+
+    # Currently only uncompressed JSON is supported.
+    match config.format:
+        case EnvelopeFormat.JSON:
+            json = package._to_serial().model_dump_json()
+            # This introduces an extra encode/decode roundtrip when calling
+            # `make_envelope_str`, but we prioritize speed for binary formats.
+            envelope += json.encode("utf-8")
+        case _:
+            msg = f"Unsupported envelope format: {config.format}"
+            raise ValueError(msg)
+
+    return bytes(envelope)
+
+
+def make_envelope_str(package: Package, config: EnvelopeConfig) -> str:
+    """Encode a HUGR package into an envelope, using the given configuration."""
+    if not config.format.ascii_printable():
+        msg = "Only ascii-printable envelope formats can be encoded into a string."
+        raise ValueError(msg)
+    envelope = make_envelope(package, config)
+    return envelope.decode("utf-8")
+
+
+def read_envelope(envelope: bytes) -> Package:
+    """Decode a HUGR package from an envelope."""
+    header = EnvelopeHeader.from_bytes(envelope)
+    payload = envelope[10:]
+
+    if header.zstd:
+        msg = "Zstd compression is not supported yet."
+        raise ValueError(msg)
+
+    match header.format:
+        case EnvelopeFormat.JSON:
+            return ext_s.Package.model_validate_json(payload).deserialize()
+        case _:
+            msg = f"Unsupported envelope format: {header.format}"
+            raise ValueError(msg)
+
+
+def read_envelope_str(envelope: str) -> Package:
+    """Decode a HUGR package from an envelope."""
+    return read_envelope(envelope.encode("utf-8"))
+
+
+class EnvelopeFormat(Enum):
+    """Format used to encode a HUGR envelope."""
+
+    MODULE = 1
+    """A capnp-encoded hugr-module."""
+    MODULE_WITH_EXTS = 2
+    """A capnp-encoded hugr-module, immediately followed by a json-encoded
+    extension registry."""
+    JSON = 63  # '?' in ASCII
+    """A json-encoded hugr-package. This format is ASCII-printable."""
+
+    def ascii_printable(self) -> bool:
+        return self in {EnvelopeFormat.JSON}
+
+
+@dataclass
+class EnvelopeHeader:
+    """Header of a HUGR envelope.
+
+    See the module docstring for the binary format.
+
+    Attributes:
+        format: The format used to encode the HUGR.
+        zstd: Whether the payload is compressed with zstd.
+            The compression level is detected from the payload.
+    """
+
+    format: EnvelopeFormat
+    zstd: bool = False
+
+    def to_bytes(self) -> bytes:
+        header_bytes = bytearray(MAGIC_NUMBERS)
+        header_bytes.append(self.format.value)
+        flags = 0b01000000
+        if self.zstd:
+            flags |= 0b00000001
+        header_bytes.append(flags)
+        return bytes(header_bytes)
+
+    @staticmethod
+    def from_bytes(data: bytes) -> EnvelopeHeader:
+        if len(data) < 10:
+            msg = (
+                "Invalid HUGR envelope."
+                + f"Expected at least 10 bytes, but got {len(data)} bytes."
+            )
+            raise ValueError(msg)
+        if data[:8] != MAGIC_NUMBERS:
+            msg = (
+                "Invalid magic number for HUGR envelope."
+                + f"Expected {MAGIC_NUMBERS!r} but got {data[:8]!r}"
+            )
+            raise ValueError(msg)
+
+        format: EnvelopeFormat = EnvelopeFormat(data[8])
+
+        flags = data[9]
+        zstd = bool(flags & 0b00000001)
+
+        return EnvelopeHeader(format=format, zstd=zstd)
+
+
+@dataclass
+class EnvelopeConfig:
+    """Configuration for writing a HUGR envelope.
+
+    Attributes:
+        format: The format to use for encoding the HUGR.
+        zstd: The compression level to use, or None for no compression.
+            Use 0 for the default level.
+    """
+
+    format: EnvelopeFormat = EnvelopeFormat.JSON
+    zstd: int | None = None
+
+    TEXT: ClassVar[EnvelopeConfig]
+
+    def _make_header(self) -> EnvelopeHeader:
+        return EnvelopeHeader(format=self.format, zstd=self.zstd is not None)
+
+
+# Set EnvelopeConfig's class variables.
+# This can only be done _after_ the class is defined.
+EnvelopeConfig.TEXT = EnvelopeConfig(format=EnvelopeFormat.JSON, zstd=None)
diff --git a/hugr-py/src/hugr/hugr/base.py b/hugr-py/src/hugr/hugr/base.py
index 9c63999723..0364d2dfdd 100644
--- a/hugr-py/src/hugr/hugr/base.py
+++ b/hugr-py/src/hugr/hugr/base.py
@@ -723,12 +723,20 @@ def get_meta(idx: int) -> dict[str, Any]:
         return hugr
 
     def to_json(self) -> str:
-        """Serialize the HUGR to a JSON string."""
+        """Serialize the HUGR to a JSON string.
+
+        For most use cases, it is recommended to store a HUGR package instead.
+        See :meth:`hugr.package.Package.to_bytes`.
+        """
         return self._to_serial().to_json()
 
     @classmethod
     def load_json(cls, json_str: str) -> Hugr:
-        """Deserialize a JSON string into a HUGR."""
+        """Deserialize a JSON string into a HUGR.
+
+        For most use cases, it is recommended to use package serialization instead.
+        See :meth:`hugr.package.Package.from_bytes`.
+        """
         json_dict = json.loads(json_str)
         serial = SerialHugr.load_json(json_dict)
         return cls._from_serial(serial)
diff --git a/hugr-py/src/hugr/package.py b/hugr-py/src/hugr/package.py
index f9cd36a3bc..fcc8016bdd 100644
--- a/hugr-py/src/hugr/package.py
+++ b/hugr-py/src/hugr/package.py
@@ -5,7 +5,25 @@
 from dataclasses import dataclass, field
 from typing import TYPE_CHECKING, Generic, TypeVar, cast
 
+try:
+    from warnings import deprecated  # type: ignore[attr-defined]
+except ImportError:
+    # Python < 3.13
+    def deprecated(_msg):  # type: ignore[no-redef]
+        def _deprecated(func):
+            return func
+
+        return _deprecated
+
+
 import hugr._serialization.extension as ext_s
+from hugr.envelope import (
+    EnvelopeConfig,
+    make_envelope,
+    make_envelope_str,
+    read_envelope,
+    read_envelope_str,
+)
 from hugr.ops import FuncDecl, FuncDefn, Op
 
 if TYPE_CHECKING:
@@ -43,10 +61,59 @@ def _to_serial(self) -> ext_s.Package:
             extensions=[e._to_serial() for e in self.extensions],
         )
 
+    @staticmethod
+    def from_bytes(envelope: bytes) -> Package:
+        """Deserialize a byte string to a Package object.
+
+        Some envelope formats can be read from a string. See :meth:`from_str`.
+
+        Args:
+            envelope: The byte string representing a Package.
+
+        Returns:
+            The deserialized Package object.
+        """
+        return read_envelope(envelope)
+
+    @staticmethod
+    def from_str(envelope: str) -> Package:
+        """Deserialize a string to a Package object.
+
+        Not all envelope formats can be read from a string.
+        See :meth:`from_bytes` for a more general method.
+
+        Args:
+            envelope: The string representing a Package.
+
+        Returns:
+            The deserialized Package object.
+        """
+        return read_envelope_str(envelope)
+
+    def to_bytes(self, config: EnvelopeConfig) -> bytes:
+        """Serialize the package to a HUGR envelope byte string.
+
+        Some envelope formats can be encoded into a string. See :meth:`to_str`.
+        """
+        return make_envelope(self, config)
+
+    def to_str(self, config: EnvelopeConfig) -> str:
+        """Serialize the package to a HUGR envelope string.
+
+        Not all envelope formats can be encoded into a string.
+        See :meth:`to_bytes` for a more general method.
+        """
+        return make_envelope_str(self, config)
+
+    @deprecated("Use HUGR envelopes instead. See the `to_bytes` and `to_str` methods.")
     def to_json(self) -> str:
+        """Serialize the package to a printable HUGR envelope string."""
         return self._to_serial().model_dump_json()
 
     @classmethod
+    @deprecated(
+        "Use HUGR envelopes instead. See the `from_bytes` and `from_str` methods."
+    )
     def from_json(cls, json_str: str) -> Package:
         """Deserialize a JSON string to a Package object.
 
diff --git a/hugr-py/tests/conftest.py b/hugr-py/tests/conftest.py
index c2496fea93..6cf5ee294b 100644
--- a/hugr-py/tests/conftest.py
+++ b/hugr-py/tests/conftest.py
@@ -12,6 +12,7 @@
 
 from hugr import ext, tys
 from hugr._serialization.serial_hugr import SerialHugr
+from hugr.envelope import EnvelopeConfig
 from hugr.hugr import Hugr
 from hugr.ops import AsExtOp, Command, DataflowOp, ExtOp, RegisteredOp
 from hugr.std.float import FLOAT_T
@@ -147,9 +148,16 @@ def validate(
         snapshot: A hugr render snapshot. If not None, it will be compared against the
         rendered HUGR. Pass `--snapshot-update` to pytest to update the snapshot file.
     """
+    # TODO: Use envelopes instead of legacy hugr-json
     cmd = [*_base_command(), "validate", "-"]
-    serial = h.to_json()
-    _run_hugr_cmd(serial, cmd)
+
+    if isinstance(h, Hugr):
+        cmd += ["--hugr-json"]
+        serial = h.to_json()
+        _run_hugr_cmd(serial, cmd)
+    else:
+        serial = h.to_str(EnvelopeConfig.TEXT)
+        _run_hugr_cmd(serial, cmd)
 
     if not roundtrip:
         return
diff --git a/hugr-py/tests/serialization/test_extension.py b/hugr-py/tests/serialization/test_extension.py
index 5f7c81bfc4..cf595319a8 100644
--- a/hugr-py/tests/serialization/test_extension.py
+++ b/hugr-py/tests/serialization/test_extension.py
@@ -19,6 +19,7 @@
     TypeTypeParam,
     Variable,
 )
+from hugr.envelope import EnvelopeConfig
 
 EXAMPLE = r"""
 {
@@ -136,4 +137,7 @@ def test_package():
     assert package == package_load
 
     hugr_package = package.deserialize()
-    assert hugr_package.from_json(hugr_package.to_json()) == hugr_package
+    assert (
+        hugr_package.from_bytes(hugr_package.to_bytes(EnvelopeConfig.TEXT))
+        == hugr_package
+    )
diff --git a/hugr/src/lib.rs b/hugr/src/lib.rs
index 708e8b47f5..198621a190 100644
--- a/hugr/src/lib.rs
+++ b/hugr/src/lib.rs
@@ -129,7 +129,9 @@
 
 // These modules are re-exported as-is. If more control is needed, define a new module in this crate with the desired exports.
 // The doc inline directive is necessary for renamed modules to appear as if they were defined in this crate.
-pub use hugr_core::{builder, core, extension, ops, package, std_extensions, types, utils};
+pub use hugr_core::{
+    builder, core, envelope, extension, ops, package, std_extensions, types, utils,
+};
 #[doc(inline)]
 pub use hugr_passes as algorithms;
 


Reviews:
user: login: aborgna-q
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T15:29:00Z
user: login: aborgna-q
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-13T18:57:19Z
user: login: ss2165
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T09:28:40Z
user: login: doug-q
body: Disappointing no zstd yet, but I agree it's best to merge now without it. 

Also very annoying no multi-module packages with the model, but again it's best to merge without.

Looks great Agustin, thank you.


state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-14T14:51:45Z
user: login: doug-q
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-14T14:52:10Z
user: login: doug-q
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-14T14:52:20Z
user: login: aborgna-q
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-14T15:24:54Z
user: login: aborgna-q
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-14T15:29:15Z

Review comments:
diff_hunk: @@ -2,13 +2,28 @@ use crate::capnp::hugr_v0_capnp as hugr_capnp;
 use crate::v0 as model;
 use bumpalo::collections::Vec as BumpVec;
 use bumpalo::Bump;
+use std::io::BufRead;
 
-type ReadResult<T> = Result<T, capnp::Error>;
+/// An error encounted while deserialising a model.
+#[derive(Debug, derive_more::From, derive_more::Display, derive_more::Error)]
+#[non_exhaustive]
+pub enum ReadError {
+    #[from(forward)]
+    /// An error encounted while decoding a model from a `capnproto` buffer.
+    DecodingError(capnp::Error),
+}
+
+type ReadResult<T> = Result<T, ReadError>;
 
 /// Read a hugr module from a byte slice.
 pub fn read_from_slice<'a>(slice: &[u8], bump: &'a Bump) -> ReadResult<model::Module<'a>> {
+    read_from_reader(slice, bump)
+}
+
+/// Read a hugr module from an impl of [BufRead].
+pub fn read_from_reader(reader: impl BufRead, bump: &Bump) -> ReadResult<model::Module<'_>> {
path: hugr-model/src/v0/binary/read.rs
user: login: aborgna-q
body: I wanted to loosen the `BufRead` bound into a `io::Read` instead, but the restriction comes from [`capnp-rust`](https://www.github.com/capnproto/capnproto-rust/pull/281) -.-'
created_at: 2025-03-12T15:29:00Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,335 @@
+//! Envelope format for HUGR packages.
+//!
+//! The format is designed to be extensible and backwards-compatible. It
+//! consists of a header declaring the format used to encode the HUGR, followed
+//! by the encoded HUGR itself.
+//!
+//! Use [`read_envelope`] and [`write_envelope`] for reading and writing
+//! envelopes from/to readers and writers, or call [Package::load] and
+//! [Package::store] directly.
+//!
+//! ## Payload formats
+//!
+//! The envelope may encode the HUGR in different formats, listed in
+//! [`EnvelopeFormat`]. The payload may also be compressed with zstd.
+//!
+//! Some formats can be represented as ASCII, as indicated by the
+//! [`EnvelopeFormat::ascii_printable`] method. When this is the case, the
+//! whole envelope can be stored in a string.
+//!
+//! ## Envelope header
+//!
+//! The binary header format is 10 bytes, with the following fields:
+//!
+//! | Field  | Size (bytes) | Description |
+//! |--------|--------------|-------------|
+//! | Magic  | 8            | [MAGIC_NUMBERS] constant identifying the envelope format. |
+//! | Format | 1            | [EnvelopeFormat] describing the payload format. |
+//! | Flags  | 1            | Additional configuration flags. |
+//!
+//! Flags:
+//!
+//! - Bit 0: Whether the payload is compressed with zstd.
+//! - Bits 1-5: Reserved for future use.
+//! - Bit 7,6: Constant "01" to make some headers ascii-printable.
+//!
+
+mod header;
+
+pub use header::{EnvelopeConfig, EnvelopeFormat, ZstdConfig, MAGIC_NUMBERS};
+
+use crate::{
+    extension::ExtensionRegistry,
+    package::{Package, PackageEncodingError, PackageError},
+};
+use header::EnvelopeHeader;
+use std::io::BufRead;
+use std::io::Write;
+
+#[allow(unused_imports)]
+use itertools::Itertools as _;
+
+#[cfg(feature = "model_unstable")]
+use crate::import::ImportError;
+
+/// Read a HUGR envelope from a reader.
+///
+/// Returns the deserialized package and the configuration used to encode it.
+///
+/// Parameters:
+/// - `reader`: The reader to read the envelope from.
+/// - `registry`: An extension registry with additional extensions to use when
+///     decoding the HUGR, if they are not already included in the package.
+pub fn read_envelope(
+    mut reader: impl BufRead,
+    registry: &ExtensionRegistry,
+) -> Result<(EnvelopeConfig, Package), EnvelopeError> {
+    let header = EnvelopeHeader::read(&mut reader)?;
+
+    let package = match header.zstd {
+        #[cfg(feature = "zstd")]
+        true => read_impl(
+            std::io::BufReader::new(zstd::Decoder::new(reader)?),
+            header,
+            registry,
+        ),
+        #[cfg(not(feature = "zstd"))]
+        true => Err(EnvelopeError::ZstdUnsupported),
+        false => read_impl(reader, header, registry),
+    }?;
+    Ok((header.config(), package))
+}
+
+/// Write a HUGR package into an envelope, using the specified configuration.
+///
+/// It is recommended to use a buffered writer for better performance.
+/// See [`std::io::BufWriter`] for more information.
+pub fn write_envelope(
+    mut writer: impl Write,
+    package: &Package,
+    config: EnvelopeConfig,
+) -> Result<(), EnvelopeError> {
+    let header = config.make_header();
+    header.write(&mut writer)?;
+
+    match config.zstd {
+        #[cfg(feature = "zstd")]
+        Some(zstd) => {
+            let writer = zstd::Encoder::new(writer, zstd.level())?;
+            write_impl(writer, package, config)?;
+        }
+        #[cfg(not(feature = "zstd"))]
+        Some(_) => return Err(EnvelopeError::ZstdUnsupported),
+        None => write_impl(writer, package, config)?,
+    }
+
+    Ok(())
+}
+
+/// Error type for envelope operations.
+#[derive(derive_more::Display, derive_more::Error, Debug, derive_more::From)]
+#[non_exhaustive]
+pub enum EnvelopeError {
+    /// Bad magic number.
+    #[display(
+        "Bad magic number. expected '{:X}' found '{:X}'",
+        u64::from_be_bytes(*expected),
+        u64::from_be_bytes(*found)
+    )]
+    #[from(ignore)]
+    MagicNumber {
+        /// The expected magic number.
+        ///
+        /// See [`MAGIC_NUMBERS`].
+        expected: [u8; 8],
+        /// The magic number in the envelope.
+        found: [u8; 8],
+    },
+    /// The specified payload format is invalid.
+    #[display("Format descriptor {descriptor} is invalid.")]
+    #[from(ignore)]
+    InvalidFormatDescriptor {
+        /// The unsupported format.
+        descriptor: usize,
+    },
+    /// The specified payload format is not supported.
+    #[display("Payload format {format} is not supported.{}",
+        match feature {
+            Some(f) => format!(" This requires the '{f}' feature for `hugr`."),
+            None => "".to_string()
+        },
+    )]
+    #[from(ignore)]
+    FormatUnsupported {
+        /// The unsupported format.
+        format: EnvelopeFormat,
+        /// Optionally, the feature required to support this format.
+        feature: Option<&'static str>,
+    },
+    /// Not all envelope formats can be represented as ASCII.
+    ///
+    /// This error is used when trying to store the envelope into a string.
+    #[display("Envelope format {format} cannot be represented as ASCII.")]
+    #[from(ignore)]
+    NonASCIIFormat {
+        /// The unsupported format.
+        format: EnvelopeFormat,
+    },
+    /// Envelope encoding required zstd compression, but the feature is not enabled.
+    #[display("Zstd compression is not supported. This requires the 'zstd' feature for `hugr`.")]
+    #[from(ignore)]
+    ZstdUnsupported,
+    /// Tried to encode a package with multiple HUGRs, when only 1 was expected.
+    #[display(
+        "Packages with multiple HUGRs are currently unsupported. Tried to encode {count} HUGRs, when 1 was expected."
+    )]
+    #[from(ignore)]
+    MultipleHugrs {
+        /// The number of HUGRs in the package.
+        count: usize,
+    },
+    /// JSON serialization error.
+    SerdeError {
+        /// The source error.
+        source: serde_json::Error,
+    },
+    /// IO read/write error.
+    IO {
+        /// The source error.
+        source: std::io::Error,
+    },
+    /// Error decoding a package from the payload.
+    Package {
+        /// The source error.
+        source: PackageError,
+    },
+    /// Error writing a json package to the payload.
+    PackageEncoding {
+        /// The source error.
+        source: PackageEncodingError,
+    },
+    /// Error importing a HUGR from a hugr-model payload.
+    #[cfg(feature = "model_unstable")]
+    ModelImport {
+        /// The source error.
+        source: ImportError,
+    },
+    /// Error reading a HUGR model payload.
+    #[cfg(feature = "model_unstable")]
+    ModelRead {
+        /// The source error.
+        source: hugr_model::v0::binary::ReadError,
+    },
+    /// Error writing a HUGR model payload.
+    #[cfg(feature = "model_unstable")]
+    ModelWrite {
+        /// The source error.
+        source: hugr_model::v0::binary::WriteError,
+    },
+}
+
+/// Internal implementation of [`read_envelope`] to call with/without the zstd decompression wrapper.
+fn read_impl(
+    payload: impl BufRead,
+    header: EnvelopeHeader,
+    registry: &ExtensionRegistry,
+) -> Result<Package, EnvelopeError> {
+    match header.format {
+        #[allow(deprecated)]
+        EnvelopeFormat::PackageJson => Ok(Package::from_json_reader(payload, registry)?),
+        #[cfg(feature = "model_unstable")]
+        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
+            decode_model(payload, registry, header.format)
+        }
+        #[cfg(not(feature = "model_unstable"))]
+        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
+            Err(EnvelopeError::FormatUnsupported {
+                format: header.format,
+                feature: Some("model_unstable"),
+            })
+        }
+    }
+}
+
+/// Read a HUGR model payload from a reader.
+///
+/// Parameters:
+/// - `stream`: The reader to read the envelope from.
+/// - `extension_registry`: An extension registry with additional extensions to use when
+///   decoding the HUGR, if they are not already included in the package.
+/// - `format`: The format of the payload.
+#[cfg(feature = "model_unstable")]
+fn decode_model(
+    mut stream: impl BufRead,
+    extension_registry: &ExtensionRegistry,
+    format: EnvelopeFormat,
+) -> Result<Package, EnvelopeError> {
+    use crate::{import::import_hugr, Extension};
+    use hugr_model::v0::bumpalo::Bump;
+
+    if format.model_version() != Some(0) {
+        return Err(EnvelopeError::FormatUnsupported {
+            format,
+            feature: None,
+        });
+    }
+
+    let bump = Bump::default();
+    let module_list = hugr_model::v0::binary::read_from_reader(&mut stream, &bump)?;
+
+    let mut extension_registry = extension_registry.clone();
+    if format.append_extensions() {
+        let extra_extensions: Vec<Extension> =
+            serde_json::from_reader::<_, Vec<Extension>>(stream)?;
+        for ext in extra_extensions {
+            extension_registry.register_updated(ext);
+        }
+    }
+
+    // TODO: Import multiple hugrs from the model?
+    let hugr = import_hugr(&module_list, &extension_registry)?;
+    let mut package = Package::new([hugr])?;
+
+    // Ensure the package contains all extensions from the registry,
+    // even if they were not used in the hugr.
+    package.extensions = extension_registry;
+
+    Ok(package)
+}
+
+/// Internal implementation of [`write_envelope`] to call with/without the zstd compression wrapper.
+fn write_impl(
+    writer: impl Write,
+    package: &Package,
+    config: EnvelopeConfig,
+) -> Result<(), EnvelopeError> {
+    match config.format {
+        #[allow(deprecated)]
+        EnvelopeFormat::PackageJson => package.to_json_writer(writer)?,
+        #[cfg(feature = "model_unstable")]
+        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
+            encode_model(writer, package, config.format)?
+        }
+        #[cfg(not(feature = "model_unstable"))]
+        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
+            return Err(EnvelopeError::FormatUnsupported {
+                format: config.format,
+                feature: Some("model_unstable"),
+            })
+        }
+    }
+    Ok(())
+}
+
+#[cfg(feature = "model_unstable")]
+fn encode_model(
+    mut writer: impl Write,
+    package: &Package,
+    format: EnvelopeFormat,
+) -> Result<(), EnvelopeError> {
+    use crate::export::export_hugr;
+    use hugr_model::v0::{binary::write_to_writer, bumpalo::Bump};
+
+    if format.model_version() != Some(0) {
+        return Err(EnvelopeError::FormatUnsupported {
+            format,
+            feature: None,
+        });
+    }
+
+    // TODO: Export multiple hugrs to the model?
+    if package.modules.len() != 1 {
+        return Err(EnvelopeError::MultipleHugrs {
+            count: package.modules.len(),
+        });
+    }
+    let bump = Bump::default();
+    let module = export_hugr(&package.modules[0], &bump);
+    write_to_writer(&module, &mut writer)?;
path: hugr-core/src/envelope.rs
user: login: aborgna-q
body: I didn't find a way to encode multi-hugr packages is the module API. Did I miss it?
created_at: 2025-03-13T15:16:49Z
author_association: COLLABORATOR
path: hugr-core/Cargo.toml
user: login: aborgna-q
body: Couldn't help myself from cleaning up the messy dependency list -.-
created_at: 2025-03-13T18:55:58Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,182 @@
+"""Generic serialization of HUGRs and Packages.
+
+The format is designed to be extensible and backwards-compatible. It
+consists of a header declaring the format used to encode the HUGR, followed
+by the encoded HUGR itself.
+
+## Payload formats
+
+The envelope may encode the HUGR in different formats, listed in
+:class:`hugr.envelope.EnvelopeFormat`. The payload may also be compressed with zstd.
+
+Some formats can be represented as ASCII, as indicated by the
+:meth:`hugr.envelope.EnvelopeFormat.ascii_printable` method. When this is the case, the
+whole envelope can be stored in a string.
+
+## Envelope header
+
+The binary header format is 10 bytes, with the following fields:
+
+| Field  | Size (bytes) | Description |
+|--------|--------------|-------------|
+| Magic  | 8            | :class:`hugr.envelope.MAGIC_NUMBERS` constant identifying the envelope format. |
+| Format | 1            | :class:`hugr.envelope.EnvelopeFormat` describing the payload format. |
+| Flags  | 1            | Additional configuration flags. |
+
+Flags:
+
+- Bit 0: Whether the payload is compressed with zstd.
+- Bits 1-5: Reserved for future use.
+- Bit 7,6: Constant "01" to make some headers ascii-printable.
+"""  # noqa: E501
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import TYPE_CHECKING
+
+import hugr._serialization.extension as ext_s
+
+if TYPE_CHECKING:
+    from hugr.package import Package
+
+# This is a hard-coded magic number that identifies the start of a HUGR envelope.
+MAGIC_NUMBERS = b"HUGRiHJv"
+
+
+def make_envelope(package: Package, config: EnvelopeConfig) -> bytes:
+    """Encode a HUGR package into an envelope, using the given configuration."""
+    envelope = bytearray(config._make_header().to_bytes())
+
+    if config.zstd is not None:
+        msg = "Zstd compression is not supported yet."
+        raise ValueError(msg)
+
+    # Currently only uncompressed JSON is supported.
+    match config.format:
+        case EnvelopeFormat.JSON:
+            json = package._to_serial().model_dump_json()
+            # This introduces an extra encode/decode roundtrip when calling
+            # `make_envelope_str`, but we prioritize speed for binary formats.
+            envelope += json.encode("utf-8")
+        case _:
+            msg = f"Unsupported envelope format: {config.format}"
+            raise ValueError(msg)
+
+    return bytes(envelope)
+
+
+def make_envelope_str(package: Package, config: EnvelopeConfig) -> str:
+    """Encode a HUGR package into an envelope, using the given configuration."""
+    if not config.format.ascii_printable():
+        msg = "Only ascii-printable envelope formats can be encoded into a string."
+        raise ValueError(msg)
+    envelope = make_envelope(package, config)
+    return envelope.decode("utf-8")
+
+
+def read_envelope(envelope: bytes) -> Package:
+    """Decode a HUGR package from an envelope."""
+    header = EnvelopeHeader.from_bytes(envelope)
+    payload = envelope[10:]
+
+    if header.zstd:
+        msg = "Zstd compression is not supported yet."
+        raise ValueError(msg)
+
+    match header.format:
+        case EnvelopeFormat.JSON:
+            return ext_s.Package.model_validate_json(payload).deserialize()
+        case _:
+            msg = f"Unsupported envelope format: {header.format}"
+            raise ValueError(msg)
+
+
+def read_envelope_str(envelope: str) -> Package:
+    """Decode a HUGR package from an envelope."""
+    return read_envelope(envelope.encode("utf-8"))
+
+
+class EnvelopeFormat(Enum):
+    """Format used to encode a HUGR envelope."""
+
+    MODULE = 1
+    """A capnp-encoded hugr-module."""
+    MODULE_WITH_EXTS = 2
+    """A capnp-encoded hugr-module, immediately followed by a json-encoded
+    extension registry."""
+    JSON = 63  # '?' in ASCII
+    """A json-encoded hugr-package. This format is ASCII-printable."""
+
+    def ascii_printable(self) -> bool:
+        return self in {EnvelopeFormat.JSON}
+
+
+@dataclass
+class EnvelopeHeader:
+    """Header of a HUGR envelope.
+
+    See the module docstring for the binary format.
+
+    Attributes:
+        format: The format used to encode the HUGR.
+        zstd: Whether the payload is compressed with zstd.
+            The compression level is detected from the payload.
+    """
+
+    format: EnvelopeFormat
+    zstd: bool = False
+
+    def to_bytes(self) -> bytes:
+        header_bytes = bytearray(MAGIC_NUMBERS)
+        header_bytes.append(self.format.value)
+        flags = 0b01000000
+        if self.zstd:
+            flags |= 0b00000001
+        header_bytes.append(flags)
+        return bytes(header_bytes)
+
+    @staticmethod
+    def from_bytes(data: bytes) -> EnvelopeHeader:
+        if len(data) < 10:
+            msg = (
+                "Invalid HUGR envelope."
+                + f"Expected at least 10 bytes, but got {len(data)} bytes."
+            )
+            raise ValueError(msg)
+        if data[:8] != MAGIC_NUMBERS:
+            msg = (
+                "Invalid magic number for HUGR envelope."
+                + f"Expected {MAGIC_NUMBERS!r} but got {data[:8]!r}"
+            )
+            raise ValueError(msg)
+
+        format: EnvelopeFormat = EnvelopeFormat(data[8])
+
+        flags = data[9]
+        zstd = bool(flags & 0b00000001)
+
+        return EnvelopeHeader(format=format, zstd=zstd)
+
+
+@dataclass
+class EnvelopeConfig:
+    """Configuration for writing a HUGR envelope.
+
+    Attributes:
+        format: The format to use for encoding the HUGR.
+        zstd: The compression level to use, or None for no compression.
+            Use 0 for the default level.
+    """
+
+    format: EnvelopeFormat = EnvelopeFormat.JSON
+    zstd: int | None = None
+
+    def _make_header(self) -> EnvelopeHeader:
+        return EnvelopeHeader(format=self.format, zstd=self.zstd is not None)
+
+    @staticmethod
+    def TEXT() -> EnvelopeConfig:
path: hugr-py/src/hugr/envelope.py
user: login: aborgna-q
body: I'd love to make this a `@property`, but it doesn't seem to work with the static decorator.
created_at: 2025-03-13T18:57:06Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,182 @@
+"""Generic serialization of HUGRs and Packages.
+
+The format is designed to be extensible and backwards-compatible. It
+consists of a header declaring the format used to encode the HUGR, followed
+by the encoded HUGR itself.
+
+## Payload formats
+
+The envelope may encode the HUGR in different formats, listed in
+:class:`hugr.envelope.EnvelopeFormat`. The payload may also be compressed with zstd.
+
+Some formats can be represented as ASCII, as indicated by the
+:meth:`hugr.envelope.EnvelopeFormat.ascii_printable` method. When this is the case, the
+whole envelope can be stored in a string.
+
+## Envelope header
+
+The binary header format is 10 bytes, with the following fields:
+
+| Field  | Size (bytes) | Description |
+|--------|--------------|-------------|
+| Magic  | 8            | :class:`hugr.envelope.MAGIC_NUMBERS` constant identifying the envelope format. |
+| Format | 1            | :class:`hugr.envelope.EnvelopeFormat` describing the payload format. |
+| Flags  | 1            | Additional configuration flags. |
+
+Flags:
+
+- Bit 0: Whether the payload is compressed with zstd.
+- Bits 1-5: Reserved for future use.
+- Bit 7,6: Constant "01" to make some headers ascii-printable.
+"""  # noqa: E501
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import TYPE_CHECKING
+
+import hugr._serialization.extension as ext_s
+
+if TYPE_CHECKING:
+    from hugr.package import Package
+
+# This is a hard-coded magic number that identifies the start of a HUGR envelope.
+MAGIC_NUMBERS = b"HUGRiHJv"
+
+
+def make_envelope(package: Package, config: EnvelopeConfig) -> bytes:
+    """Encode a HUGR package into an envelope, using the given configuration."""
+    envelope = bytearray(config._make_header().to_bytes())
+
+    if config.zstd is not None:
+        msg = "Zstd compression is not supported yet."
+        raise ValueError(msg)
+
+    # Currently only uncompressed JSON is supported.
+    match config.format:
+        case EnvelopeFormat.JSON:
+            json = package._to_serial().model_dump_json()
+            # This introduces an extra encode/decode roundtrip when calling
+            # `make_envelope_str`, but we prioritize speed for binary formats.
+            envelope += json.encode("utf-8")
+        case _:
+            msg = f"Unsupported envelope format: {config.format}"
+            raise ValueError(msg)
+
+    return bytes(envelope)
+
+
+def make_envelope_str(package: Package, config: EnvelopeConfig) -> str:
+    """Encode a HUGR package into an envelope, using the given configuration."""
+    if not config.format.ascii_printable():
+        msg = "Only ascii-printable envelope formats can be encoded into a string."
+        raise ValueError(msg)
+    envelope = make_envelope(package, config)
+    return envelope.decode("utf-8")
+
+
+def read_envelope(envelope: bytes) -> Package:
+    """Decode a HUGR package from an envelope."""
+    header = EnvelopeHeader.from_bytes(envelope)
+    payload = envelope[10:]
+
+    if header.zstd:
+        msg = "Zstd compression is not supported yet."
+        raise ValueError(msg)
+
+    match header.format:
+        case EnvelopeFormat.JSON:
+            return ext_s.Package.model_validate_json(payload).deserialize()
+        case _:
+            msg = f"Unsupported envelope format: {header.format}"
+            raise ValueError(msg)
+
+
+def read_envelope_str(envelope: str) -> Package:
+    """Decode a HUGR package from an envelope."""
+    return read_envelope(envelope.encode("utf-8"))
+
+
+class EnvelopeFormat(Enum):
+    """Format used to encode a HUGR envelope."""
+
+    MODULE = 1
+    """A capnp-encoded hugr-module."""
+    MODULE_WITH_EXTS = 2
+    """A capnp-encoded hugr-module, immediately followed by a json-encoded
+    extension registry."""
+    JSON = 63  # '?' in ASCII
+    """A json-encoded hugr-package. This format is ASCII-printable."""
+
+    def ascii_printable(self) -> bool:
+        return self in {EnvelopeFormat.JSON}
+
+
+@dataclass
+class EnvelopeHeader:
+    """Header of a HUGR envelope.
+
+    See the module docstring for the binary format.
+
+    Attributes:
+        format: The format used to encode the HUGR.
+        zstd: Whether the payload is compressed with zstd.
+            The compression level is detected from the payload.
+    """
+
+    format: EnvelopeFormat
+    zstd: bool = False
+
+    def to_bytes(self) -> bytes:
+        header_bytes = bytearray(MAGIC_NUMBERS)
+        header_bytes.append(self.format.value)
+        flags = 0b01000000
+        if self.zstd:
+            flags |= 0b00000001
+        header_bytes.append(flags)
+        return bytes(header_bytes)
+
+    @staticmethod
+    def from_bytes(data: bytes) -> EnvelopeHeader:
+        if len(data) < 10:
+            msg = (
+                "Invalid HUGR envelope."
+                + f"Expected at least 10 bytes, but got {len(data)} bytes."
+            )
+            raise ValueError(msg)
+        if data[:8] != MAGIC_NUMBERS:
+            msg = (
+                "Invalid magic number for HUGR envelope."
+                + f"Expected {MAGIC_NUMBERS!r} but got {data[:8]!r}"
+            )
+            raise ValueError(msg)
+
+        format: EnvelopeFormat = EnvelopeFormat(data[8])
+
+        flags = data[9]
+        zstd = bool(flags & 0b00000001)
+
+        return EnvelopeHeader(format=format, zstd=zstd)
+
+
+@dataclass
+class EnvelopeConfig:
+    """Configuration for writing a HUGR envelope.
+
+    Attributes:
+        format: The format to use for encoding the HUGR.
+        zstd: The compression level to use, or None for no compression.
+            Use 0 for the default level.
+    """
+
+    format: EnvelopeFormat = EnvelopeFormat.JSON
+    zstd: int | None = None
+
+    def _make_header(self) -> EnvelopeHeader:
+        return EnvelopeHeader(format=self.format, zstd=self.zstd is not None)
+
+    @staticmethod
+    def TEXT() -> EnvelopeConfig:
path: hugr-py/src/hugr/envelope.py
user: login: ss2165
body: should it be a classvar with a default factory?
created_at: 2025-03-14T09:28:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,85 @@
+//! Input/output arguments for the HUGR CLI.
+
+use clio::Input;
+use hugr::envelope::read_envelope;
+use hugr::extension::ExtensionRegistry;
+use hugr::package::Package;
+use hugr::{Extension, Hugr};
+use std::io::BufReader;
+use std::path::PathBuf;
+
+use crate::CliError;
+
+/// Arguments for reading a HUGR input.
+#[derive(Debug, clap::Args)]
+pub struct HugrInputArgs {
+    /// Input file. Defaults to `-` for stdin.
+    #[arg(value_parser, default_value = "-", help_heading = "Input")]
path: hugr-cli/src/hugr_io.rs
user: login: doug-q
body: I would think you can put an attribute on the struct to avoid having to do this on all fields? Is this the same as a group?
created_at: 2025-03-14T14:20:52Z
author_association: COLLABORATOR
diff_hunk: @@ -35,7 +32,7 @@ pub enum CliArgs {
 }
 
 /// Error type for the CLI.
-#[derive(Debug, Display, Error, From)]
+#[derive(Debug, derive_more::Display, derive_more::Error, derive_more::From)]
path: hugr-cli/src/lib.rs
user: login: doug-q
body: nice
created_at: 2025-03-14T14:22:28Z
author_association: COLLABORATOR
diff_hunk: @@ -29,30 +30,35 @@ name = "model"
 required-features = ["model_unstable"]
 
 [dependencies]
-portgraph = { workspace = true, features = ["serde", "petgraph"] }
-thiserror = { workspace = true }
-regex = { workspace = true }
+hugr-model = { version = "0.17.1", path = "../hugr-model", optional = true }
+
 cgmath = { workspace = true, features = ["serde"] }
-downcast-rs = { workspace = true }
-# Rc used here for Extension, but unfortunately we must turn the feature on globally
-serde = { workspace = true, features = ["derive", "rc"] }
-serde_yaml = { workspace = true, optional = true }
-typetag = { workspace = true }
-smol_str = { workspace = true, features = ["serde"] }
+delegate = { workspace = true }
 derive_more = { workspace = true, features = ["display", "error", "from"] }
-itertools = { workspace = true }
-html-escape = { workspace = true }
+downcast-rs = { workspace = true }
 enum_dispatch = { workspace = true }
+fxhash.workspace = true
+html-escape = { workspace = true }
+indexmap.workspace = true
+itertools = { workspace = true }
 lazy_static = { workspace = true }
+packed_struct = { workspace = true, features = ["byte_types_64"] }
+paste = { workspace = true }
 petgraph = { workspace = true }
+portgraph = { workspace = true, features = ["serde", "petgraph"] }
+regex = { workspace = true }
+# Rc used here for Extension, but unfortunately we must turn the feature on globally
path: hugr-core/Cargo.toml
user: login: doug-q
body: Is this true?
created_at: 2025-03-14T14:27:42Z
author_association: COLLABORATOR
diff_hunk: @@ -20,6 +20,7 @@ workspace = true
 extension_inference = []
 declarative = ["serde_yaml"]
 model_unstable = ["hugr-model"]
+zstd = ["dep:zstd"]
path: hugr-core/Cargo.toml
user: login: doug-q
body: I was expecting this to be a default. It could also be left non-default here and made default in `hugr` and `hugr-cli`. If it's non default I expect so many "I can't read the envelope" problems, because everyone is going to use zstd.
created_at: 2025-03-14T14:30:35Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,335 @@
+//! Envelope format for HUGR packages.
+//!
+//! The format is designed to be extensible and backwards-compatible. It
+//! consists of a header declaring the format used to encode the HUGR, followed
+//! by the encoded HUGR itself.
+//!
+//! Use [`read_envelope`] and [`write_envelope`] for reading and writing
+//! envelopes from/to readers and writers, or call [Package::load] and
+//! [Package::store] directly.
+//!
+//! ## Payload formats
+//!
+//! The envelope may encode the HUGR in different formats, listed in
+//! [`EnvelopeFormat`]. The payload may also be compressed with zstd.
+//!
+//! Some formats can be represented as ASCII, as indicated by the
+//! [`EnvelopeFormat::ascii_printable`] method. When this is the case, the
+//! whole envelope can be stored in a string.
+//!
+//! ## Envelope header
+//!
+//! The binary header format is 10 bytes, with the following fields:
+//!
+//! | Field  | Size (bytes) | Description |
+//! |--------|--------------|-------------|
+//! | Magic  | 8            | [MAGIC_NUMBERS] constant identifying the envelope format. |
+//! | Format | 1            | [EnvelopeFormat] describing the payload format. |
+//! | Flags  | 1            | Additional configuration flags. |
+//!
+//! Flags:
+//!
+//! - Bit 0: Whether the payload is compressed with zstd.
+//! - Bits 1-5: Reserved for future use.
+//! - Bit 7,6: Constant "01" to make some headers ascii-printable.
+//!
+
+mod header;
+
+pub use header::{EnvelopeConfig, EnvelopeFormat, ZstdConfig, MAGIC_NUMBERS};
+
+use crate::{
+    extension::ExtensionRegistry,
+    package::{Package, PackageEncodingError, PackageError},
+};
+use header::EnvelopeHeader;
+use std::io::BufRead;
+use std::io::Write;
+
+#[allow(unused_imports)]
+use itertools::Itertools as _;
+
+#[cfg(feature = "model_unstable")]
+use crate::import::ImportError;
+
+/// Read a HUGR envelope from a reader.
+///
+/// Returns the deserialized package and the configuration used to encode it.
+///
+/// Parameters:
+/// - `reader`: The reader to read the envelope from.
+/// - `registry`: An extension registry with additional extensions to use when
+///     decoding the HUGR, if they are not already included in the package.
+pub fn read_envelope(
+    mut reader: impl BufRead,
+    registry: &ExtensionRegistry,
+) -> Result<(EnvelopeConfig, Package), EnvelopeError> {
+    let header = EnvelopeHeader::read(&mut reader)?;
+
+    let package = match header.zstd {
+        #[cfg(feature = "zstd")]
+        true => read_impl(
+            std::io::BufReader::new(zstd::Decoder::new(reader)?),
+            header,
+            registry,
+        ),
+        #[cfg(not(feature = "zstd"))]
+        true => Err(EnvelopeError::ZstdUnsupported),
+        false => read_impl(reader, header, registry),
+    }?;
+    Ok((header.config(), package))
+}
+
+/// Write a HUGR package into an envelope, using the specified configuration.
+///
+/// It is recommended to use a buffered writer for better performance.
+/// See [`std::io::BufWriter`] for more information.
+pub fn write_envelope(
+    mut writer: impl Write,
+    package: &Package,
+    config: EnvelopeConfig,
+) -> Result<(), EnvelopeError> {
+    let header = config.make_header();
+    header.write(&mut writer)?;
+
+    match config.zstd {
+        #[cfg(feature = "zstd")]
+        Some(zstd) => {
+            let writer = zstd::Encoder::new(writer, zstd.level())?;
+            write_impl(writer, package, config)?;
+        }
+        #[cfg(not(feature = "zstd"))]
+        Some(_) => return Err(EnvelopeError::ZstdUnsupported),
+        None => write_impl(writer, package, config)?,
+    }
+
+    Ok(())
+}
+
+/// Error type for envelope operations.
+#[derive(derive_more::Display, derive_more::Error, Debug, derive_more::From)]
+#[non_exhaustive]
+pub enum EnvelopeError {
+    /// Bad magic number.
+    #[display(
+        "Bad magic number. expected '{:X}' found '{:X}'",
+        u64::from_be_bytes(*expected),
+        u64::from_be_bytes(*found)
+    )]
+    #[from(ignore)]
+    MagicNumber {
+        /// The expected magic number.
+        ///
+        /// See [`MAGIC_NUMBERS`].
+        expected: [u8; 8],
+        /// The magic number in the envelope.
+        found: [u8; 8],
+    },
+    /// The specified payload format is invalid.
+    #[display("Format descriptor {descriptor} is invalid.")]
+    #[from(ignore)]
+    InvalidFormatDescriptor {
+        /// The unsupported format.
+        descriptor: usize,
+    },
+    /// The specified payload format is not supported.
+    #[display("Payload format {format} is not supported.{}",
+        match feature {
+            Some(f) => format!(" This requires the '{f}' feature for `hugr`."),
+            None => "".to_string()
+        },
+    )]
+    #[from(ignore)]
+    FormatUnsupported {
+        /// The unsupported format.
+        format: EnvelopeFormat,
+        /// Optionally, the feature required to support this format.
+        feature: Option<&'static str>,
+    },
+    /// Not all envelope formats can be represented as ASCII.
+    ///
+    /// This error is used when trying to store the envelope into a string.
+    #[display("Envelope format {format} cannot be represented as ASCII.")]
+    #[from(ignore)]
+    NonASCIIFormat {
+        /// The unsupported format.
+        format: EnvelopeFormat,
+    },
+    /// Envelope encoding required zstd compression, but the feature is not enabled.
+    #[display("Zstd compression is not supported. This requires the 'zstd' feature for `hugr`.")]
+    #[from(ignore)]
+    ZstdUnsupported,
+    /// Tried to encode a package with multiple HUGRs, when only 1 was expected.
+    #[display(
+        "Packages with multiple HUGRs are currently unsupported. Tried to encode {count} HUGRs, when 1 was expected."
+    )]
+    #[from(ignore)]
+    MultipleHugrs {
+        /// The number of HUGRs in the package.
+        count: usize,
+    },
+    /// JSON serialization error.
+    SerdeError {
+        /// The source error.
+        source: serde_json::Error,
+    },
+    /// IO read/write error.
+    IO {
+        /// The source error.
+        source: std::io::Error,
+    },
+    /// Error decoding a package from the payload.
+    Package {
+        /// The source error.
+        source: PackageError,
+    },
+    /// Error writing a json package to the payload.
+    PackageEncoding {
+        /// The source error.
+        source: PackageEncodingError,
+    },
+    /// Error importing a HUGR from a hugr-model payload.
+    #[cfg(feature = "model_unstable")]
+    ModelImport {
+        /// The source error.
+        source: ImportError,
+    },
+    /// Error reading a HUGR model payload.
+    #[cfg(feature = "model_unstable")]
+    ModelRead {
+        /// The source error.
+        source: hugr_model::v0::binary::ReadError,
+    },
+    /// Error writing a HUGR model payload.
+    #[cfg(feature = "model_unstable")]
+    ModelWrite {
+        /// The source error.
+        source: hugr_model::v0::binary::WriteError,
+    },
+}
+
+/// Internal implementation of [`read_envelope`] to call with/without the zstd decompression wrapper.
+fn read_impl(
+    payload: impl BufRead,
+    header: EnvelopeHeader,
+    registry: &ExtensionRegistry,
+) -> Result<Package, EnvelopeError> {
+    match header.format {
+        #[allow(deprecated)]
+        EnvelopeFormat::PackageJson => Ok(Package::from_json_reader(payload, registry)?),
+        #[cfg(feature = "model_unstable")]
+        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
+            decode_model(payload, registry, header.format)
+        }
+        #[cfg(not(feature = "model_unstable"))]
+        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
+            Err(EnvelopeError::FormatUnsupported {
+                format: header.format,
+                feature: Some("model_unstable"),
+            })
+        }
+    }
+}
+
+/// Read a HUGR model payload from a reader.
+///
+/// Parameters:
+/// - `stream`: The reader to read the envelope from.
+/// - `extension_registry`: An extension registry with additional extensions to use when
+///   decoding the HUGR, if they are not already included in the package.
+/// - `format`: The format of the payload.
+#[cfg(feature = "model_unstable")]
+fn decode_model(
+    mut stream: impl BufRead,
+    extension_registry: &ExtensionRegistry,
+    format: EnvelopeFormat,
+) -> Result<Package, EnvelopeError> {
+    use crate::{import::import_hugr, Extension};
+    use hugr_model::v0::bumpalo::Bump;
+
+    if format.model_version() != Some(0) {
+        return Err(EnvelopeError::FormatUnsupported {
+            format,
+            feature: None,
+        });
+    }
+
+    let bump = Bump::default();
+    let module_list = hugr_model::v0::binary::read_from_reader(&mut stream, &bump)?;
+
+    let mut extension_registry = extension_registry.clone();
+    if format.append_extensions() {
+        let extra_extensions: Vec<Extension> =
+            serde_json::from_reader::<_, Vec<Extension>>(stream)?;
+        for ext in extra_extensions {
+            extension_registry.register_updated(ext);
+        }
+    }
+
+    // TODO: Import multiple hugrs from the model?
+    let hugr = import_hugr(&module_list, &extension_registry)?;
+    let mut package = Package::new([hugr])?;
+
+    // Ensure the package contains all extensions from the registry,
+    // even if they were not used in the hugr.
+    package.extensions = extension_registry;
+
+    Ok(package)
+}
+
+/// Internal implementation of [`write_envelope`] to call with/without the zstd compression wrapper.
+fn write_impl(
+    writer: impl Write,
+    package: &Package,
+    config: EnvelopeConfig,
+) -> Result<(), EnvelopeError> {
+    match config.format {
+        #[allow(deprecated)]
+        EnvelopeFormat::PackageJson => package.to_json_writer(writer)?,
+        #[cfg(feature = "model_unstable")]
+        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
+            encode_model(writer, package, config.format)?
+        }
+        #[cfg(not(feature = "model_unstable"))]
+        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
+            return Err(EnvelopeError::FormatUnsupported {
+                format: config.format,
+                feature: Some("model_unstable"),
+            })
+        }
+    }
+    Ok(())
+}
+
+#[cfg(feature = "model_unstable")]
+fn encode_model(
+    mut writer: impl Write,
+    package: &Package,
+    format: EnvelopeFormat,
+) -> Result<(), EnvelopeError> {
+    use crate::export::export_hugr;
+    use hugr_model::v0::{binary::write_to_writer, bumpalo::Bump};
+
+    if format.model_version() != Some(0) {
+        return Err(EnvelopeError::FormatUnsupported {
+            format,
+            feature: None,
+        });
+    }
+
+    // TODO: Export multiple hugrs to the model?
+    if package.modules.len() != 1 {
+        return Err(EnvelopeError::MultipleHugrs {
+            count: package.modules.len(),
+        });
+    }
+    let bump = Bump::default();
+    let module = export_hugr(&package.modules[0], &bump);
+    write_to_writer(&module, &mut writer)?;
path: hugr-core/src/envelope.rs
user: login: doug-q
body: At some point I added a `module_list` in the capnproto and accompanying reader/writer methods. I must have reverted this.
created_at: 2025-03-14T14:38:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,182 @@
+"""Generic serialization of HUGRs and Packages.
+
+The format is designed to be extensible and backwards-compatible. It
+consists of a header declaring the format used to encode the HUGR, followed
+by the encoded HUGR itself.
+
+## Payload formats
+
+The envelope may encode the HUGR in different formats, listed in
+:class:`hugr.envelope.EnvelopeFormat`. The payload may also be compressed with zstd.
+
+Some formats can be represented as ASCII, as indicated by the
+:meth:`hugr.envelope.EnvelopeFormat.ascii_printable` method. When this is the case, the
+whole envelope can be stored in a string.
+
+## Envelope header
+
+The binary header format is 10 bytes, with the following fields:
+
+| Field  | Size (bytes) | Description |
+|--------|--------------|-------------|
+| Magic  | 8            | :class:`hugr.envelope.MAGIC_NUMBERS` constant identifying the envelope format. |
+| Format | 1            | :class:`hugr.envelope.EnvelopeFormat` describing the payload format. |
+| Flags  | 1            | Additional configuration flags. |
+
+Flags:
+
+- Bit 0: Whether the payload is compressed with zstd.
+- Bits 1-5: Reserved for future use.
+- Bit 7,6: Constant "01" to make some headers ascii-printable.
+"""  # noqa: E501
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import TYPE_CHECKING
+
+import hugr._serialization.extension as ext_s
+
+if TYPE_CHECKING:
+    from hugr.package import Package
+
+# This is a hard-coded magic number that identifies the start of a HUGR envelope.
+MAGIC_NUMBERS = b"HUGRiHJv"
+
+
+def make_envelope(package: Package, config: EnvelopeConfig) -> bytes:
+    """Encode a HUGR package into an envelope, using the given configuration."""
+    envelope = bytearray(config._make_header().to_bytes())
+
+    if config.zstd is not None:
+        msg = "Zstd compression is not supported yet."
path: hugr-py/src/hugr/envelope.py
user: login: doug-q
body: I see, it makes sense to not be the default in the rust package then!
created_at: 2025-03-14T14:46:16Z
author_association: COLLABORATOR
diff_hunk: @@ -46,17 +51,20 @@ itertools = "0.14.0"
 jsonschema = "0.29.0"
 lazy_static = "1.4.0"
 num-rational = "0.4.1"
+packed_struct = "0.10.1"
path: Cargo.toml
user: login: doug-q
body: we're not using this are we?
created_at: 2025-03-14T14:52:10Z
author_association: COLLABORATOR
diff_hunk: @@ -68,14 +76,16 @@ assert_fs = "1.1.1"
 predicates = "3.1.0"
 indexmap = "2.3.0"
 fxhash = "0.2.1"
-bumpalo = { version = "3.16.0" }
+bumpalo = "3.16.0"
 pathsearch = "0.2.0"
 base64 = "0.22.1"
 ordered-float = "5.0.0"
 pest = "2.7.12"
 pest_derive = "2.7.12"
 pretty = "0.12.3"
 pretty_assertions = "1.4.1"
+zerocopy = "0.7.35"
path: Cargo.toml
user: login: doug-q
body: or this
created_at: 2025-03-14T14:52:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,85 @@
+//! Input/output arguments for the HUGR CLI.
+
+use clio::Input;
+use hugr::envelope::read_envelope;
+use hugr::extension::ExtensionRegistry;
+use hugr::package::Package;
+use hugr::{Extension, Hugr};
+use std::io::BufReader;
+use std::path::PathBuf;
+
+use crate::CliError;
+
+/// Arguments for reading a HUGR input.
+#[derive(Debug, clap::Args)]
+pub struct HugrInputArgs {
+    /// Input file. Defaults to `-` for stdin.
+    #[arg(value_parser, default_value = "-", help_heading = "Input")]
path: hugr-cli/src/hugr_io.rs
user: login: aborgna-q
body: This is formatting of the help output, independent from `group`.
I haven't found a way to avoid repeating it -.-

Current help output:
```
$ hugr validate -h
Validate and visualize a HUGR file

Usage: hugr validate [OPTIONS] [INPUT]

Options:
  -v, --verbose...  Increase logging verbosity
  -q, --quiet...    Decrease logging verbosity
  -h, --help        Print help (see more with '--help')
  -V, --version     Print version

Input:
      --no-std                   Don't use standard extensions when validating hugrs. Prelude is still used.
  -e, --extensions <EXTENSIONS>  Paths to serialised extensions to validate against.
      --hugr-json                Read the input as a HUGR JSON file instead of an envelope
  [INPUT]                    Input file. Defaults to `-` for stdin [default: -]
```
created_at: 2025-03-14T15:24:53Z
author_association: COLLABORATOR
diff_hunk: @@ -29,30 +30,35 @@ name = "model"
 required-features = ["model_unstable"]
 
 [dependencies]
-portgraph = { workspace = true, features = ["serde", "petgraph"] }
-thiserror = { workspace = true }
-regex = { workspace = true }
+hugr-model = { version = "0.17.1", path = "../hugr-model", optional = true }
+
 cgmath = { workspace = true, features = ["serde"] }
-downcast-rs = { workspace = true }
-# Rc used here for Extension, but unfortunately we must turn the feature on globally
-serde = { workspace = true, features = ["derive", "rc"] }
-serde_yaml = { workspace = true, optional = true }
-typetag = { workspace = true }
-smol_str = { workspace = true, features = ["serde"] }
+delegate = { workspace = true }
 derive_more = { workspace = true, features = ["display", "error", "from"] }
-itertools = { workspace = true }
-html-escape = { workspace = true }
+downcast-rs = { workspace = true }
 enum_dispatch = { workspace = true }
+fxhash.workspace = true
+html-escape = { workspace = true }
+indexmap.workspace = true
+itertools = { workspace = true }
 lazy_static = { workspace = true }
+packed_struct = { workspace = true, features = ["byte_types_64"] }
+paste = { workspace = true }
 petgraph = { workspace = true }
+portgraph = { workspace = true, features = ["serde", "petgraph"] }
+regex = { workspace = true }
+# Rc used here for Extension, but unfortunately we must turn the feature on globally
path: hugr-core/Cargo.toml
user: login: aborgna-q
body: The comment was there before, but yes.
The "unfortunate" part is that instead of getting a warning from serde when deriving serialization of an object containing an `Rc` or `Arc`, things will work but maybe have unexpected behaviour since serde [doesn't deduplicate values](https://serde.rs/feature-flags.html#rc).

It's not super terrible though.
created_at: 2025-03-14T15:29:15Z
author_association: COLLABORATOR

PR comments:
user: login: hugrbot
created_at: 2025-03-10T09:49:42Z
body: This PR contains breaking changes to the public Rust API.

<details>
  <summary>cargo-semver-checks summary</summary>
  
  ```
  
--- failure enum_variant_missing: pub enum variant removed or renamed ---

Description:
A publicly-visible enum has at least one variant that is no longer available under its prior name. It may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.40.0/src/lints/enum_variant_missing.ron

Failed in:
  variant CliError::HUGRLoad, previously in file /home/runner/work/hugr/hugr/BASELINE_BRANCH/hugr-cli/src/lib.rs:49

--- failure struct_pub_field_missing: pub struct's pub field removed or renamed ---

Description:
A publicly-visible struct has at least one public field that is no longer available under its prior name. It may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.40.0/src/lints/struct_pub_field_missing.ron

Failed in:
  field hugr_args of struct MermaidArgs, previously in file /home/runner/work/hugr/hugr/BASELINE_BRANCH/hugr-cli/src/mermaid.rs:17
  field hugr_args of struct ValArgs, previously in file /home/runner/work/hugr/hugr/BASELINE_BRANCH/hugr-cli/src/validate.rs:18

--- warning enum_missing: pub enum removed or renamed ---

Description:
A publicly-visible enum cannot be imported by its prior path. A `pub use` may have been removed, or the enum itself may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.40.0/src/lints/enum_missing.ron

Failed in:
  enum hugr_cli::PackageOrHugr, previously in file /home/runner/work/hugr/hugr/BASELINE_BRANCH/hugr-cli/src/lib.rs:83

--- warning struct_missing: pub struct removed or renamed ---

Description:
A publicly-visible struct cannot be imported by its prior path. A `pub use` may have been removed, or the struct itself may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.40.0/src/lints/struct_missing.ron

Failed in:
  struct hugr_cli::HugrArgs, previously in file /home/runner/work/hugr/hugr/BASELINE_BRANCH/hugr-cli/src/lib.rs:57

--- failure type_method_marked_deprecated: type method #[deprecated] added ---

Description:
A type method is now #[deprecated]. Downstream crates will get a compiler warning when using this method.
        ref: https://doc.rust-lang.org/reference/attributes/diagnostics.html#the-deprecated-attribute
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.40.0/src/lints/type_method_marked_deprecated.ron

Failed in:
  method hugr_core::package::Package::from_json_reader in /home/runner/work/hugr/hugr/PR_BRANCH/hugr-core/src/package.rs:183
  method hugr_core::package::Package::from_json in /home/runner/work/hugr/hugr/PR_BRANCH/hugr-core/src/package.rs:245
  method hugr_core::package::Package::from_json_file in /home/runner/work/hugr/hugr/PR_BRANCH/hugr-core/src/package.rs:261
  method hugr_core::package::Package::to_json_writer in /home/runner/work/hugr/hugr/PR_BRANCH/hugr-core/src/package.rs:277
  method hugr_core::package::Package::to_json in /home/runner/work/hugr/hugr/PR_BRANCH/hugr-core/src/package.rs:290
  method hugr_core::package::Package::to_json_file in /home/runner/work/hugr/hugr/PR_BRANCH/hugr-core/src/package.rs:301

  ```
  
</details>
<!-- Sticky Pull Request Commentrs-semver-checks -->
author_association: COLLABORATOR
user: login: codecov[bot]
created_at: 2025-03-13T15:03:41Z
body: ## [Codecov](https://app.codecov.io/gh/CQCL/hugr/pull/1958?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) Report
Attention: Patch coverage is `74.81108%` with `100 lines` in your changes missing coverage. Please review.
> Project coverage is 83.64%. Comparing base [(`5b89677`)](https://app.codecov.io/gh/CQCL/hugr/commit/5b89677c021bf4326600efede3369f1c74c7c7e3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) to head [(`3c04532`)](https://app.codecov.io/gh/CQCL/hugr/commit/3c045320d4d5e7fe17dfeaf180fc2aab69e6a48b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL).
> Report is 4 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/CQCL/hugr/pull/1958?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) | Patch % | Lines |
|---|---|---|
| [hugr-core/src/envelope.rs](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&filepath=hugr-core%2Fsrc%2Fenvelope.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL#diff-aHVnci1jb3JlL3NyYy9lbnZlbG9wZS5ycw==) | 71.17% | [18 Missing and 14 partials :warning: ](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) |
| [hugr-py/src/hugr/envelope.py](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&filepath=hugr-py%2Fsrc%2Fhugr%2Fenvelope.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL#diff-aHVnci1weS9zcmMvaHVnci9lbnZlbG9wZS5weQ==) | 77.50% | [18 Missing :warning: ](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) |
| [hugr-core/src/envelope/header.rs](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&filepath=hugr-core%2Fsrc%2Fenvelope%2Fheader.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL#diff-aHVnci1jb3JlL3NyYy9lbnZlbG9wZS9oZWFkZXIucnM=) | 82.95% | [8 Missing and 7 partials :warning: ](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) |
| [hugr-cli/src/mermaid.rs](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&filepath=hugr-cli%2Fsrc%2Fmermaid.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL#diff-aHVnci1jbGkvc3JjL21lcm1haWQucnM=) | 43.47% | [10 Missing and 3 partials :warning: ](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) |
| [hugr-cli/src/hugr\_io.rs](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&filepath=hugr-cli%2Fsrc%2Fhugr_io.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL#diff-aHVnci1jbGkvc3JjL2h1Z3JfaW8ucnM=) | 65.38% | [4 Missing and 5 partials :warning: ](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) |
| [hugr-py/src/hugr/package.py](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&filepath=hugr-py%2Fsrc%2Fhugr%2Fpackage.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL#diff-aHVnci1weS9zcmMvaHVnci9wYWNrYWdlLnB5) | 70.00% | [6 Missing :warning: ](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) |
| [hugr-cli/src/validate.rs](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&filepath=hugr-cli%2Fsrc%2Fvalidate.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL#diff-aHVnci1jbGkvc3JjL3ZhbGlkYXRlLnJz) | 58.33% | [3 Missing and 2 partials :warning: ](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) |
| [hugr-core/src/package.rs](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&filepath=hugr-core%2Fsrc%2Fpackage.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL#diff-aHVnci1jb3JlL3NyYy9wYWNrYWdlLnJz) | 93.75% | [0 Missing and 2 partials :warning: ](https://app.codecov.io/gh/CQCL/hugr/pull/1958?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1958      +/-   ##
==========================================
- Coverage   83.83%   83.64%   -0.19%     
==========================================
  Files         203      208       +5     
  Lines       38357    38956     +599     
  Branches    35167    35666     +499     
==========================================
+ Hits        32156    32586     +430     
- Misses       4405     4530     +125     
- Partials     1796     1840      +44     
```

| [Flag](https://app.codecov.io/gh/CQCL/hugr/pull/1958/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) | Coverage Δ | |
|---|---|---|
| [python](https://app.codecov.io/gh/CQCL/hugr/pull/1958/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) | `91.82% <76.00%> (-0.56%)` | :arrow_down: |
| [rust](https://app.codecov.io/gh/CQCL/hugr/pull/1958/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL) | `82.89% <74.41%> (-0.17%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/CQCL/hugr/pull/1958?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CQCL).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: aborgna-q
created_at: 2025-03-14T15:35:11Z
body: I'll add an issue to add zstd to the python side and make it a runtime dependency by default after this.

I agree that it should be the default _sometimes_, although the "text" config will remain uncompressed json for now (until we replace it with hugr-model's text repr ?).
author_association: COLLABORATOR