diff --git a/drv/cosmo-hf/src/main.rs b/drv/cosmo-hf/src/main.rs
index 4b06280947..1a077fc11b 100644
--- a/drv/cosmo-hf/src/main.rs
+++ b/drv/cosmo-hf/src/main.rs
@@ -36,25 +36,9 @@ enum Trace {
 
 counted_ringbuf!(Trace, 32, Trace::None);
 
-/// Size in bytes of a single page of data (i.e., the max length of slice we
-/// accept for `page_program()` and `read_memory()`).
-///
-/// This value is really a property of the flash we're talking to and not this
-/// driver, but it's correct for all our current parts. If that changes, this
-/// will need to change to something more flexible.
-pub const PAGE_SIZE_BYTES: usize = 256;
-
-/// Size in bytes of a single sector of data (i.e., the size of the data erased
-/// by a call to `sector_erase()`).
-///
-/// This value is really a property of the flash we're talking to and not this
-/// driver, but it's correct for all our current parts. If that changes, this
-/// will need to change to something more flexible.
-///
-/// **Note:** the datasheet refers to a "sector" as a 4K block, but also
-/// supports 64K block erases, so we call the latter a sector to match the
-/// behavior of the Gimlet host flash driver.
-pub const SECTOR_SIZE_BYTES: u32 = 65_536;
+// Re-export constants from the generic host flash API
+pub use drv_hf_api::PAGE_SIZE_BYTES;
+pub const SECTOR_SIZE_BYTES: u32 = drv_hf_api::SECTOR_SIZE_BYTES as u32;
 
 /// Total flash size is 128 MiB
 pub const FLASH_SIZE_BYTES: u32 = 128 * 1024 * 1024;
diff --git a/drv/spartan7-loader/grapefruit/README.md b/drv/spartan7-loader/grapefruit/README.md
index 0328b9a141..534eb05c4a 100644
--- a/drv/spartan7-loader/grapefruit/README.md
+++ b/drv/spartan7-loader/grapefruit/README.md
@@ -1,2 +1,2 @@
-generated from [this commit](https://github.com/oxidecomputer/quartz/commit/e31772323d13f15802554ae3daa5061f9c9290cc)
-([this CI run](https://github.com/oxidecomputer/quartz/actions/runs/13228105732/job/36921564266))
+generated from [this commit](https://github.com/oxidecomputer/quartz/commit/cf9dfbe5d7b4bfd4bf2c0dac4f3ffe852ff8d20e)
+([this CI run](https://github.com/oxidecomputer/quartz/actions/runs/13502683270/job/37724885653))
diff --git a/drv/spartan7-loader/grapefruit/grapefruit.bz2 b/drv/spartan7-loader/grapefruit/grapefruit.bz2
index a5f9ffff2e..a8476667c6 100644
Binary files a/drv/spartan7-loader/grapefruit/grapefruit.bz2 and b/drv/spartan7-loader/grapefruit/grapefruit.bz2 differ
diff --git a/lib/host-sp-messages/src/lib.rs b/lib/host-sp-messages/src/lib.rs
index 5981290bb1..a93a0a72b3 100644
--- a/lib/host-sp-messages/src/lib.rs
+++ b/lib/host-sp-messages/src/lib.rs
@@ -123,6 +123,10 @@ pub enum HostToSp {
         // We use a raw `u8` here for the same reason as in `KeyLookup` above.
         key: u8,
     },
+    // APOB is followed by a binary data blob, which should be written to flash
+    APOB {
+        offset: u64,
+    },
 }
 
 /// The order of these cases is critical! We are relying on hubpack's encoding
@@ -185,6 +189,7 @@ pub enum SpToHost {
         name: [u8; 32],
     },
     KeySetResult(#[count(children)] KeySetResult),
+    APOBResult(u8),
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq, num_derive::FromPrimitive)]
diff --git a/task/host-sp-comms/src/main.rs b/task/host-sp-comms/src/main.rs
index 70c7729568..150a049540 100644
--- a/task/host-sp-comms/src/main.rs
+++ b/task/host-sp-comms/src/main.rs
@@ -134,6 +134,11 @@ enum Trace {
         #[count(children)]
         message: SpToHost,
     },
+    APOBWriteError {
+        offset: u64,
+        #[count(children)]
+        err: APOBError,
+    },
 }
 
 counted_ringbuf!(Trace, 20, Trace::None);
@@ -160,6 +165,31 @@ enum Timers {
     TxPeriodicZeroByte,
 }
 
+#[derive(Copy, Clone, Debug, Eq, PartialEq, counters::Count)]
+enum APOBError {
+    OffsetOverflow {
+        offset: u64,
+    },
+    NotErased {
+        offset: u32,
+    },
+    EraseFailed {
+        offset: u32,
+        #[count(children)]
+        err: drv_hf_api::HfError,
+    },
+    WriteFailed {
+        offset: u32,
+        #[count(children)]
+        err: drv_hf_api::HfError,
+    },
+    ReadFailed {
+        offset: u32,
+        #[count(children)]
+        err: drv_hf_api::HfError,
+    },
+}
+
 #[export_name = "main"]
 fn main() -> ! {
     let mut server = ServerImpl::claim_static_resources();
@@ -967,6 +997,15 @@ impl ServerImpl {
                     }),
                 }
             }
+            HostToSp::APOB { offset } => {
+                Some(match Self::apob_write(&self.hf, offset, data) {
+                    Ok(()) => SpToHost::APOBResult(0),
+                    Err(err) => {
+                        ringbuf_entry!(Trace::APOBWriteError { offset, err });
+                        SpToHost::APOBResult(1)
+                    }
+                })
+            }
         };
 
         if let Some(response) = response {
@@ -995,6 +1034,51 @@ impl ServerImpl {
         Ok(())
     }
 
+    /// Write data to the bonus region of flash
+    ///
+    /// This does not take `&self` because we need to force a split borrow
+    fn apob_write(
+        hf: &HostFlash,
+        mut offset: u64,
+        data: &[u8],
+    ) -> Result<(), APOBError> {
+        for chunk in data.chunks(drv_hf_api::PAGE_SIZE_BYTES) {
+            Self::apob_write_page(
+                hf,
+                offset
+                    .try_into()
+                    .map_err(|_| APOBError::OffsetOverflow { offset })?,
+                chunk,
+            )?;
+            offset += chunk.len() as u64;
+        }
+        Ok(())
+    }
+
+    /// Write a single page of data to the bonus region of flash
+    ///
+    /// This does not take `&self` because we need to force a split borrow
+    fn apob_write_page(
+        hf: &HostFlash,
+        offset: u32,
+        data: &[u8],
+    ) -> Result<(), APOBError> {
+        if offset as usize % drv_hf_api::SECTOR_SIZE_BYTES == 0 {
+            hf.bonus_sector_erase(offset)
+                .map_err(|err| APOBError::EraseFailed { offset, err })?;
+        } else {
+            // Read back the page and confirm that it's all empty
+            let mut scratch = [0u8; drv_hf_api::PAGE_SIZE_BYTES];
+            hf.bonus_read(offset, &mut scratch[..data.len()])
+                .map_err(|err| APOBError::ReadFailed { offset, err })?;
+            if !scratch[..data.len()].iter().all(|b| *b == 0xFF) {
+                return Err(APOBError::NotErased { offset });
+            }
+        }
+        hf.bonus_page_program(offset, data)
+            .map_err(|err| APOBError::WriteFailed { offset, err })
+    }
+
     fn handle_sprot(
         &mut self,
         sequence: u64,