diff --git a/README.md b/README.md
index 214b58c..26eee5e 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,18 @@ Font style and size can be controlled by the matching options.
 
 </details>
 
+<details>
+<summary>Kephri</summary>
+
+### 🚀 Swarmer
+
+Displays wave numbers on scarab swarms in the kephri room as they spawn.
+The side panel will show logs of previous raid's leaked swarms.
+Font color, style and size can be controlled by the matching options.
+![Swarmer Example](https://imgur.com/msneEOQ.png "Swarmer Example")
+
+</details>
+
 <details>
 <summary>Path of Apmeken</summary>
 
diff --git a/src/main/java/com/duckblade/osrs/toa/TombsOfAmascutConfig.java b/src/main/java/com/duckblade/osrs/toa/TombsOfAmascutConfig.java
index d2d9332..eaeb413 100644
--- a/src/main/java/com/duckblade/osrs/toa/TombsOfAmascutConfig.java
+++ b/src/main/java/com/duckblade/osrs/toa/TombsOfAmascutConfig.java
@@ -1,6 +1,8 @@
 package com.duckblade.osrs.toa;
 
 import com.duckblade.osrs.toa.features.QuickProceedSwaps.QuickProceedEnableMode;
+import com.duckblade.osrs.toa.features.boss.kephri.swarmer.SwarmerFonts;
+import com.duckblade.osrs.toa.features.boss.kephri.swarmer.SwarmerPanelManager;
 import com.duckblade.osrs.toa.features.hporbs.HpOrbMode;
 import com.duckblade.osrs.toa.features.scabaras.ScabarasHelperMode;
 import com.duckblade.osrs.toa.features.scabaras.SkipObeliskOverlay;
@@ -9,7 +11,9 @@
 import com.duckblade.osrs.toa.features.updatenotifier.UpdateNotifier;
 import com.duckblade.osrs.toa.util.FontStyle;
 import com.duckblade.osrs.toa.util.HighlightMode;
-import java.awt.Color;
+
+import java.awt.*;
+
 import net.runelite.client.config.Alpha;
 import net.runelite.client.config.Config;
 import net.runelite.client.config.ConfigGroup;
@@ -42,10 +46,18 @@ public interface TombsOfAmascutConfig extends Config
 	)
 	String SECTION_AKKHA = "sectionAkkha";
 
+	@ConfigSection(
+		name = "Kephri",
+		description = "Configuration for the Kephri room.",
+		position = 2,
+		closedByDefault = true
+	)
+	String SECTION_KEPHRI = "sectionKephri";
+
 	@ConfigSection(
 		name = "Path of Apmeken",
 		description = "Options for the Path of Apmeken.",
-		position = 2,
+		position = 3,
 		closedByDefault = true
 	)
 	String SECTION_APMEKEN = "sectionApmeken";
@@ -53,7 +65,7 @@ public interface TombsOfAmascutConfig extends Config
 	@ConfigSection(
 		name = "Path of Het",
 		description = "Helpers for the Path of Het.",
-		position = 3,
+		position = 4,
 		closedByDefault = true
 	)
 	String SECTION_HET = "sectionHet";
@@ -61,7 +73,7 @@ public interface TombsOfAmascutConfig extends Config
 	@ConfigSection(
 		name = "Path of Scabaras",
 		description = "Options for the puzzles in the Path of Scabaras.",
-		position = 4,
+		position = 5,
 		closedByDefault = true
 	)
 	String SECTION_SCABARAS = "sectionScabaras";
@@ -69,7 +81,7 @@ public interface TombsOfAmascutConfig extends Config
 	@ConfigSection(
 		name = "Burial Tomb",
 		description = "Configuration for the burial tomb.",
-		position = 5,
+		position = 6,
 		closedByDefault = true
 	)
 	String SECTION_BURIAL_TOMB = "sectionBurialTomb";
@@ -78,7 +90,7 @@ public interface TombsOfAmascutConfig extends Config
 		name = "Points Tracker",
 		description = "<html>Tracks points for the raid, used in calculating drop chance." +
 			"<br/>NOTE: For teams, you MUST use the RuneLite Party plugin to receive team drop chance.</html>",
-		position = 6,
+		position = 7,
 		closedByDefault = true
 	)
 	String SECTION_POINTS_TRACKER = "sectionPointsTracker";
