diff --git a/.github/workflows/docs_deploy.yml b/.github/workflows/docs_deploy.yml
index 8db84333a..088b4f555 100644
--- a/.github/workflows/docs_deploy.yml
+++ b/.github/workflows/docs_deploy.yml
@@ -55,6 +55,10 @@ jobs:
         run: |
           dotnet tool restore
 
+      - name: Build schema
+        run: |
+          .\scripts\Generate-SourceFromSchema.ps1
+
       - run: dotnet tool update -g docfx
       - run: docfx docs/docfx.json
 
diff --git a/docs/docs/customize/yaml.md b/docs/docs/customize/yaml.md
index e97705e46..90a987cfe 100644
--- a/docs/docs/customize/yaml.md
+++ b/docs/docs/customize/yaml.md
@@ -81,7 +81,6 @@ To treat key modifiers like `LWin` and `RWin` the same, set `unify_key_modifiers
 ### Keybinds Example
 
 ```yaml
-# yaml-language-server: $schema=https://raw.githubusercontent.com/dalyIsaac/Whim/main/src/Whim.Yaml/schema.json
 keybinds:
   entries:
     - command: whim.core.focus_next_monitor
@@ -107,3 +106,135 @@ keybinds:
 
   unify_key_modifiers: true
 ```
+
+## Filters
+
+By default, Whim ignores a built-in list of windows that are known to cause problems with dynamic tiling window manager. Behind the scenes, Whim automatically updates the built-in list of ignored windows based on a subset of the rules from the community-driven [collection of application rules](https://github.com/LGUG2Z/komorebi-application-specific-configuration) managed by komorebi.
+
+### Custom Filtering Behavior
+
+The filters configuration tells Whim to ignore windows that match the specified criteria.
+
+You can filter windows by:
+
+- `window_class`
+- `process_file_name`
+- `title`
+- `title_regex`
+
+### Window Class Filter
+
+For example, to filter out Chromium windows with the class `Chrome_WidgetWin_1`, add the following to your configuration:
+
+```yaml
+filters:
+  entries:
+    - filter_type: window_class
+      value: Chrome_WidgetWin_1
+```
+
+### Process File Name Filter
+
+For example, to filter out windows with the process file name `explorer.exe`, add the following to your configuration:
+
+```yaml
+filters:
+  entries:
+    - filter_type: process_file_name
+      value: explorer.exe
+```
+
+### Title Filter
+
+For example, to filter out windows with the title `Untitled - Notepad`, add the following to your configuration:
+
+```yaml
+filters:
+  entries:
+    - filter_type: title
+      value: Untitled - Notepad
+```
+
+### Title Match Filter
+
+For example, to filter out windows with the title that matches the regex `^Untitled - Notepad$`, add the following to your configuration:
+
+```yaml
+filters:
+  entries:
+    - filter_type: title_regex
+      value: ^Untitled - Notepad$
+```
+
+## Routers
+
+The routers configuration tells Whim to route windows that match the specified criteria to the first workspace with name `workspace_name`.
+
+### Default Routing Behavior
+
+To customize the default window routing behavior, you can use the `routing_behavior` property. The default routing behavior is `route_to_launched_workspace`.
+
+The available routing behaviors are:
+
+- `route_to_launched_workspace`
+- `route_to_active_workspace`
+- `route_to_last_tracked_active_workspace`
+
+### Custom Routing Behavior
+
+You can also define custom routing behavior by specifying a list of routing entries. Each routing entry has a `router_type`, `value`, and `workspace_name`.
+
+The available router types are:
+
+- `window_class`
+- `process_file_name`
+- `title`
+- `title_regex`
+
+#### Window Class Router
+
+For example, to route Chromium windows with the class `Chrome_WidgetWin_1` to the workspace `web`, add the following to your configuration:
+
+```yaml
+routers:
+  entries:
+    - router_type: window_class
+      value: Chrome_WidgetWin_1
+      workspace_name: web
+```
+
+#### Process File Name Router
+
+For example, to route windows with the process file name `explorer.exe` to the workspace `file_explorer`, add the following to your configuration:
+
+```yaml
+routers:
+  entries:
+    - router_type: process_file_name
+      value: explorer.exe
+      workspace_name: file_explorer
+```
+
+#### Title Router
+
+For example, to route windows with the title `Untitled - Notepad` to the workspace `notepad`, add the following to your configuration:
+
+```yaml
+routers:
+  entries:
+    - router_type: title
+      value: Untitled - Notepad
+      workspace_name: notepad
+```
+
+#### Title Match Router
+
+For example, to route windows with the title that matches the regex `^Untitled - Notepad$` to the workspace `notepad`, add the following to your configuration:
+
+```yaml
+routers:
+  entries:
+    - router_type: title_regex
+      value: ^Untitled - Notepad$
+      workspace_name: notepad
+```
diff --git a/scripts/Generate-SourceFromSchema.ps1 b/scripts/Generate-SourceFromSchema.ps1
index 46272c6dd..cc6d1ecb3 100644
--- a/scripts/Generate-SourceFromSchema.ps1
+++ b/scripts/Generate-SourceFromSchema.ps1
@@ -2,11 +2,63 @@ Write-Host "Generating source from schema..."
 
 $schemaPath = ".\src\Whim.Yaml\schema.json"
 $outputPath = ".\src\Whim.Yaml\Generated"
+$metadataPath = "$outputPath\metadata.json"
+$now = Get-Date
+if ($env:CI) {
+    $gitSha = $env:GITHUB_SHA
+}
+else {
+    $gitSha = (git rev-parse HEAD)
+}
 
-New-Item $outputPath -ItemType Directory | Out-Null
+function Test-Regenerate {
+    param (
+        [string]$schemaPath = ".\src\Whim.Yaml\schema.json",
+        [string]$outputPath = ".\src\Whim.Yaml\Generated"
+    )
+
+    if (!(Test-Path $outputPath)) {
+        Write-Host "Output directory does not exist, generating..."
+        New-Item $outputPath -ItemType Directory
+        return $true
+    }
+
+    Write-Host "Output directory exists..."
+
+    if ((Test-Path $schemaPath) -eq $false) {
+        Write-Host "Schema file does not exist, skipping..."
+        return $false
+    }
+
+    $metadata = Get-Content $metadataPath | ConvertFrom-Json
+    if ($metadata.gitRef -ne $gitSha) {
+        Write-Host "Git ref has changed since last generation, regenerating..."
+        return $true
+    }
+
+    $schemaLastWriteTime = (Get-Item $schemaPath).LastWriteTime
+    if ($metadata.lastWriteTime -lt $schemaLastWriteTime) {
+        Write-Host "Schema has changed since last generation, regenerating..."
+        return $true
+    }
+
+    Write-Host "Schema has not changed since last generation, skipping..."
+    return $false
+}
+
+if (!(Test-Regenerate -schemaPath $schemaPath -outputPath $outputPath)) {
+    Write-Host "Skipping generation..."
+    return
+}
 
 dotnet tool run generatejsonschematypes `
     $schemaPath `
     --rootNamespace Whim.Yaml `
     --useSchema Draft7 `
     --outputPath $outputPath
+
+# If not in CI, write metadata file
+if ($null -eq $env:CI) {
+    Write-Host "Writing metadata file..."
+    @{ gitRef = $gitSha; lastWriteTime = $now } | ConvertTo-Json | Set-Content $metadataPath
+}
diff --git a/src/Whim.Tests/Router/RouterManagerTests.cs b/src/Whim.Tests/Router/RouterManagerTests.cs
index 9f7eccb63..eef81c717 100644
--- a/src/Whim.Tests/Router/RouterManagerTests.cs
+++ b/src/Whim.Tests/Router/RouterManagerTests.cs
@@ -1,18 +1,28 @@
+using System.Diagnostics.CodeAnalysis;
 using AutoFixture;
 
 namespace Whim.Tests;
 
-public class RouterManagerCustomization : ICustomization
+public class RouterManagerCustomization : StoreCustomization
 {
-	public void Customize(IFixture fixture)
-	{
-		IContext ctx = fixture.Freeze<IContext>();
-
-		IWorkspace workspace = fixture.Freeze<IWorkspace>();
-		workspace.Name.Returns("Test");
-
-		ctx.WorkspaceManager.TryGet("Test").Returns(workspace);
+	public IRouterManager? RouterManager { get; private set; }
 
+	[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope")]
+	protected override void PostCustomize(IFixture fixture)
+	{
+		// System under test
+		RouterManager = new RouterManager(_ctx);
+		fixture.Inject(RouterManager);
+
+		// Setup workspace
+		Workspace workspace = CreateWorkspace(_ctx) with
+		{
+			BackingName = "Test",
+		};
+		fixture.Inject(workspace);
+		AddWorkspaceToManager(_ctx, _store._root.MutableRootSector, workspace);
+
+		// Setup window
 		IWindow window = fixture.Freeze<IWindow>();
 		window.WindowClass.Returns("Test");
 		window.ProcessFileName.Returns("Test.exe");
@@ -23,49 +33,45 @@ public void Customize(IFixture fixture)
 public class RouterManagerTests
 {
 	[Theory, AutoSubstituteData<RouterManagerCustomization>]
-	public void AddWindowClassRouteString(IContext ctx, IWindow window)
+	internal void AddWindowClassRouteString(RouterManager routerManager, IWindow window)
 	{
 		// Given
-		RouterManager routerManager = new(ctx);
 
 		// When
 		routerManager.AddWindowClassRoute("Test", "Test");
 
 		// Then
-		Assert.Equal("Test", routerManager.RouteWindow(window)?.Name);
+		Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName);
 	}
 
 	[Theory, AutoSubstituteData<RouterManagerCustomization>]
-	public void AddWindowClassRoute(IContext ctx, IWindow window, IWorkspace workspace)
+	internal void AddWindowClassRoute(RouterManager routerManager, IWindow window, Workspace workspace)
 	{
 		// Given
-		RouterManager routerManager = new(ctx);
 
 		// When
 		routerManager.AddWindowClassRoute("Test", workspace);
 
 		// Then
-		Assert.Equal("Test", routerManager.RouteWindow(window)?.Name);
+		Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName);
 	}
 
 	[Theory, AutoSubstituteData<RouterManagerCustomization>]
-	public void AddProcessFileNameRouteString(IContext ctx, IWindow window)
+	internal void AddProcessFileNameRouteString(RouterManager routerManager, IWindow window)
 	{
 		// Given
-		RouterManager routerManager = new(ctx);
 
 		// When
 		routerManager.AddProcessFileNameRoute("Test.exe", "Test");
 
 		// Then
-		Assert.Equal("Test", routerManager.RouteWindow(window)?.Name);
+		Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName);
 	}
 
 	[Theory, AutoSubstituteData<RouterManagerCustomization>]
-	public void AddProcessFileNameRouteString_ProcessFileNameIsNull(IContext ctx, IWindow window)
+	internal void AddProcessFileNameRouteString_ProcessFileNameIsNull(RouterManager routerManager, IWindow window)
 	{
 		// Given
-		RouterManager routerManager = new(ctx);
 		routerManager.AddProcessFileNameRoute("Test.exe", "Test");
 
 		// When
@@ -76,23 +82,25 @@ public void AddProcessFileNameRouteString_ProcessFileNameIsNull(IContext ctx, IW
 	}
 
 	[Theory, AutoSubstituteData<RouterManagerCustomization>]
-	public void AddProcessFileNameRoute(IContext ctx, IWindow window, IWorkspace workspace)
+	internal void AddProcessFileNameRoute(RouterManager routerManager, IWindow window, Workspace workspace)
 	{
 		// Given
-		RouterManager routerManager = new(ctx);
 
 		// When
 		routerManager.AddProcessFileNameRoute("Test.exe", workspace);
 
 		// Then
-		Assert.Equal("Test", routerManager.RouteWindow(window)?.Name);
+		Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName);
 	}
 
 	[Theory, AutoSubstituteData<RouterManagerCustomization>]
-	public void AddProcessFileNameRoute_ProcessFileNameIsNull(IContext ctx, IWindow window, IWorkspace workspace)
+	internal void AddProcessFileNameRoute_ProcessFileNameIsNull(
+		RouterManager routerManager,
+		IWindow window,
+		Workspace workspace
+	)
 	{
 		// Given
-		RouterManager routerManager = new(ctx);
 		routerManager.AddProcessFileNameRoute("Test.exe", workspace);
 
 		// When
@@ -103,62 +111,57 @@ public void AddProcessFileNameRoute_ProcessFileNameIsNull(IContext ctx, IWindow
 	}
 
 	[Theory, AutoSubstituteData<RouterManagerCustomization>]
-	public void AddTitleRouteString(IContext ctx, IWindow window)
+	internal void AddTitleRouteString(RouterManager routerManager, IWindow window)
 	{
 		// Given
-		RouterManager routerManager = new(ctx);
 
 		// When
 		routerManager.AddTitleRoute("Test", "Test");
 
 		// Then
-		Assert.Equal("Test", routerManager.RouteWindow(window)?.Name);
+		Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName);
 	}
 
 	[Theory, AutoSubstituteData<RouterManagerCustomization>]
-	public void AddTitleRoute(IContext ctx, IWindow window, IWorkspace workspace)
+	internal void AddTitleRoute(RouterManager routerManager, IWindow window, Workspace workspace)
 	{
 		// Given
-		RouterManager routerManager = new(ctx);
 
 		// When
 		routerManager.AddTitleRoute("Test", workspace);
 
 		// Then
-		Assert.Equal("Test", routerManager.RouteWindow(window)?.Name);
+		Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName);
 	}
 
 	[Theory, AutoSubstituteData<RouterManagerCustomization>]
-	public void AddTitleMatchRouteString(IContext ctx, IWindow window)
+	internal void AddTitleMatchRouteString(RouterManager routerManager, IWindow window)
 	{
 		// Given
-		RouterManager routerManager = new(ctx);
 
 		// When
 		routerManager.AddTitleMatchRoute("Test", "Test");
 
 		// Then
-		Assert.Equal("Test", routerManager.RouteWindow(window)?.Name);
+		Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName);
 	}
 
 	[Theory, AutoSubstituteData<RouterManagerCustomization>]
-	public void AddTitleMatchRoute(IContext ctx, IWindow window, IWorkspace workspace)
+	internal void AddTitleMatchRoute(RouterManager routerManager, IWindow window, Workspace workspace)
 	{
 		// Given
-		RouterManager routerManager = new(ctx);
 
 		// When
 		routerManager.AddTitleMatchRoute("Test", workspace);
 
 		// Then
-		Assert.Equal("Test", routerManager.RouteWindow(window)?.Name);
+		Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName);
 	}
 
 	[Theory, AutoSubstituteData<RouterManagerCustomization>]
-	public void Clear(IContext ctx, IWindow window)
+	internal void Clear(RouterManager routerManager, IWindow window)
 	{
 		// Given
-		RouterManager routerManager = new(ctx);
 		routerManager.AddWindowClassRoute("Test", "Test");
 
 		// When
@@ -169,17 +172,15 @@ public void Clear(IContext ctx, IWindow window)
 	}
 
 	[Theory, AutoSubstituteData<RouterManagerCustomization>]
-	public void CustomRouter(IContext ctx, IWindow window, IWorkspace workspace)
+	internal void CustomRouter(RouterManager routerManager, IWindow window, Workspace workspace)
 	{
 		// Given
-		RouterManager routerManager = new(ctx);
-
 		routerManager.Add((w) => w.WindowClass == "Not Test" ? Substitute.For<IWorkspace>() : null);
 
 		// When
 		routerManager.Add((w) => w.WindowClass == "Test" ? workspace : null);
 
 		// Then
-		Assert.Equal("Test", routerManager.RouteWindow(window)?.Name);
+		Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName);
 	}
 }
diff --git a/src/Whim.Yaml.Tests/YamlLoaderTests.cs b/src/Whim.Yaml.Tests/YamlLoaderTests.cs
index c92b77187..7955ee3ef 100644
--- a/src/Whim.Yaml.Tests/YamlLoaderTests.cs
+++ b/src/Whim.Yaml.Tests/YamlLoaderTests.cs
@@ -93,4 +93,18 @@ public void Load_InvalidYamlConfig(IContext ctx)
 		Assert.False(result);
 		ctx.KeybindManager.DidNotReceive().SetKeybind(Arg.Any<string>(), Arg.Any<IKeybind>());
 	}
+
+	[Theory]
+	[InlineData("route_to_launched_workspace", "RouteToLaunchedWorkspace")]
+	[InlineData("route_to_active_workspace", "RouteToActiveWorkspace")]
+	[InlineData(" ", " ")]
+	public void SnakeToPascal(string snake, string expected)
+	{
+		// Given a snake case string
+		// When converting it to Pascal case
+		string result = snake.SnakeToPascal();
+
+		// Then the string is converted to camel case
+		Assert.Equal(expected, result);
+	}
 }
diff --git a/src/Whim.Yaml.Tests/YamlLoader_UpdateFiltersTests.cs b/src/Whim.Yaml.Tests/YamlLoader_UpdateFiltersTests.cs
new file mode 100644
index 000000000..b581f7d54
--- /dev/null
+++ b/src/Whim.Yaml.Tests/YamlLoader_UpdateFiltersTests.cs
@@ -0,0 +1,138 @@
+using NSubstitute;
+using Whim.TestUtils;
+using Xunit;
+
+namespace Whim.Yaml.Tests;
+
+public class YamlLoader_UpdateFiltersTests
+{
+	public static TheoryData<string, bool> FilterConfig =>
+		new()
+		{
+			{
+				"""
+					filters:
+					  entries:
+					  - filter_type: window_class
+					    value: class1
+					  - filter_type: process_file_name
+					    value: process1
+					  - filter_type: title
+					    value: title1
+					  - filter_type: title_regex
+					    value: titleMatch1
+					""",
+				true
+			},
+			{
+				"""
+					{
+						"filters": {
+							"entries": [
+								{
+									"filter_type": "window_class",
+									"value": "class1"
+								},
+								{
+									"filter_type": "process_file_name",
+									"value": "process1"
+								},
+								{
+									"filter_type": "title",
+									"value": "title1"
+								},
+								{
+									"filter_type": "title_regex",
+									"value": "titleMatch1"
+								}
+							]
+						}
+					}
+					""",
+				false
+			},
+		};
+
+	[Theory, MemberAutoSubstituteData<YamlLoaderCustomization>(nameof(FilterConfig))]
+	public void Load_Filters(string config, bool isYaml, IContext ctx)
+	{
+		// Given a valid config with filters set
+		ctx.FileManager.FileExists(Arg.Is<string>(s => s.EndsWith(isYaml ? "yaml" : "json"))).Returns(true);
+		ctx.FileManager.ReadAllText(Arg.Any<string>()).Returns(config);
+
+		// When loading the config
+		bool result = YamlLoader.Load(ctx);
+
+		// Then the filters should be updated
+		Assert.True(result);
+		ctx.FilterManager.Received(1).AddWindowClassFilter("class1");
+		ctx.FilterManager.Received(1).AddProcessFileNameFilter("process1");
+		ctx.FilterManager.Received(1).AddTitleFilter("title1");
+		ctx.FilterManager.Received(1).AddTitleMatchFilter("titleMatch1");
+	}
+
+	public static TheoryData<string, bool> InvalidFilterConfig =>
+		new()
+		{
+			{
+				"""
+					filters:
+					  entries:
+					  - filter_type: invalid
+					    value: class1
+					""",
+				true
+			},
+			{
+				"""
+					filters:
+					  entries:
+					  - type: windowClass
+					    value: false
+					""",
+				true
+			},
+			{
+				"""
+					{
+						"filters": {
+							"entries": [
+								{
+									"filter_type": "invalid",
+									"value": "class1"
+								}
+							]
+						}
+					}
+					""",
+				false
+			},
+			{
+				// Not technically invalid, but no filters are set
+				"""
+					{
+						"filters": {}
+					}
+					""",
+				false
+			},
+		};
+
+	[Theory, MemberAutoSubstituteData<YamlLoaderCustomization>(nameof(InvalidFilterConfig))]
+	public void Load_InvalidFilters(string config, bool isYaml, IContext ctx)
+	{
+		// Given an invalid config with filters set
+		ctx.FileManager.FileExists(Arg.Is<string>(s => s.EndsWith(isYaml ? "yaml" : "json"))).Returns(true);
+		ctx.FileManager.ReadAllText(Arg.Any<string>()).Returns(config);
+
+		// When loading the config
+		bool result = YamlLoader.Load(ctx);
+
+		// Then the filters should not be updated
+		Assert.True(result);
+		ctx.FilterManager.DidNotReceive().AddWindowClassFilter(Arg.Any<string>());
+		ctx.FilterManager.DidNotReceive().AddProcessFileNameFilter(Arg.Any<string>());
+		ctx.FilterManager.DidNotReceive().AddTitleFilter(Arg.Any<string>());
+		ctx.FilterManager.DidNotReceive().AddTitleMatchFilter(Arg.Any<string>());
+	}
+}
diff --git a/src/Whim.Yaml.Tests/YamlLoader_UpdateRoutersTests.cs b/src/Whim.Yaml.Tests/YamlLoader_UpdateRoutersTests.cs
new file mode 100644
index 000000000..ad9fd927f
--- /dev/null
+++ b/src/Whim.Yaml.Tests/YamlLoader_UpdateRoutersTests.cs
@@ -0,0 +1,177 @@
+using NSubstitute;
+using Whim.TestUtils;
+using Xunit;
+
+namespace Whim.Yaml.Tests;
+
+public class YamlLoader_UpdateRoutersTests
+{
+	public static TheoryData<string, bool> RouterConfig =>
+		new()
+		{
+			{
+				"""
+					routers:
+					  entries:
+					  - router_type: window_class
+					    value: class1
+					    workspace_name: workspace1
+					  - router_type: process_file_name
+					    value: process1
+					    workspace_name: workspace2
+					  - router_type: title
+					    value: title1
+					    workspace_name: workspace3
+					  - router_type: title_regex
+					    value: titleMatch1
+					    workspace_name: workspace4
+					""",
+				true
+			},
+			{
+				"""
+					{
+						"routers": {
+							"entries": [
+								{
+									"router_type": "window_class",
+									"value": "class1",
+									"workspace_name": "workspace1"
+								},
+								{
+									"router_type": "process_file_name",
+									"value": "process1",
+									"workspace_name": "workspace2"
+								},
+								{
+									"router_type": "title",
+									"value": "title1",
+									"workspace_name": "workspace3"
+								},
+								{
+									"router_type": "title_regex",
+									"value": "titleMatch1",
+									"workspace_name": "workspace4"
+								}
+							]
+						}
+					}
+					""",
+				false
+			},
+		};
+
+	[Theory, MemberAutoSubstituteData<YamlLoaderCustomization>(nameof(RouterConfig))]
+	public void Load_Routers(string config, bool isYaml, IContext ctx)
+	{
+		// Given a valid config with routers set
+		ctx.FileManager.FileExists(Arg.Is<string>(s => s.EndsWith(isYaml ? "yaml" : "json"))).Returns(true);
+		ctx.FileManager.ReadAllText(Arg.Any<string>()).Returns(config);
+
+		// When loading the config
+		bool result = YamlLoader.Load(ctx);
+
+		// Then the routers are updated
+		Assert.True(result);
+		ctx.RouterManager.Received(1).AddWindowClassRoute("class1", "workspace1");
+		ctx.RouterManager.Received(1).AddProcessFileNameRoute("process1", "workspace2");
+		ctx.RouterManager.Received(1).AddTitleRoute("title1", "workspace3");
+		ctx.RouterManager.Received(1).AddTitleMatchRoute("titleMatch1", "workspace4");
+	}
+
+	public static TheoryData<string, bool> InvalidRouterConfig =>
+		new()
+		{
+			{
+				"""
+					routers:
+					  entries:
+					  - router_type: invalid
+					    value: class1
+					    workspace_name: workspace1
+					""",
+				true
+			},
+			{
+				"""
+					{
+						"routers": {
+							"entries": [
+								{
+									"router_type": "invalid",
+									"value": "class1",
+									"workspace_name": "workspace1"
+								}
+							]
+						}
+					}
+					""",
+				false
+			},
+		};
+
+	[Theory, MemberAutoSubstituteData<YamlLoaderCustomization>(nameof(InvalidRouterConfig))]
+	public void Load_InvalidRouters(string config, bool isYaml, IContext ctx)
+	{
+		// Given an invalid config with routers set
+		ctx.FileManager.FileExists(Arg.Is<string>(s => s.EndsWith(isYaml ? "yaml" : "json"))).Returns(true);
+		ctx.FileManager.ReadAllText(Arg.Any<string>()).Returns(config);
+
+		// When loading the config
+		bool result = YamlLoader.Load(ctx);
+
+		// Then the routers are not updated
+		Assert.True(result);
+		ctx.RouterManager.DidNotReceive().AddWindowClassRoute(Arg.Any<string>(), Arg.Any<string>());
+		ctx.RouterManager.DidNotReceive().AddProcessFileNameRoute(Arg.Any<string>(), Arg.Any<string>());
+		ctx.RouterManager.DidNotReceive().AddTitleRoute(Arg.Any<string>(), Arg.Any<string>());
+		ctx.RouterManager.DidNotReceive().AddTitleMatchRoute(Arg.Any<string>(), Arg.Any<string>());
+	}
+
+	[Theory]
+	[InlineAutoSubstituteData<YamlLoaderCustomization>(
+		"route_to_launched_workspace",
+		RouterOptions.RouteToLaunchedWorkspace
+	)]
+	[InlineAutoSubstituteData<YamlLoaderCustomization>(
+		"route_to_active_workspace",
+		RouterOptions.RouteToActiveWorkspace
+	)]
+	[InlineAutoSubstituteData<YamlLoaderCustomization>(
+		"route_to_last_tracked_active_workspace",
+		RouterOptions.RouteToLastTrackedActiveWorkspace
+	)]
+	public void Load_RouterBehavior(string routerOption, RouterOptions expected, IContext ctx)
+	{
+		// Given a valid config with a router option set
+		string config = "routers:\n" + "  routing_behavior: " + routerOption + "\n";
+		ctx.FileManager.FileExists(Arg.Is<string>(s => s.EndsWith("yaml"))).Returns(true);
+		ctx.FileManager.ReadAllText(Arg.Any<string>()).Returns(config);
+
+		// When loading the config
+		bool result = YamlLoader.Load(ctx);
+
+		// Then the router option is updated
+		Assert.True(result);
+		ctx.RouterManager.Received(1).RouterOptions = expected;
+	}
+
+	[Theory, AutoSubstituteData<YamlLoaderCustomization>]
+	public void Load_InvalidRouterBehavior(IContext ctx)
+	{
+		// Given an invalid config with a router option set
+		string config = """
+			routers:
+			  routing_behavior: route_to_bogus_workspace
+			""";
+		ctx.FileManager.FileExists(Arg.Is<string>(s => s.EndsWith("yaml"))).Returns(true);
+		ctx.FileManager.ReadAllText(Arg.Any<string>()).Returns(config);
+
+		// When loading the config
+		bool result = YamlLoader.Load(ctx);
+
+		// Then the router option is not updated
+		Assert.True(result);
+		ctx.RouterManager.DidNotReceive().RouterOptions = Arg.Any<RouterOptions>();
+	}
+}
diff --git a/src/Whim.Yaml/Whim.Yaml.csproj b/src/Whim.Yaml/Whim.Yaml.csproj
index f27a3cba3..aefe4838f 100644
--- a/src/Whim.Yaml/Whim.Yaml.csproj
+++ b/src/Whim.Yaml/Whim.Yaml.csproj
@@ -44,7 +44,7 @@
 		<PackageReference Include="YamlDotNet" />
 	</ItemGroup>
 
-	<Target Name="GenerateSchema" BeforeTargets="BeforeCompile;CoreCompile;PreBuildEvent">
+	<Target Name="GenerateSchema" BeforeTargets="BeforeCompile">
 		<Exec
 			Command="powershell.exe -NonInteractive -ExecutionPolicy Unrestricted .\scripts\Generate-SourceFromSchema.ps1"
 			WorkingDirectory="..\..\" ConsoleToMsBuild="true">
diff --git a/src/Whim.Yaml/YamlLoader.cs b/src/Whim.Yaml/YamlLoader.cs
index 21c15439c..0da106d1d 100644
--- a/src/Whim.Yaml/YamlLoader.cs
+++ b/src/Whim.Yaml/YamlLoader.cs
@@ -1,3 +1,4 @@
+using System.Text;
 using System.Text.Json;
 using System.Text.Json.Nodes;
 using Corvus.Json;
@@ -19,7 +20,7 @@ public static class YamlLoader
 	/// </summary>
 	/// <param name="ctx">The <see cref="IContext"/> to operate on.</param>
 	/// <returns>
-	/// <see langword="true"/> if the configuration was loaded successfully; otherwise, <see langword="false"/>.
+	/// <see langword="true"/> if the configuration was parsed successfully; otherwise, <see langword="false"/>.
 	/// </returns>
 	public static bool Load(IContext ctx)
 	{
@@ -29,6 +30,8 @@ public static bool Load(IContext ctx)
 		}
 
 		UpdateKeybinds(ctx, schema);
+		UpdateFilters(ctx, schema);
+		UpdateRouters(ctx, schema);
 		return true;
 	}
 
@@ -89,4 +92,96 @@ private static void UpdateKeybinds(IContext ctx, Schema schema)
 			ctx.KeybindManager.SetKeybind((string)pair.Command, keybind);
 		}
 	}
+
+	private static void UpdateFilters(IContext ctx, Schema schema)
+	{
+		if (!schema.Filters.IsValid() || schema.Filters.Entries.AsOptional() is not { } entries)
+		{
+			return;
+		}
+
+		foreach (var filter in entries)
+		{
+			string value = (string)filter.Value;
+
+			switch ((string)filter.FilterType)
+			{
+				case "window_class":
+					ctx.FilterManager.AddWindowClassFilter(value);
+					break;
+				case "process_file_name":
+					ctx.FilterManager.AddProcessFileNameFilter(value);
+					break;
+				case "title":
+					ctx.FilterManager.AddTitleFilter(value);
+					break;
+				case "title_regex":
+					ctx.FilterManager.AddTitleMatchFilter(value);
+					break;
+				default:
+					Logger.Error($"Invalid filter type: {filter.FilterType}");
+					break;
+			}
+		}
+	}
+
+	private static void UpdateRouters(IContext ctx, Schema schema)
+	{
+		if (!schema.Routers.IsValid())
+		{
+			return;
+		}
+
+		if (
+			schema.Routers.RoutingBehavior.TryGetString(out string? routingBehavior)
+			&& Enum.TryParse(routingBehavior?.SnakeToPascal(), out RouterOptions routerOptions)
+		)
+		{
+			ctx.RouterManager.RouterOptions = routerOptions;
+		}
+
+		if (schema.Routers.Entries.AsOptional() is not { } entries)
+		{
+			return;
+		}
+
+		foreach (var router in entries)
+		{
+			string value = (string)router.Value;
+			string workspaceName = (string)router.WorkspaceName;
+
+			switch ((string)router.RouterType)
+			{
+				case "window_class":
+					ctx.RouterManager.AddWindowClassRoute(value, workspaceName);
+					break;
+				case "process_file_name":
+					ctx.RouterManager.AddProcessFileNameRoute(value, workspaceName);
+					break;
+				case "title":
+					ctx.RouterManager.AddTitleRoute(value, workspaceName);
+					break;
+				case "title_regex":
+					ctx.RouterManager.AddTitleMatchRoute(value, workspaceName);
+					break;
+				default:
+					Logger.Error($"Invalid router type: {router.RouterType}");
+					break;
+			}
+		}
+	}
+
+	internal static string SnakeToPascal(this string snake)
+	{
+		string[] parts = snake.Split('_');
+		StringBuilder builder = new(snake.Length);
+
+		foreach (string part in parts)
+		{
+			builder.Append(char.ToUpper(part[0]));
+			builder.Append(part.AsSpan(1));
+		}
+
+		return builder.ToString();
+	}
 }
diff --git a/src/Whim.Yaml/schema.json b/src/Whim.Yaml/schema.json
index 1aeade28a..5e17c40cb 100644
--- a/src/Whim.Yaml/schema.json
+++ b/src/Whim.Yaml/schema.json
@@ -17,7 +17,35 @@
 
         "unify_key_modifiers": {
           "type": "boolean",
-          "description": "Whether to treat key modifiers like `LWin` and `RWin` as the same key modifier. Defaults to `true`."
+          "description": "Whether to treat key modifiers like `LWin` and `RWin` as the same key modifier. Defaults to `true`.",
+          "default": true
+        }
+      }
+    },
+
+    "filters": {
+      "type": "object",
+      "properties": {
+        "entries": {
+          "type": "array",
+          "items": { "$ref": "#/$defs/Filter" },
+          "description": "Filters to apply to windows to determine whether they should be managed by Whim"
+        }
+      }
+    },
+
+    "routers": {
+      "type": "object",
+      "properties": {
+        "entries": {
+          "type": "array",
+          "items": { "$ref": "#/$defs/Router" },
+          "description": "Routers to determine which workspace a window should be placed in"
+        },
+
+        "routing_behavior": {
+          "$ref": "#/$defs/RoutingBehavior",
+          "description": "The behavior to use when routing windows to workspaces"
         }
       }
     }
@@ -38,6 +66,89 @@
           "description": "The keybind to trigger the command. For more, see https://dalyisaac.github.io/Whim/docs/customize/keybinds.html"
         }
       }
+    },
+
+    "Filter": {
+      "type": "object",
+      "required": ["filter_type", "value"],
+      "additionalProperties": false,
+      "properties": {
+        "filter_type": {
+          "anyOf": [
+            {
+              "const": "window_class",
+              "description": "Ignores windows with the specified window class"
+            },
+            {
+              "const": "process_file_name",
+              "description": "Ignores windows with the specified process file name"
+            },
+            {
+              "const": "title",
+              "description": "Ignores windows with the specified title"
+            },
+            {
+              "const": "title_regex",
+              "description": "Ignores windows with a title that matches the specified regex"
+            }
+          ]
+        },
+        "value": {
+          "type": "string",
+          "description": "The value to filter by"
+        }
+      }
+    },
+
+    "Router": {
+      "type": "object",
+      "required": ["router_type", "value", "workspace_name"],
+      "additionalProperties": false,
+      "properties": {
+        "router_type": {
+          "anyOf": [
+            {
+              "const": "window_class",
+              "description": "Routes windows with the specified window class to the specified workspace"
+            },
+            {
+              "const": "process_file_name",
+              "description": "Routes windows with the specified process file name to the specified workspace"
+            },
+            {
+              "const": "title",
+              "description": "Routes windows with the specified title to the specified workspace"
+            },
+            {
+              "const": "title_regex",
+              "description": "Routes windows with a title that matches the specified regex to the specified workspace"
+            }
+          ]
+        },
+        "value": { "type": "string", "description": "The value to route by" },
+        "workspace_name": {
+          "type": "string",
+          "description": "The workspace to route to"
+        }
+      }
+    },
+
+    "RoutingBehavior": {
+      "anyOf": [
+        {
+          "const": "route_to_launched_workspace",
+          "description": "Routes windows to the workspace that the window was launched on by Windows."
+        },
+        {
+          "const": "route_to_active_workspace",
+          "description": "Routes windows to the workspace which last received an event sent by Windows."
+        },
+        {
+          "const": "route_to_last_tracked_active_workspace",
+          "description": "Routes windows to the workspace which last received an event sent by Windows which Whim did not ignore."
+        }
+      ],
+      "default": "route_to_launched_workspace"
     }
   }
 }
diff --git a/src/Whim/Router/RouterManager.cs b/src/Whim/Router/RouterManager.cs
index 39d1d37ed..41435e14d 100644
--- a/src/Whim/Router/RouterManager.cs
+++ b/src/Whim/Router/RouterManager.cs
@@ -29,7 +29,7 @@ public IRouterManager AddProcessFileNameRoute(string processFileName, string wor
 		{
 			if (window.ProcessFileName?.ToLower() == processFileName)
 			{
-				return _context.WorkspaceManager.TryGet(workspaceName);
+				return _context.Store.Pick(PickWorkspaceByName(workspaceName)).ValueOrDefault;
 			}
 			return null;
 		});
@@ -59,7 +59,7 @@ public IRouterManager AddTitleRoute(string title, string workspaceName)
 		{
 			if (window.Title.Equals(title, StringComparison.CurrentCultureIgnoreCase))
 			{
-				return _context.WorkspaceManager.TryGet(workspaceName);
+				return _context.Store.Pick(PickWorkspaceByName(workspaceName)).ValueOrDefault;
 			}
 			return null;
 		});
@@ -89,7 +89,7 @@ public IRouterManager AddTitleMatchRoute(string match, string workspaceName)
 		{
 			if (regex.IsMatch(window.Title))
 			{
-				return _context.WorkspaceManager.TryGet(workspaceName);
+				return _context.Store.Pick(PickWorkspaceByName(workspaceName)).ValueOrDefault;
 			}
 			return null;
 		});
@@ -136,7 +136,7 @@ public IRouterManager AddWindowClassRoute(string windowClass, string workspaceNa
 		{
 			if (window.WindowClass.Equals(windowClass, StringComparison.CurrentCultureIgnoreCase))
 			{
-				return _context.WorkspaceManager.TryGet(workspaceName);
+				return _context.Store.Pick(PickWorkspaceByName(workspaceName)).ValueOrDefault;
 			}
 			return null;
 		});
diff --git a/src/Whim/Router/RouterOptions.cs b/src/Whim/Router/RouterOptions.cs
index fafcb639b..4a4d85a0c 100644
--- a/src/Whim/Router/RouterOptions.cs
+++ b/src/Whim/Router/RouterOptions.cs
@@ -6,19 +6,19 @@ namespace Whim;
 public enum RouterOptions
 {
 	/// <summary>
-	/// Routes windows to the workspace which is currently active on the monitor the window is on.
+	/// Routes windows to the workspace that the window was launched on by Windows.
 	/// </summary>
 	RouteToLaunchedWorkspace,
 
 	/// <summary>
 	/// Routes windows to the active workspace. This may lead to unexpected results, as the
-	/// <see cref="IMonitorManager.ActiveMonitor"/> and thus <see cref="IWorkspaceManager.ActiveWorkspace"/>
+	/// <see cref="IMonitorSector.ActiveMonitorHandle"/> and thus the active workspace
 	/// will be updated by every window event sent by Windows - even those which Whim ignores.
 	///
 	/// <br/>
 	///
 	/// For example, launching an app from the taskbar on Windows 11 will cause <c>Shell_TrayWnd</c>
-	/// to focus on the main monitor, overriding the <see cref="IMonitorManager.ActiveMonitor"/>.
+	/// to focus on the main monitor, overriding the <see cref="IMonitorSector.ActiveMonitorHandle"/>.
 	/// As a result, the window will be routed to the workspace on the main monitor.
 	/// </summary>
 	RouteToActiveWorkspace,