diff --git a/fabric-transfer-api-v1/build.gradle b/fabric-transfer-api-v1/build.gradle
index 9f81ef61b..042140be8 100644
--- a/fabric-transfer-api-v1/build.gradle
+++ b/fabric-transfer-api-v1/build.gradle
@@ -11,5 +11,6 @@ moduleDependencies(project, [
 testDependencies(project, [
 	':fabric-object-builder-api-v1',
 	':fabric-rendering-v1',
-	':fabric-resource-loader-v0'
+	':fabric-resource-loader-v0',
+	':fabric-command-api-v2'
 ])
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemStorage.java
index b51d15d96..f4edcd71b 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemStorage.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemStorage.java
@@ -27,17 +27,22 @@ import net.minecraft.block.entity.ChestBlockEntity;
 import net.minecraft.inventory.Inventory;
 import net.minecraft.inventory.SidedInventory;
 import net.minecraft.inventory.SimpleInventory;
+import net.minecraft.item.Items;
 import net.minecraft.util.Identifier;
 import net.minecraft.util.math.Direction;
 
 import net.fabricmc.fabric.api.lookup.v1.block.BlockApiLookup;
+import net.fabricmc.fabric.api.lookup.v1.item.ItemApiLookup;
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
 import net.fabricmc.fabric.api.transfer.v1.item.base.SingleStackStorage;
 import net.fabricmc.fabric.api.transfer.v1.storage.SlottedStorage;
 import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
 import net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedSlottedStorage;
 import net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedStorage;
 import net.fabricmc.fabric.api.transfer.v1.storage.base.SidedStorageBlockEntity;
+import net.fabricmc.fabric.impl.transfer.item.BundleContentsStorage;
 import net.fabricmc.fabric.impl.transfer.item.ComposterWrapper;
+import net.fabricmc.fabric.impl.transfer.item.ContainerComponentStorage;
 import net.fabricmc.fabric.mixin.transfer.DoubleInventoryAccessor;
 
 /**
@@ -80,6 +85,16 @@ public final class ItemStorage {
 	public static final BlockApiLookup<Storage<ItemVariant>, @Nullable Direction> SIDED =
 			BlockApiLookup.get(Identifier.of("fabric", "sided_item_storage"), Storage.asClass(), Direction.class);
 
+	/**
+	 * Item access to item variant storages.
+	 * Querying should happen through {@link ContainerItemContext#find}.
+	 *
+	 * <p>This may be queried both client-side and server-side.
+	 * Returned APIs should behave the same regardless of the logical side.
+	 */
+	public static final ItemApiLookup<Storage<ItemVariant>, ContainerItemContext> ITEM =
+			ItemApiLookup.get(Identifier.of("fabric", "item_storage"), Storage.asClass(), ContainerItemContext.class);
+
 	private ItemStorage() {
 	}
 
@@ -128,5 +143,47 @@ public final class ItemStorage {
 
 			return inventoryToWrap != null ? InventoryStorage.of(inventoryToWrap, direction) : null;
 		});
+
+		ItemStorage.ITEM.registerForItems(
+				(itemStack, context) -> new ContainerComponentStorage(context, 27),
+				Items.SHULKER_BOX,
+				Items.WHITE_SHULKER_BOX,
+				Items.ORANGE_SHULKER_BOX,
+				Items.MAGENTA_SHULKER_BOX,
+				Items.LIGHT_BLUE_SHULKER_BOX,
+				Items.YELLOW_SHULKER_BOX,
+				Items.LIME_SHULKER_BOX,
+				Items.PINK_SHULKER_BOX,
+				Items.GRAY_SHULKER_BOX,
+				Items.LIGHT_GRAY_SHULKER_BOX,
+				Items.CYAN_SHULKER_BOX,
+				Items.PURPLE_SHULKER_BOX,
+				Items.BLUE_SHULKER_BOX,
+				Items.BROWN_SHULKER_BOX,
+				Items.GREEN_SHULKER_BOX,
+				Items.RED_SHULKER_BOX,
+				Items.BLACK_SHULKER_BOX
+		);
+
+		ItemStorage.ITEM.registerForItems(
+				(itemStack, context) -> new BundleContentsStorage(context),
+				Items.BUNDLE,
+				Items.WHITE_BUNDLE,
+				Items.ORANGE_BUNDLE,
+				Items.MAGENTA_BUNDLE,
+				Items.LIGHT_BLUE_BUNDLE,
+				Items.YELLOW_BUNDLE,
+				Items.LIME_BUNDLE,
+				Items.PINK_BUNDLE,
+				Items.GRAY_BUNDLE,
+				Items.LIGHT_GRAY_BUNDLE,
+				Items.CYAN_BUNDLE,
+				Items.PURPLE_BUNDLE,
+				Items.BLUE_BUNDLE,
+				Items.BROWN_BUNDLE,
+				Items.GREEN_BUNDLE,
+				Items.RED_BUNDLE,
+				Items.BLACK_BUNDLE
+		);
 	}
 }
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/BundleContentsStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/BundleContentsStorage.java
new file mode 100644
index 000000000..358444e7b
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/BundleContentsStorage.java
@@ -0,0 +1,191 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.item;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.commons.lang3.math.Fraction;
+
+import net.minecraft.component.ComponentChanges;
+import net.minecraft.component.DataComponentTypes;
+import net.minecraft.component.type.BundleContentsComponent;
+import net.minecraft.item.Item;
+import net.minecraft.item.ItemStack;
+
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
+import net.fabricmc.fabric.api.transfer.v1.storage.StorageView;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+import net.fabricmc.fabric.mixin.transfer.BundleContentsComponentAccessor;
+
+public class BundleContentsStorage implements Storage<ItemVariant> {
+	private final ContainerItemContext ctx;
+	private final List<BundleSlotWrapper> slotCache = new ArrayList<>();
+	private List<StorageView<ItemVariant>> slots = List.of();
+	private final Item originalItem;
+
+	public BundleContentsStorage(ContainerItemContext ctx) {
+		this.ctx = ctx;
+		this.originalItem = ctx.getItemVariant().getItem();
+	}
+
+	private boolean updateStack(ComponentChanges changes, TransactionContext transaction) {
+		ItemVariant newVariant = ctx.getItemVariant().withComponentChanges(changes);
+		return ctx.exchange(newVariant, 1, transaction) > 0;
+	}
+
+	@Override
+	public long insert(ItemVariant resource, long maxAmount, TransactionContext transaction) {
+		StoragePreconditions.notBlankNotNegative(resource, maxAmount);
+
+		if (!isStillValid()) return 0;
+
+		if (maxAmount > Integer.MAX_VALUE) maxAmount = Integer.MAX_VALUE;
+
+		ItemStack stack = resource.toStack((int) maxAmount);
+
+		if (!BundleContentsComponent.canBeBundled(stack)) return 0;
+
+		var builder = new BundleContentsComponent.Builder(bundleContents());
+
+		int inserted = builder.add(stack);
+
+		if (inserted == 0) return 0;
+
+		ComponentChanges changes = ComponentChanges.builder()
+				.add(DataComponentTypes.BUNDLE_CONTENTS, builder.build())
+				.build();
+
+		if (!updateStack(changes, transaction)) return 0;
+
+		return inserted;
+	}
+
+	@Override
+	public long extract(ItemVariant resource, long maxAmount, TransactionContext transaction) {
+		StoragePreconditions.notNegative(maxAmount);
+
+		if (!isStillValid()) return 0;
+
+		updateSlotsIfNeeded();
+
+		long amount = 0;
+
+		for (StorageView<ItemVariant> slot : slots) {
+			amount += slot.extract(resource, maxAmount - amount, transaction);
+			if (amount == maxAmount) break;
+		}
+
+		return amount;
+	}
+
+	@Override
+	public Iterator<StorageView<ItemVariant>> iterator() {
+		updateSlotsIfNeeded();
+
+		return slots.iterator();
+	}
+
+	private boolean isStillValid() {
+		return ctx.getItemVariant().getItem() == originalItem;
+	}
+
+	private void updateSlotsIfNeeded() {
+		int bundleSize = bundleContents().size();
+
+		if (slots.size() != bundleSize) {
+			while (bundleSize > slotCache.size()) {
+				slotCache.add(new BundleSlotWrapper(slotCache.size()));
+			}
+
+			slots = Collections.unmodifiableList(slotCache.subList(0, bundleSize));
+		}
+	}
+
+	BundleContentsComponent bundleContents() {
+		return ctx.getItemVariant().getComponentMap().getOrDefault(DataComponentTypes.BUNDLE_CONTENTS, BundleContentsComponent.DEFAULT);
+	}
+
+	private class BundleSlotWrapper implements StorageView<ItemVariant> {
+		private final int index;
+
+		private BundleSlotWrapper(int index) {
+			this.index = index;
+		}
+
+		private ItemStack getStack() {
+			if (bundleContents().size() <= index) return ItemStack.EMPTY;
+
+			return ((List<ItemStack>) bundleContents().iterate()).get(index);
+		}
+
+		@Override
+		public long extract(ItemVariant resource, long maxAmount, TransactionContext transaction) {
+			StoragePreconditions.notNegative(maxAmount);
+
+			if (!BundleContentsStorage.this.isStillValid()) return 0;
+			if (bundleContents().size() <= index) return 0;
+			if (!resource.matches(getStack())) return 0;
+
+			var stacksCopy = new ArrayList<>((Collection<ItemStack>) bundleContents().iterateCopy());
+
+			int extracted = (int) Math.min(stacksCopy.get(index).getCount(), maxAmount);
+
+			stacksCopy.get(index).decrement(extracted);
+			if (stacksCopy.get(index).isEmpty()) stacksCopy.remove(index);
+
+			ComponentChanges changes = ComponentChanges.builder()
+					.add(DataComponentTypes.BUNDLE_CONTENTS, new BundleContentsComponent(stacksCopy))
+					.build();
+
+			if (!updateStack(changes, transaction)) return 0;
+
+			return extracted;
+		}
+
+		@Override
+		public boolean isResourceBlank() {
+			return getStack().isEmpty();
+		}
+
+		@Override
+		public ItemVariant getResource() {
+			return ItemVariant.of(getStack());
+		}
+
+		@Override
+		public long getAmount() {
+			return getStack().getCount();
+		}
+
+		@Override
+		public long getCapacity() {
+			Fraction remainingSpace = Fraction.ONE.subtract(bundleContents().getOccupancy());
+			int extraAllowed = Math.max(
+					remainingSpace.divideBy(BundleContentsComponentAccessor.getOccupancy(getStack())).intValue(),
+					0
+			);
+			return getAmount() + extraAllowed;
+		}
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ContainerComponentStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ContainerComponentStorage.java
new file mode 100644
index 000000000..4a483d0f0
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ContainerComponentStorage.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.item;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import net.minecraft.component.ComponentChanges;
+import net.minecraft.component.DataComponentTypes;
+import net.minecraft.component.type.ContainerComponent;
+import net.minecraft.item.Item;
+import net.minecraft.item.ItemStack;
+
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedSlottedStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+import net.fabricmc.fabric.mixin.transfer.ContainerComponentAccessor;
+
+public class ContainerComponentStorage extends CombinedSlottedStorage<ItemVariant, SingleSlotStorage<ItemVariant>> {
+	final ContainerItemContext ctx;
+	private final Item originalItem;
+
+	public ContainerComponentStorage(ContainerItemContext ctx, int slots) {
+		super(Collections.emptyList());
+		this.ctx = ctx;
+		this.originalItem = ctx.getItemVariant().getItem();
+
+		List<ContainerSlotWrapper> backingList = new ArrayList<>(slots);
+
+		for (int i = 0; i < slots; i++) {
+			backingList.add(new ContainerSlotWrapper(i));
+		}
+
+		parts = Collections.unmodifiableList(backingList);
+	}
+
+	ContainerComponent container() {
+		return ctx.getItemVariant().getComponentMap().getOrDefault(DataComponentTypes.CONTAINER, ContainerComponent.DEFAULT);
+	}
+
+	ContainerComponentAccessor containerAccessor() {
+		return (ContainerComponentAccessor) (Object) container();
+	}
+
+	private boolean isStillValid() {
+		return ctx.getItemVariant().getItem() == originalItem;
+	}
+
+	private class ContainerSlotWrapper implements SingleSlotStorage<ItemVariant> {
+		final int slot;
+
+		ContainerSlotWrapper(int slot) {
+			this.slot = slot;
+		}
+
+		private ItemStack getStack() {
+			List<ItemStack> stacks = ContainerComponentStorage.this.containerAccessor().fabric_getStacks();
+
+			if (stacks.size() <= slot) return ItemStack.EMPTY;
+
+			return stacks.get(slot);
+		}
+
+		protected boolean setStack(ItemStack stack, TransactionContext transaction) {
+			List<ItemStack> stacks = ContainerComponentStorage.this.container().stream().collect(Collectors.toList());
+
+			while (stacks.size() <= slot) stacks.add(ItemStack.EMPTY);
+
+			stacks.set(slot, stack);
+
+			ContainerItemContext ctx = ContainerComponentStorage.this.ctx;
+
+			ItemVariant newVariant = ctx.getItemVariant().withComponentChanges(ComponentChanges.builder()
+							.add(DataComponentTypes.CONTAINER, ContainerComponent.fromStacks(stacks))
+							.build());
+
+			return ctx.exchange(newVariant, 1, transaction) == 1;
+		}
+
+		@Override
+		public long insert(ItemVariant insertedVariant, long maxAmount, TransactionContext transaction) {
+			StoragePreconditions.notBlankNotNegative(insertedVariant, maxAmount);
+
+			if (!ContainerComponentStorage.this.isStillValid()) return 0;
+
+			ItemStack currentStack = getStack();
+
+			if ((insertedVariant.matches(currentStack) || currentStack.isEmpty()) && insertedVariant.getItem().canBeNested()) {
+				int insertedAmount = (int) Math.min(maxAmount, getCapacity() - currentStack.getCount());
+
+				if (insertedAmount > 0) {
+					currentStack = getStack().copy();
+
+					if (currentStack.isEmpty()) {
+						currentStack = insertedVariant.toStack(insertedAmount);
+					} else {
+						currentStack.increment(insertedAmount);
+					}
+
+					if (!setStack(currentStack, transaction)) return 0;
+
+					return insertedAmount;
+				}
+			}
+
+			return 0;
+		}
+
+		@Override
+		public long extract(ItemVariant variant, long maxAmount, TransactionContext transaction) {
+			StoragePreconditions.notBlankNotNegative(variant, maxAmount);
+
+			if (!ContainerComponentStorage.this.isStillValid()) return 0;
+
+			ItemStack currentStack = getStack();
+
+			if (variant.matches(currentStack)) {
+				int extracted = (int) Math.min(currentStack.getCount(), maxAmount);
+
+				if (extracted > 0) {
+					currentStack = getStack().copy();
+					currentStack.decrement(extracted);
+
+					if (!setStack(currentStack, transaction)) return 0;
+
+					return extracted;
+				}
+			}
+
+			return 0;
+		}
+
+		@Override
+		public boolean isResourceBlank() {
+			return getStack().isEmpty();
+		}
+
+		@Override
+		public ItemVariant getResource() {
+			return ItemVariant.of(getStack());
+		}
+
+		@Override
+		public long getAmount() {
+			return getStack().getCount();
+		}
+
+		@Override
+		public long getCapacity() {
+			return getStack().getItem().getMaxCount();
+		}
+
+		@Override
+		public String toString() {
+			return "ContainerSlotWrapper[%s#%d]".formatted(ContainerComponentStorage.this.ctx.getItemVariant(), slot);
+		}
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/BundleContentsComponentAccessor.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/BundleContentsComponentAccessor.java
new file mode 100644
index 000000000..43ec46e85
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/BundleContentsComponentAccessor.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.transfer;
+
+import org.apache.commons.lang3.math.Fraction;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Invoker;
+
+import net.minecraft.component.type.BundleContentsComponent;
+import net.minecraft.item.ItemStack;
+
+@Mixin(BundleContentsComponent.class)
+public interface BundleContentsComponentAccessor {
+	@Invoker("getOccupancy")
+	static Fraction getOccupancy(ItemStack stack) {
+		throw new AssertionError("This shouldn't happen!");
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/ContainerComponentAccessor.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/ContainerComponentAccessor.java
new file mode 100644
index 000000000..41793e7f3
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/ContainerComponentAccessor.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.transfer;
+
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+import net.minecraft.component.type.ContainerComponent;
+import net.minecraft.item.ItemStack;
+import net.minecraft.util.collection.DefaultedList;
+
+@Mixin(ContainerComponent.class)
+public interface ContainerComponentAccessor {
+	@Accessor("stacks")
+	DefaultedList<ItemStack> fabric_getStacks();
+}
diff --git a/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1.mixins.json b/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1.mixins.json
index 4952c8429..26e1a54a3 100644
--- a/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1.mixins.json
+++ b/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1.mixins.json
@@ -6,7 +6,9 @@
     "AbstractFurnaceBlockEntityMixin",
     "BucketItemAccessor",
     "BucketItemMixin",
+    "BundleContentsComponentAccessor",
     "ChiseledBookshelfBlockEntityMixin",
+    "ContainerComponentAccessor",
     "CrafterBlockMixin",
     "DoubleInventoryAccessor",
     "DropperBlockMixin",
diff --git a/fabric-transfer-api-v1/src/test/java/net/fabricmc/fabric/test/transfer/unittests/ContainerItemTests.java b/fabric-transfer-api-v1/src/test/java/net/fabricmc/fabric/test/transfer/unittests/ContainerItemTests.java
new file mode 100644
index 000000000..a81388b41
--- /dev/null
+++ b/fabric-transfer-api-v1/src/test/java/net/fabricmc/fabric/test/transfer/unittests/ContainerItemTests.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.test.transfer.unittests;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.item.base.SingleStackStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.SlottedStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.api.transfer.v1.storage.StorageView;
+import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction;
+
+class ContainerItemTests extends AbstractTransferApiTest {
+	@BeforeAll
+	static void beforeAll() {
+		bootstrap();
+	}
+
+	@Test
+	public void emptyShulkerBox() {
+		ItemStack stack = new ItemStack(Items.SHULKER_BOX);
+		Storage<ItemVariant> storage = ContainerItemContext.withConstant(stack).find(ItemStorage.ITEM);
+
+		Assertions.assertInstanceOf(SlottedStorage.class, storage);
+		Assertions.assertEquals(27, ((SlottedStorage<ItemVariant>) storage).getSlotCount());
+	}
+
+	@Test
+	public void insertAndExtractShulkerBox() {
+		var sourceStorage = new SingleStackStorage() {
+			public ItemStack stack = new ItemStack(Items.SHULKER_BOX);
+
+			@Override
+			protected ItemStack getStack() {
+				return stack;
+			}
+
+			@Override
+			public void setStack(ItemStack stack) {
+				this.stack = stack;
+			}
+		};
+
+		Storage<ItemVariant> storage = ContainerItemContext.ofSingleSlot(sourceStorage).find(ItemStorage.ITEM);
+
+		Assertions.assertNotNull(storage, "Shulker Box didn't have a Storage<ItemVariant>");
+
+		try (var tx = Transaction.openOuter()) {
+			Assertions.assertEquals(20, storage.insert(ItemVariant.of(Items.NETHER_STAR), 20, tx));
+			tx.commit();
+		}
+
+		try (var tx = Transaction.openOuter()) {
+			Assertions.assertEquals(20, storage.extract(ItemVariant.of(Items.NETHER_STAR), 64, tx));
+			tx.commit();
+		}
+	}
+
+	@Test
+	public void bundle() {
+		var sourceStorage = new SingleStackStorage() {
+			public ItemStack stack = new ItemStack(Items.BUNDLE);
+
+			@Override
+			protected ItemStack getStack() {
+				return stack;
+			}
+
+			@Override
+			public void setStack(ItemStack stack) {
+				this.stack = stack;
+			}
+		};
+
+		Storage<ItemVariant> storage = ContainerItemContext.ofSingleSlot(sourceStorage).find(ItemStorage.ITEM);
+
+		Assertions.assertNotNull(storage, "Bundle didn't have a Storage<ItemVariant>");
+
+		try (Transaction tx = Transaction.openOuter()) {
+			long inserted1 = storage.insert(ItemVariant.of(Items.NETHER_STAR), 200, tx);
+			Assertions.assertEquals(64, inserted1);
+
+			long inserted2 = storage.insert(ItemVariant.of(Items.STONE), 40, tx);
+			Assertions.assertEquals(0, inserted2);
+
+			tx.commit();
+		}
+
+		try (Transaction tx = Transaction.openOuter()) {
+			long extracted1 = storage.extract(ItemVariant.of(Items.STONE), 60, tx);
+			Assertions.assertEquals(0, extracted1);
+
+			long extracted2 = storage.extract(ItemVariant.of(Items.NETHER_STAR), 35, tx);
+			Assertions.assertEquals(35, extracted2);
+
+			StorageView<ItemVariant> view = storage.nonEmptyIterator().next();
+			Assertions.assertEquals(29, view.getAmount());
+		}
+	}
+
+	@Test
+	public void shulkerBoxWrongItem() {
+		var sourceStorage = new SingleStackStorage() {
+			public ItemStack stack = new ItemStack(Items.SHULKER_BOX);
+
+			@Override
+			protected ItemStack getStack() {
+				return stack;
+			}
+
+			@Override
+			public void setStack(ItemStack stack) {
+				this.stack = stack;
+			}
+		};
+
+		Storage<ItemVariant> storage = ContainerItemContext.ofSingleSlot(sourceStorage).find(ItemStorage.ITEM);
+
+		Assertions.assertNotNull(storage, "Shulker Box didn't have a Storage<ItemVariant>");
+
+		try (var tx = Transaction.openOuter()) {
+			Assertions.assertEquals(20, storage.insert(ItemVariant.of(Items.NETHER_STAR), 20, tx));
+		}
+
+		sourceStorage.setStack(new ItemStack(Items.NETHER_STAR));
+
+		try (var tx = Transaction.openOuter()) {
+			Assertions.assertEquals(0, storage.insert(ItemVariant.of(Items.NETHER_STAR), 20, tx));
+		}
+	}
+
+	@Test
+	public void bundleWrongItem() {
+		var sourceStorage = new SingleStackStorage() {
+			public ItemStack stack = new ItemStack(Items.BUNDLE);
+
+			@Override
+			protected ItemStack getStack() {
+				return stack;
+			}
+
+			@Override
+			public void setStack(ItemStack stack) {
+				this.stack = stack;
+			}
+		};
+
+		Storage<ItemVariant> storage = ContainerItemContext.ofSingleSlot(sourceStorage).find(ItemStorage.ITEM);
+
+		Assertions.assertNotNull(storage, "Bundle didn't have a Storage<ItemVariant>");
+
+		try (Transaction tx = Transaction.openOuter()) {
+			long inserted1 = storage.insert(ItemVariant.of(Items.NETHER_STAR), 200, tx);
+			Assertions.assertEquals(64, inserted1);
+		}
+
+		sourceStorage.setStack(new ItemStack(Items.NETHER_STAR));
+
+		try (var tx = Transaction.openOuter()) {
+			Assertions.assertEquals(0, storage.insert(ItemVariant.of(Items.NETHER_STAR), 200, tx));
+		}
+	}
+}
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/TransferTestInitializer.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/TransferTestInitializer.java
index d9a81ffd7..261b230ab 100644
--- a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/TransferTestInitializer.java
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/TransferTestInitializer.java
@@ -16,20 +16,31 @@
 
 package net.fabricmc.fabric.test.transfer.ingame;
 
+import com.mojang.brigadier.arguments.LongArgumentType;
+
 import net.minecraft.block.AbstractBlock;
 import net.minecraft.block.Block;
 import net.minecraft.block.Blocks;
 import net.minecraft.block.entity.BlockEntityType;
+import net.minecraft.command.argument.ItemStackArgumentType;
 import net.minecraft.item.BlockItem;
 import net.minecraft.item.Item;
 import net.minecraft.registry.Registries;
 import net.minecraft.registry.Registry;
+import net.minecraft.server.command.CommandManager;
+import net.minecraft.text.Text;
+import net.minecraft.util.Hand;
 import net.minecraft.util.Identifier;
 
 import net.fabricmc.api.ModInitializer;
+import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
 import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
 import net.fabricmc.fabric.api.transfer.v1.fluid.FluidStorage;
 import net.fabricmc.fabric.api.transfer.v1.item.ItemStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction;
 
 public class TransferTestInitializer implements ModInitializer {
 	public static final String MOD_ID = "fabric-transfer-api-v1-testmod";
@@ -57,6 +68,74 @@ public class TransferTestInitializer implements ModInitializer {
 		ItemStorage.SIDED.registerForBlocks((world, pos, state, be, direction) -> TrashingStorage.ITEM, Blocks.OBSIDIAN);
 		// And diamond ore blocks are an infinite source of diamonds! Yay!
 		ItemStorage.SIDED.registerForBlocks((world, pos, state, be, direction) -> CreativeStorage.DIAMONDS, Blocks.DIAMOND_ORE);
+
+		CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> {
+			dispatcher.register(
+					CommandManager.literal("fabric_insertintoheldstack")
+							.then(CommandManager.argument("stack", ItemStackArgumentType.itemStack(registryAccess))
+									.then(CommandManager.argument("count", LongArgumentType.longArg(1))
+											.executes(context -> {
+												ItemVariant variant = ItemVariant.of(ItemStackArgumentType.getItemStackArgument(context, "stack")
+														.createStack(1, false));
+
+												ContainerItemContext containerCtx = ContainerItemContext.ofPlayerHand(context.getSource().getPlayerOrThrow(), Hand.MAIN_HAND);
+												Storage<ItemVariant> storage = containerCtx.find(ItemStorage.ITEM);
+
+												if (storage == null) {
+													context.getSource().sendMessage(Text.literal("no storage found"));
+													return 0;
+												}
+
+												long inserted;
+
+												try (Transaction tx = Transaction.openOuter()) {
+													inserted = storage.insert(
+															variant,
+															LongArgumentType.getLong(context, "count"),
+															tx
+													);
+													tx.commit();
+												}
+
+												context.getSource().sendMessage(Text.literal("inserted " + inserted + " items"));
+
+												return (int) inserted;
+											})))
+			);
+
+			dispatcher.register(
+					CommandManager.literal("fabric_extractfromheldstack")
+							.then(CommandManager.argument("stack", ItemStackArgumentType.itemStack(registryAccess))
+									.then(CommandManager.argument("count", LongArgumentType.longArg(1))
+											.executes(context -> {
+												ItemVariant variant = ItemVariant.of(ItemStackArgumentType.getItemStackArgument(context, "stack")
+														.createStack(1, false));
+
+												ContainerItemContext containerCtx = ContainerItemContext.ofPlayerHand(context.getSource().getPlayerOrThrow(), Hand.MAIN_HAND);
+												Storage<ItemVariant> storage = containerCtx.find(ItemStorage.ITEM);
+
+												if (storage == null) {
+													context.getSource().sendMessage(Text.literal("no storage found"));
+													return 0;
+												}
+
+												long extracted;
+
+												try (Transaction tx = Transaction.openOuter()) {
+													extracted = storage.extract(
+															variant,
+															LongArgumentType.getLong(context, "count"),
+															tx
+													);
+													tx.commit();
+												}
+
+												context.getSource().sendMessage(Text.literal("extracted " + extracted + " items"));
+
+												return (int) extracted;
+											})))
+			);
+		});
 	}
 
 	private static void registerBlock(Block block, String name) {