@@ -86,7 +98,7 @@ public interface TombsOfAmascutConfig extends Config
 	@ConfigSection(
 		name = "Invocation Presets",
 		description = "Save presets of invocations to quickly restore your invocations between runs of different types.",
-		position = 7,
+		position = 8,
 		closedByDefault = true
 	)
 	String SECTION_INVOCATION_PRESETS = "invocationPresetsSection";
@@ -94,7 +106,7 @@ public interface TombsOfAmascutConfig extends Config
 	@ConfigSection(
 		name = "Invocation Screenshot",
 		description = "All config options related to the Invocation Screenshot functionality",
-		position = 8,
+		position = 9,
 		closedByDefault = true
 	)
 	String SECTION_INVOCATION_SCREENSHOT = "invocationScreenshotSection";
@@ -102,7 +114,7 @@ public interface TombsOfAmascutConfig extends Config
 	@ConfigSection(
 		name = "Time Tracking",
 		description = "Time tracking and splits.",
-		position = 9,
+		position = 10,
 		closedByDefault = true
 	)
 	String SECTION_TIME_TRACKING = "sectionTimeTracking";
@@ -147,6 +159,81 @@ default int akkhaFontSize()
 		return 12;
 	}
 
+	// Kephri
+	@ConfigItem(
+		keyName = "swarmerOverlay",
+		name = "Swarmer Overlay",
+		description = "Overlay swarm wave number.",
+		position = 0,
+		section = SECTION_KEPHRI
+	)
+	default boolean swarmerOverlay()
+	{
+		return false;
+	}
+
+	@ConfigItem(
+			name = "Font Type",
+			description = "Type of font",
+			position = 1,
+			keyName = "fontType",
+			section = SECTION_KEPHRI
+	)
+	default SwarmerFonts swarmerFontType()
+	{
+		return SwarmerFonts.ARIAL;
+	}
+
+	@ConfigItem(
+			name = "Use Bold Font",
+			description = "Font style of swarm overlay.",
+			position = 2,
+			keyName = "useBoldFont",
+			section = SECTION_KEPHRI
+	)
+	default boolean useBoldFont()
+	{
+		return true;
+	}
+
+	@ConfigItem(
+			name = "Font Size",
+			description = "Font size of swarm overlay.",
+			position = 3,
+			keyName = "swarmerFontSize",
+			section = SECTION_KEPHRI
+	)
+	@Units(Units.PIXELS)
+	@Range(min = 12)
+	default int swarmerFontSize()
+	{
+		return 12;
+	}
+
+	@ConfigItem(
+			name = "Side Panel",
+			description = "Show a side panel with summary data for previous raids.",
+			position = 4,
+			keyName = "swarmerSidePanel",
+			section = SECTION_KEPHRI
+	)
+	default SwarmerPanelManager.PanelMode swarmerSidePanel()
+	{
+		return SwarmerPanelManager.PanelMode.NEVER;
+	}
+
+	@ConfigItem(
+			name = "Font Color",
+			description = "Font color of swarm overlay.",
+			position = 4,
+			keyName = "swarmerFontColor",
+			section = SECTION_KEPHRI
+	)
+	default Color swarmerFontColor()
+	{
+		return Color.WHITE;
+	}
+
 	// Apmeken
 
 	@ConfigItem(
diff --git a/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmNpc.java b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmNpc.java
new file mode 100644
index 0000000..45088dd
--- /dev/null
+++ b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmNpc.java
@@ -0,0 +1,11 @@
+package com.duckblade.osrs.toa.features.boss.kephri.swarmer;
+
+import lombok.Data;
+import net.runelite.api.NPC;
+
+@Data
+public class SwarmNpc
+{
+	private final NPC npc;
+	private final int waveSpawned;
+}
\ No newline at end of file
diff --git a/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerDataManager.java b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerDataManager.java
new file mode 100644
index 0000000..036f142
--- /dev/null
+++ b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerDataManager.java
@@ -0,0 +1,133 @@
+package com.duckblade.osrs.toa.features.boss.kephri.swarmer;
+
+import com.duckblade.osrs.toa.TombsOfAmascutPlugin;
+import com.duckblade.osrs.toa.module.PluginLifecycleComponent;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.io.File;
+import java.io.FileReader;
+import java.lang.reflect.Type;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.text.SimpleDateFormat;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import lombok.RequiredArgsConstructor;
+
+@Singleton
+@RequiredArgsConstructor(onConstructor_ = @Inject)
+public class SwarmerDataManager implements PluginLifecycleComponent
+{
+
+	private static final int MAX_RECENT_RAIDS = 10;
+	public static final Path SWARMS_DIRECTORY = new File(TombsOfAmascutPlugin.TOA_FOLDER, "kephri-swarms").toPath();
+
+	private final Gson gson;
+
+	private ExecutorService executor;
+
+	@Override
+	public void startUp()
+	{
+		executor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("ToA-SwarmerDataManager-%d").build());
+	}
+
+	@Override
+	public void shutDown()
+	{
+		executor.shutdown();
+	}
+
+	public CompletableFuture<List<String>> getRaidList()
+	{
+		return CompletableFuture.supplyAsync(() ->
+		{
+			try
+			{
+				if (!Files.exists(SWARMS_DIRECTORY))
+				{
+					return Collections.emptyList();
+				}
+
+				try (Stream<Path> files = Files.list(SWARMS_DIRECTORY))
+				{
+					return files.filter(f -> f.getFileName().toString().endsWith(".json"))
+						.sorted(Comparator.reverseOrder())
+						.limit(MAX_RECENT_RAIDS)
+						.map(f -> f.getFileName().toString().replace(".json", ""))
+						.map(s -> s.replace('_', ':'))
+						.collect(Collectors.toList());
+				}
+			}
+			catch (Exception ignored)
+			{
+			}
+
+			return Collections.emptyList();
+		}, executor);
+	}
+
+	public CompletableFuture<List<SwarmerRoomData>> getRaidData(String raidUnsafe)
+	{
+		return CompletableFuture.supplyAsync(() ->
+		{
+			String raid = raidUnsafe.replace(':', '_');
+
+			if (!Files.exists(SWARMS_DIRECTORY))
+			{
+				return Collections.emptyList();
+			}
+			if (!Files.exists(SWARMS_DIRECTORY.resolve(raid + ".json")))
+			{
+				return Collections.emptyList();
+			}
+
+			try (FileReader reader = new FileReader(SWARMS_DIRECTORY.resolve(raid + ".json").toFile()))
+			{
+				Type listType = new TypeToken<List<SwarmerRoomData>>()
+				{
+				}.getType();
+				return gson.fromJson(reader, listType);
+			}
+			catch (Exception ignored)
+			{
+				return Collections.emptyList();
+			}
+		}, executor);
+	}
+
+	public CompletableFuture<Void> saveRaidData(List<SwarmerRoomData> raidDataList)
+	{
+		return CompletableFuture.runAsync(() ->
+		{
+			String raidName = new SimpleDateFormat("yyyy-MM-dd HH_mm_ss").format(new Date());
+			try
+			{
+				if (!Files.exists(SWARMS_DIRECTORY))
+				{
+					Files.createDirectories(SWARMS_DIRECTORY);
+				}
+				Files.writeString(
+					SWARMS_DIRECTORY.resolve(raidName + ".json"),
+					gson.toJson(raidDataList),
+					StandardOpenOption.CREATE,
+					StandardOpenOption.TRUNCATE_EXISTING
+				);
+			}
+			catch (Exception ignored)
+			{
+			}
+		});
+	}
+}
\ No newline at end of file
diff --git a/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerFonts.java b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerFonts.java
new file mode 100644
index 0000000..1127f0a
--- /dev/null
+++ b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerFonts.java
@@ -0,0 +1,30 @@
+package com.duckblade.osrs.toa.features.boss.kephri.swarmer;
+
+import lombok.Getter;
+
+@Getter
+public enum SwarmerFonts
+{
+	REGULAR("RS Regular"),
+	ARIAL("Arial"),
+	CAMBRIA("Cambria"),
+	ROCKWELL("Rockwell"),
+	SEGOE_UI("Segoe Ui"),
+	TIMES_NEW_ROMAN("Times New Roman"),
+	VERDANA("Verdana"),
+	DIALOG("DIALOG"),
+	RUNESCAPE("RuneScape");
+
+	private final String name;
+
+	public String toString()
+	{
+		return this.name;
+	}
+
+	SwarmerFonts(String name)
+	{
+		this.name = name;
+	}
+
+}
diff --git a/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerOverlay.java b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerOverlay.java
new file mode 100644
index 0000000..be274ab
--- /dev/null
+++ b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerOverlay.java
@@ -0,0 +1,274 @@
+package com.duckblade.osrs.toa.features.boss.kephri.swarmer;
+
+import com.duckblade.osrs.toa.TombsOfAmascutConfig;
+import com.duckblade.osrs.toa.module.PluginLifecycleComponent;
+import com.duckblade.osrs.toa.util.RaidRoom;
+import com.duckblade.osrs.toa.util.RaidState;
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.Font;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.Shape;
+import java.awt.font.FontRenderContext;
+import java.awt.font.TextLayout;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import lombok.extern.slf4j.Slf4j;
+import net.runelite.api.ChatMessageType;
+import net.runelite.api.Client;
+import net.runelite.api.NPC;
+import net.runelite.api.NpcID;
+import net.runelite.api.Point;
+import net.runelite.api.events.AnimationChanged;
+import net.runelite.api.events.ChatMessage;
+import net.runelite.api.events.NpcSpawned;
+import net.runelite.client.eventbus.EventBus;
+import net.runelite.client.eventbus.Subscribe;
+import net.runelite.client.ui.overlay.Overlay;
+import net.runelite.client.ui.overlay.OverlayLayer;
+import net.runelite.client.ui.overlay.OverlayManager;
+import net.runelite.client.ui.overlay.OverlayPosition;
+
+@Slf4j
+@Singleton
+public class SwarmerOverlay extends Overlay implements PluginLifecycleComponent
+{
+	private static final int ANIMATION_KEPHRI_DOWN = 9579;
+	private static final int ANIMATION_KEPHRI_UP = 9581;
+	private static final int ANIMATION_SWARM_LEAK = 9607;
+	private static final int ANIMATION_SWARM_DEATH = 9608;
+
+	private static final String ROOM_ENDED_MESSAGE = "Challenge complete: Kephri.";
+	private static final String ROOM_FAIL_MESSAGE = "Your party failed to complete";
+
+	private final Client client;
+	private final EventBus eventBus;
+	private final OverlayManager overlayManager;
+	private final TombsOfAmascutConfig config;
+	private final SwarmerDataManager swarmerDataManager;
+	private final SwarmerPanel swarmerPanel;
+
+	private final Map<Integer, SwarmNpc> aliveSwarms = new HashMap<>();
+	private final Map<Integer, Map<Integer, Integer>> leaks = new HashMap<>();
+
+	private int waveNumber;
+	private int kephriDownCount;
+
+	private boolean isKephriDowned;
+	private int lastSpawnTick;
+
+	@Inject
+	public SwarmerOverlay(Client client, EventBus eventBus, OverlayManager overlayManager, TombsOfAmascutConfig config, SwarmerDataManager swarmerDataManager, SwarmerPanel swarmerPanel)
+	{
+		this.client = client;
+		this.eventBus = eventBus;
+		this.overlayManager = overlayManager;
+		this.config = config;
+		this.swarmerDataManager = swarmerDataManager;
+		this.swarmerPanel = swarmerPanel;
+
+		setPriority(Overlay.PRIORITY_HIGH);
+		setPosition(OverlayPosition.DYNAMIC);
+		setLayer(OverlayLayer.ABOVE_SCENE);
+	}
+
+	@Override
+	public boolean isEnabled(TombsOfAmascutConfig config, RaidState raidState)
+	{
+		return config.swarmerOverlay()
+			&& raidState.getCurrentRoom() == RaidRoom.KEPHRI;
+	}
+
+	@Override
+	public void startUp()
+	{
+		eventBus.register(this);
+		overlayManager.add(this);
+		reset();
+	}
+
+	@Override
+	public void shutDown()
+	{
+		eventBus.unregister(this);
+		overlayManager.remove(this);
+		reset();
+	}
+
+	private void reset()
+	{
+		leaks.clear();
+		aliveSwarms.clear();
+		isKephriDowned = false;
+		lastSpawnTick = -1;
+		waveNumber = 0;
+		kephriDownCount = 0;
+	}
+
+	@Subscribe
+	public void onNpcSpawned(NpcSpawned event)
+	{
+		final NPC npc = event.getNpc();
+		final int npcId = npc.getId();
+
+		if (isKephriDowned && npcId == NpcID.SCARAB_SWARM_11723)
+		{
+			int thisTick = client.getTickCount();
+			if (lastSpawnTick != thisTick)
+			{
+				waveNumber++;
+				lastSpawnTick = thisTick;
+			}
+
+			SwarmNpc swarm = new SwarmNpc(npc, waveNumber);
+			aliveSwarms.put(npc.getIndex(), swarm);
+		}
+	}
+
+	@Subscribe
+	public void onAnimationChanged(AnimationChanged e)
+	{
+		if (!(e.getActor() instanceof NPC))
+		{
+			return;
+		}
+
+		NPC npc = ((NPC) e.getActor());
+		if (npc.getId() == NpcID.SCARAB_SWARM_11723)
+		{
+			handleSwarmAnimationChanged(npc);
+		}
+		else if (npc.getId() == NpcID.KEPHRI || npc.getId() == NpcID.KEPHRI_11720)
+		{
+			handleKephriAnimationChanged(npc);
+		}
+	}
+
+	private void handleSwarmAnimationChanged(NPC npc)
+	{
+		SwarmNpc swarm = aliveSwarms.get(npc.getIndex());
+		if (swarm == null)
+		{
+			return;
+		}
+
+		if (npc.getAnimation() == ANIMATION_SWARM_LEAK)
+		{
+			aliveSwarms.remove(npc.getIndex());
+			leaks.compute(kephriDownCount, (downs, waveMap) ->
+			{
+				Map<Integer, Integer> waveLeaks = waveMap != null ? waveMap : new HashMap<>();
+				waveLeaks.compute(swarm.getWaveSpawned(), (wave, count) -> count != null ? count + 1 : 1);
+				return waveLeaks;
+			});
+		}
+		else if (npc.getAnimation() == ANIMATION_SWARM_DEATH)
+		{
+			aliveSwarms.remove(npc.getIndex());
+		}
+	}
+
+	private void handleKephriAnimationChanged(NPC npc)
+	{
+		if (!isKephriDowned && npc.getAnimation() == ANIMATION_KEPHRI_DOWN)
+		{
+			isKephriDowned = true;
+			kephriDownCount++;
+			waveNumber = 0;
+			aliveSwarms.clear();
+		}
+		else if (isKephriDowned && npc.getAnimation() == ANIMATION_KEPHRI_UP)
+		{
+			isKephriDowned = false;
+		}
+	}
+
+	@Subscribe
+	public void onChatMessage(ChatMessage event)
+	{
+		if (!event.getType().equals(ChatMessageType.GAMEMESSAGE))
+		{
+			return;
+		}
+
+		if (event.getMessage().startsWith(ROOM_ENDED_MESSAGE))
+		{
+			List<SwarmerRoomData> swarmData = new ArrayList<>();
+			for (Map.Entry<Integer, Map<Integer, Integer>> e : leaks.entrySet())
+			{
+				int down = e.getKey();
+				for (Map.Entry<Integer, Integer> f : e.getValue().entrySet())
+				{
+					int wave = f.getKey();
+					int leaks = f.getValue();
+					swarmData.add(new SwarmerRoomData(down, wave, leaks));
+				}
+			}
+			swarmerDataManager.saveRaidData(swarmData)
+				.thenRun(() ->
+				{
+					if (config.swarmerSidePanel() != SwarmerPanelManager.PanelMode.NEVER)
+					{
+						swarmerPanel.updateRecentRaids();
+					}
+				});
+		}
+
+		if (event.getMessage().startsWith(ROOM_FAIL_MESSAGE))
+		{
+			reset();
+		}
+	}
+
+	@Override
+	public Dimension render(Graphics2D graphics)
+	{
+		graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+		aliveSwarms.values()
+			.stream()
+			.collect(Collectors.groupingBy(swarm -> swarm.getNpc().getWorldLocation()))
+			.values()
+			.forEach(tileSwarms ->
+			{
+				int stackOffset = 0;
+				for (SwarmNpc swarm : tileSwarms)
+				{
+					this.draw(graphics, swarm, stackOffset);
+					stackOffset += graphics.getFontMetrics().getHeight();
+				}
+			});
+		return null;
+	}
+
+	private void draw(Graphics2D graphics, SwarmNpc swarmer, int offset)
+	{
+		String text = String.valueOf(swarmer.getWaveSpawned());
+
+		Point canvasTextLocation = swarmer.getNpc().getCanvasTextLocation(graphics, text, 0);
+		if (canvasTextLocation == null)
+		{
+			return;
+		}
+		int x = canvasTextLocation.getX();
+		int y = canvasTextLocation.getY() + offset;
+
+		Font font = new Font(config.swarmerFontType().toString(), config.useBoldFont() ? Font.BOLD : Font.PLAIN, config.swarmerFontSize());
+		FontRenderContext frc = graphics.getFontRenderContext();
+		TextLayout tl = new TextLayout(text, font, frc);
+		Shape outline = tl.getOutline(null);
+		graphics.translate(x, y);
+		graphics.setStroke(new BasicStroke(3));
+		graphics.setColor(Color.BLACK);
+		graphics.draw(outline);
+		graphics.setColor(config.swarmerFontColor());
+		graphics.fill(outline);
+		graphics.translate(-x, -y);
+	}
+}
diff --git a/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerPanel.java b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerPanel.java
new file mode 100644
index 0000000..3620475
--- /dev/null
+++ b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerPanel.java
@@ -0,0 +1,182 @@
+package com.duckblade.osrs.toa.features.boss.kephri.swarmer;
+
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.Font;
+import java.util.Comparator;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import javax.swing.Box;
+import javax.swing.BoxLayout;
+import javax.swing.DefaultListCellRenderer;
+import javax.swing.DefaultListModel;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTable;
+import javax.swing.SwingConstants;
+import javax.swing.SwingUtilities;
+import javax.swing.table.DefaultTableCellRenderer;
+import javax.swing.table.DefaultTableModel;
+import net.runelite.client.ui.PluginPanel;
+
+@Singleton
+public class SwarmerPanel extends PluginPanel
+{
+	private static final String[] LEAKS_COLUMN_NAMES = {"Down", "Wave", "Leaks"};
+
+	private static final Color textColor = Color.WHITE;
+	private static final Color tableColor1 = new Color(0x1F1F1F);
+	private static final Color tableColor2 = new Color(0x2D2D2D);
+
+	private final SwarmerDataManager swarmerDataManager;
+
+	private final DefaultListModel<String> raidsListModel;
+	private final DefaultTableModel leaksTableModel;
+
+	private String loadedRaidData;
+	private String selectedRaid;
+
+	@Inject
+	SwarmerPanel(SwarmerDataManager swarmerDataManager)
+	{
+		super(false);
+		this.swarmerDataManager = swarmerDataManager;
+
+		Font tableTitleFont = new Font(SwarmerFonts.REGULAR.toString(), Font.PLAIN, 18);
+		Font tableFont = new Font(SwarmerFonts.VERDANA.toString(), Font.PLAIN, 12);
+
+		setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
+		add(Box.createVerticalStrut(20));
+
+		JPanel recentRaidsPanel = new JPanel();
+		recentRaidsPanel.setLayout(new BoxLayout(recentRaidsPanel, BoxLayout.Y_AXIS));
+
+		JLabel recentRaidsLabel = new JLabel("Recent Raids");
+		recentRaidsLabel.setForeground(textColor);
+		recentRaidsLabel.setFont(tableTitleFont);
+		recentRaidsLabel.setHorizontalAlignment(SwingConstants.CENTER);
+		recentRaidsLabel.setAlignmentX(CENTER_ALIGNMENT);
+		recentRaidsPanel.add(recentRaidsLabel);
+		recentRaidsPanel.add(Box.createVerticalStrut(5));
+
+		raidsListModel = new DefaultListModel<>();
+		JList<String> raidsList = new JList<>(raidsListModel);
+		raidsList.setBackground(tableColor1);
+		raidsList.setForeground(textColor);
+		raidsList.setFont(tableFont);
+		raidsList.setSelectionBackground(tableColor2);
+		raidsList.setSelectionForeground(textColor);
+		raidsList.addListSelectionListener(e ->
+		{
+			if (!e.getValueIsAdjusting())
+			{
+				String selectedRaid = raidsList.getSelectedValue();
+				if (selectedRaid != null)
+				{
+					this.selectedRaid = selectedRaid;
+					loadRaidData(selectedRaid);
+				}
+			}
+		});
+		raidsList.setCellRenderer(new DefaultListCellRenderer()
+		{
+			@Override
+			public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean isSelected, boolean cellHasFocus)
+			{
+				JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
+				label.setHorizontalAlignment(SwingConstants.CENTER);
+				label.setBackground(value.equals(selectedRaid) ? tableColor2 : tableColor1);
+				label.setPreferredSize(new Dimension(label.getPreferredSize().width, 25));
+				return label;
+			}
+		});
+
+		recentRaidsPanel.add(new JScrollPane(raidsList));
+		add(recentRaidsPanel);
+		add(Box.createVerticalStrut(20));
+
+		JPanel leaksPanel = new JPanel();
+		leaksPanel.setLayout(new BoxLayout(leaksPanel, BoxLayout.Y_AXIS));
+
+		JLabel leaksLabel = new JLabel("Leaks");
+		leaksLabel.setForeground(textColor);
+		leaksLabel.setFont(tableTitleFont);
+		leaksLabel.setHorizontalAlignment(SwingConstants.CENTER);
+		leaksLabel.setAlignmentX(CENTER_ALIGNMENT);
+		leaksPanel.add(leaksLabel);
+		leaksPanel.add(Box.createVerticalStrut(5));
+
+		leaksTableModel = new DefaultTableModel()
+		{
+			@Override
+			public boolean isCellEditable(int row, int column)
+			{
+				return false;
+			}
+		};
+		JTable leaksTable = new JTable(leaksTableModel);
+		leaksTable.setRowSelectionAllowed(false);
+		leaksTable.setColumnSelectionAllowed(false);
+		leaksTable.setCellSelectionEnabled(false);
+		leaksTable.setBackground(tableColor1);
+		leaksTable.setForeground(textColor);
+		leaksTable.setGridColor(tableColor1);
+		leaksTable.setFont(tableFont);
+
+		DefaultTableCellRenderer leaksCellRenderer = new DefaultTableCellRenderer()
+		{
+			@Override
+			public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
+			{
+				Component c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
+				c.setBackground(row % 2 == 0 ? tableColor1 : tableColor2);
+				c.setForeground(textColor);
+				return c;
+			}
+		};
+		leaksCellRenderer.setHorizontalAlignment(SwingConstants.CENTER);
+		leaksTable.setDefaultRenderer(Object.class, leaksCellRenderer);
+
+		JScrollPane leaksScrollPane = new JScrollPane(leaksTable);
+		leaksScrollPane.setPreferredSize(new Dimension(100, 400));
+
+		leaksPanel.add(leaksScrollPane);
+		add(leaksPanel);
+		add(Box.createVerticalStrut(20));
+	}
+
+	public void loadRaidData(String raid)
+	{
+		if (raid == null || raid.equals(loadedRaidData))
+		{
+			return;
+		}
+
+		// set this early to prevent multi-clicks from possibly beating out the execution of the read.
+		loadedRaidData = raid;
+		swarmerDataManager.getRaidData(raid)
+			.thenAccept(raidDataList -> SwingUtilities.invokeLater(() ->
+			{
+				Object[][] newData = raidDataList.stream()
+					.sorted(Comparator.comparing(SwarmerRoomData::getDown)
+						.thenComparing(SwarmerRoomData::getWave))
+					.map(row -> new Object[]{row.getDown(), row.getWave(), row.getLeaks()})
+					.toArray(Object[][]::new);
+				leaksTableModel.setDataVector(newData, LEAKS_COLUMN_NAMES);
+			}));
+	}
+
+	public void updateRecentRaids()
+	{
+		swarmerDataManager.getRaidList()
+			.thenAccept(raids -> SwingUtilities.invokeLater(() ->
+			{
+				raidsListModel.clear();
+				raidsListModel.addAll(raids);
+			}));
+	}
+
+}
diff --git a/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerPanelManager.java b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerPanelManager.java
new file mode 100644
index 0000000..c633f21
--- /dev/null
+++ b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerPanelManager.java
@@ -0,0 +1,73 @@
+package com.duckblade.osrs.toa.features.boss.kephri.swarmer;
+
+import com.duckblade.osrs.toa.TombsOfAmascutConfig;
+import com.duckblade.osrs.toa.module.PluginLifecycleComponent;
+import com.duckblade.osrs.toa.util.RaidState;
+import java.awt.image.BufferedImage;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import javax.swing.SwingUtilities;
+import lombok.RequiredArgsConstructor;
+import net.runelite.client.ui.ClientToolbar;
+import net.runelite.client.ui.NavigationButton;
+import net.runelite.client.util.ImageUtil;
+
+@Singleton
+@RequiredArgsConstructor(onConstructor_ = @Inject)
+public class SwarmerPanelManager implements PluginLifecycleComponent
+{
+
+	public enum PanelMode
+	{
+		ALWAYS,
+		AT_TOA,
+		NEVER,
+		;
+	}
+
+	private static final BufferedImage PANEL_ICON = ImageUtil.loadImageResource(SwarmerPanelManager.class, "icon.png");
+
+	private final ClientToolbar clientToolbar;
+	private final SwarmerPanel swarmerPanel;
+
+	private NavigationButton navButton;
+
+	@Override
+	public boolean isEnabled(TombsOfAmascutConfig config, RaidState raidState)
+	{
+		switch (config.swarmerSidePanel())
+		{
+			case ALWAYS:
+				return true;
+
+			case AT_TOA:
+				return raidState.isInLobby() || raidState.isInRaid();
+
+			case NEVER:
+			default:
+				return false;
+		}
+	}
+
+	@Override
+	public void startUp()
+	{
+		SwingUtilities.invokeLater(() ->
+		{
+			navButton = NavigationButton.builder()
+				.tooltip("Swarmer")
+				.icon(PANEL_ICON)
+				.priority(999)
+				.panel(swarmerPanel)
+				.build();
+			clientToolbar.addNavigation(navButton);
+			swarmerPanel.updateRecentRaids();
+		});
+	}
+
+	@Override
+	public void shutDown()
+	{
+		clientToolbar.removeNavigation(navButton);
+	}
+}
diff --git a/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerRoomData.java b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerRoomData.java
new file mode 100644
index 0000000..caa1b32
--- /dev/null
+++ b/src/main/java/com/duckblade/osrs/toa/features/boss/kephri/swarmer/SwarmerRoomData.java
@@ -0,0 +1,15 @@
+package com.duckblade.osrs.toa.features.boss.kephri.swarmer;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+@Data
+@AllArgsConstructor
+public class SwarmerRoomData
+{
+
+	private int down;
+	private int wave;
+	private int leaks;
+
+}
diff --git a/src/main/java/com/duckblade/osrs/toa/module/TombsOfAmascutModule.java b/src/main/java/com/duckblade/osrs/toa/module/TombsOfAmascutModule.java
index 5d944a7..2e0b19a 100644
--- a/src/main/java/com/duckblade/osrs/toa/module/TombsOfAmascutModule.java
+++ b/src/main/java/com/duckblade/osrs/toa/module/TombsOfAmascutModule.java
@@ -10,6 +10,9 @@
 import com.duckblade.osrs.toa.features.boss.akkha.AkkhaShadowHealthOverlay;
 import com.duckblade.osrs.toa.features.apmeken.ApmekenBaboonIndicator;
 import com.duckblade.osrs.toa.features.apmeken.ApmekenBaboonIndicatorOverlay;
+import com.duckblade.osrs.toa.features.boss.kephri.swarmer.SwarmerDataManager;
+import com.duckblade.osrs.toa.features.boss.kephri.swarmer.SwarmerOverlay;
+import com.duckblade.osrs.toa.features.boss.kephri.swarmer.SwarmerPanelManager;
 import com.duckblade.osrs.toa.features.het.pickaxe.DepositPickaxeOverlay;
 import com.duckblade.osrs.toa.features.het.pickaxe.DepositPickaxePreventEntry;
 import com.duckblade.osrs.toa.features.het.solver.HetSolver;
@@ -96,6 +99,9 @@ protected void configure()
 		lifecycleComponents.addBinding().to(SmellingSaltsCooldown.class);
 		lifecycleComponents.addBinding().to(SplitsOverlay.class);
 		lifecycleComponents.addBinding().to(SplitsTracker.class);
+		lifecycleComponents.addBinding().to(SwarmerOverlay.class);
+		lifecycleComponents.addBinding().to(SwarmerPanelManager.class);
+		lifecycleComponents.addBinding().to(SwarmerDataManager.class);
 		lifecycleComponents.addBinding().to(TargetTimeManager.class);
 		lifecycleComponents.addBinding().to(UpdateNotifier.class);
 	}
diff --git a/src/main/resources/com/duckblade/osrs/toa/features/boss/kephri/swarmer/icon.png b/src/main/resources/com/duckblade/osrs/toa/features/boss/kephri/swarmer/icon.png
new file mode 100644
index 0000000..bd3b365
Binary files /dev/null and b/src/main/resources/com/duckblade/osrs/toa/features/boss/kephri/swarmer/icon.png differ