diff --git a/package-lock.json b/package-lock.json
index e9aa8b42..51653db3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
         "@types/prismjs": "^1.26.5",
         "@types/react-syntax-highlighter": "^15.5.13",
         "@untitled-ui/icons-react": "^0.1.4",
+        "@xyflow/react": "^12.4.4",
         "clsx": "^2.1.1",
         "date-fns": "^4.1.0",
         "fuse.js": "^7.0.0",
@@ -4536,6 +4537,55 @@
       "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
       "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
     },
+    "node_modules/@types/d3-color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-drag": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+      "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/d3-interpolate": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-color": "*"
+      }
+    },
+    "node_modules/@types/d3-selection": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+      "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-transition": {
+      "version": "3.0.9",
+      "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+      "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/d3-zoom": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+      "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-interpolate": "*",
+        "@types/d3-selection": "*"
+      }
+    },
     "node_modules/@types/debug": {
       "version": "4.1.12",
       "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -5230,6 +5280,36 @@
         "url": "https://opencollective.com/vitest"
       }
     },
+    "node_modules/@xyflow/react": {
+      "version": "12.4.4",
+      "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.4.4.tgz",
+      "integrity": "sha512-9RZ9dgKZNJOlbrXXST5HPb5TcXPOIDGondjwcjDro44OQRPl1E0ZRPTeWPGaQtVjbg4WpR4BUYwOeshNI2TuVg==",
+      "license": "MIT",
+      "dependencies": {
+        "@xyflow/system": "0.0.52",
+        "classcat": "^5.0.3",
+        "zustand": "^4.4.0"
+      },
+      "peerDependencies": {
+        "react": ">=17",
+        "react-dom": ">=17"
+      }
+    },
+    "node_modules/@xyflow/system": {
+      "version": "0.0.52",
+      "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.52.tgz",
+      "integrity": "sha512-pJBMaoh/GEebIABWEIxAai0yf57dm+kH7J/Br+LnLFPuJL87Fhcmm4KFWd/bCUy/kCWUg+2/yFAGY0AUHRPOnQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-drag": "^3.0.7",
+        "@types/d3-selection": "^3.0.10",
+        "@types/d3-transition": "^3.0.8",
+        "@types/d3-zoom": "^3.0.8",
+        "d3-drag": "^3.0.0",
+        "d3-selection": "^3.0.0",
+        "d3-zoom": "^3.0.0"
+      }
+    },
     "node_modules/acorn": {
       "version": "8.14.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
@@ -6029,6 +6109,12 @@
         "consola": "^3.2.3"
       }
     },
+    "node_modules/classcat": {
+      "version": "5.0.5",
+      "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+      "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+      "license": "MIT"
+    },
     "node_modules/clean-stack": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@@ -6345,6 +6431,111 @@
       "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
       "license": "MIT"
     },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dispatch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+      "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-drag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+      "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-selection": "3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-selection": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+      "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-transition": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+      "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-dispatch": "1 - 3",
+        "d3-ease": "1 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "d3-selection": "2 - 3"
+      }
+    },
+    "node_modules/d3-zoom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+      "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-drag": "2 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-selection": "2 - 3",
+        "d3-transition": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/data-urls": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
@@ -15101,6 +15292,34 @@
         "zod": "^3.18.0"
       }
     },
+    "node_modules/zustand": {
+      "version": "4.5.6",
+      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz",
+      "integrity": "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==",
+      "license": "MIT",
+      "dependencies": {
+        "use-sync-external-store": "^1.2.2"
+      },
+      "engines": {
+        "node": ">=12.7.0"
+      },
+      "peerDependencies": {
+        "@types/react": ">=16.8",
+        "immer": ">=9.0.6",
+        "react": ">=16.8"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "immer": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/zwitch": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
diff --git a/package.json b/package.json
index 5fe1bab1..8cca6ce3 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
     "@types/prismjs": "^1.26.5",
     "@types/react-syntax-highlighter": "^15.5.13",
     "@untitled-ui/icons-react": "^0.1.4",
+    "@xyflow/react": "^12.4.4",
     "clsx": "^2.1.1",
     "date-fns": "^4.1.0",
     "fuse.js": "^7.0.0",
diff --git a/src/Page.tsx b/src/Page.tsx
index e2d4c30d..6968face 100644
--- a/src/Page.tsx
+++ b/src/Page.tsx
@@ -11,6 +11,7 @@ import { RouteNotFound } from './routes/route-not-found'
 import { RouteProvider } from './routes/route-providers'
 import { RouteProviderCreate } from './routes/route-provider-create'
 import { RouteProviderUpdate } from './routes/route-provider-update'
+import { RouteMuxes } from './routes/route-mux-config'
 
 export default function Page() {
   return (
@@ -20,6 +21,7 @@ export default function Page() {
       <Route path="/certificates" element={<RouteCertificates />} />
       <Route path="/workspace/:name" element={<RouteWorkspace />} />
       <Route path="/workspaces" element={<RouteWorkspaces />} />
+      <Route path="/muxes" element={<RouteMuxes />} />
       <Route path="/workspace/create" element={<RouteWorkspaceCreation />} />
       <Route
         path="/certificates/security"
diff --git a/src/components/icons/icon-regex.tsx b/src/components/icons/icon-regex.tsx
new file mode 100644
index 00000000..fd6b16d6
--- /dev/null
+++ b/src/components/icons/icon-regex.tsx
@@ -0,0 +1,21 @@
+import type { SVGProps } from 'react'
+
+export const IconRegex = (props: SVGProps<SVGSVGElement>) => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="24"
+    height="24"
+    viewBox="0 0 24 24"
+    fill="none"
+    stroke="currentColor"
+    stroke-width="2"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+    {...props}
+  >
+    <path d="M17 3v10" />
+    <path d="m12.67 5.5 8.66 5" />
+    <path d="m12.67 10.5 8.66-5" />
+    <path d="M9 17a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2z" />
+  </svg>
+)
diff --git a/src/features/muxing/components/mux-node-base.tsx b/src/features/muxing/components/mux-node-base.tsx
new file mode 100644
index 00000000..74b3fed2
--- /dev/null
+++ b/src/features/muxing/components/mux-node-base.tsx
@@ -0,0 +1,55 @@
+import { Handle, Position } from '@xyflow/react'
+import * as config from '../constants/mux-node-config'
+import { twMerge } from 'tailwind-merge'
+import { tv } from 'tailwind-variants'
+import { CSSProperties, ReactNode } from 'react'
+
+const nodeStyles = tv({
+  base: 'w-full overflow-hidden rounded border border-gray-200 bg-base shadow-sm',
+})
+
+export const MuxNodeBase = ({
+  hasSourceRight,
+  hasTargetLeft,
+  children,
+  className,
+  icon: Icon,
+  style,
+}: {
+  icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element
+  className?: string
+  children: ReactNode
+  hasTargetLeft?: boolean
+  hasSourceRight?: boolean
+  style?: CSSProperties
+}) => {
+  return (
+    <>
+      {hasTargetLeft ? <Handle type="target" position={Position.Left} /> : null}
+
+      <div
+        style={{
+          height: config.HEIGHT_NODE,
+          ...style,
+        }}
+        className={twMerge(nodeStyles(), 'flex items-center')}
+      >
+        <div
+          className="flex shrink-0 items-center justify-center bg-gray-50"
+          style={{
+            height: config.HEIGHT_NODE,
+            width: config.HEIGHT_NODE,
+          }}
+        >
+          <Icon className="size-5" />
+        </div>
+
+        <div className={twMerge('w-full px-4 py-3', className)}>{children}</div>
+      </div>
+
+      {hasSourceRight ? (
+        <Handle type="source" position={Position.Right} />
+      ) : null}
+    </>
+  )
+}
diff --git a/src/features/muxing/components/mux-node-matcher.tsx b/src/features/muxing/components/mux-node-matcher.tsx
new file mode 100644
index 00000000..823c1baa
--- /dev/null
+++ b/src/features/muxing/components/mux-node-matcher.tsx
@@ -0,0 +1,69 @@
+import { MuxNodeBase } from './mux-node-base'
+import { Lock01 } from '@untitled-ui/icons-react'
+import * as config from '../constants/mux-node-config'
+import SvgDrag from '@/components/icons/Drag'
+import {
+  FieldGroup,
+  Input,
+  Select,
+  SelectButton,
+  TextField,
+} from '@stacklok/ui-kit'
+import { IconRegex } from '@/components/icons/icon-regex'
+import { Node } from '@xyflow/react'
+
+export const MuxNodeMatcher = ({
+  id,
+  data,
+}: Partial<Node> & {
+  data: {
+    isDisabled?: boolean
+    onChange: (id: string | undefined, v: string) => void
+  }
+}) => {
+  return (
+    <MuxNodeBase
+      style={{
+        width: config.WIDTH_NODE,
+      }}
+      hasSourceRight
+      hasTargetLeft
+      icon={IconRegex}
+      className=""
+    >
+      <FieldGroup className="grid w-full grid-cols-[1fr_2fr] items-center">
+        <Select
+          defaultSelectedKey={'all'}
+          items={[
+            {
+              textValue: 'All',
+              id: 'all',
+            },
+            {
+              textValue: 'FIM',
+              id: 'fim',
+            },
+            {
+              textValue: 'Chat',
+              id: 'chat',
+            },
+          ]}
+        >
+          <SelectButton isBorderless className="border-r border-r-gray-200" />
+        </Select>
+        <TextField
+          isDisabled={data.isDisabled}
+          type="text"
+          aria-label="Matcher"
+          onChange={(v) => data.onChange(id, v)}
+        >
+          <Input
+            isBorderless
+            icon={data.isDisabled ? <Lock01 /> : null}
+            placeholder="e.g. *.ts"
+          />
+        </TextField>
+      </FieldGroup>
+    </MuxNodeBase>
+  )
+}
diff --git a/src/features/muxing/components/mux-node-prompt.tsx b/src/features/muxing/components/mux-node-prompt.tsx
new file mode 100644
index 00000000..f7be4239
--- /dev/null
+++ b/src/features/muxing/components/mux-node-prompt.tsx
@@ -0,0 +1,10 @@
+import { MuxNodeBase } from './mux-node-base'
+import { MessageDotsCircle } from '@untitled-ui/icons-react'
+
+export const MuxNodePrompt = () => {
+  return (
+    <MuxNodeBase hasSourceRight icon={MessageDotsCircle}>
+      <span className="font-semibold">Prompt</span>
+    </MuxNodeBase>
+  )
+}
diff --git a/src/features/muxing/constants/mux-node-config.tsx b/src/features/muxing/constants/mux-node-config.tsx
new file mode 100644
index 00000000..74296c85
--- /dev/null
+++ b/src/features/muxing/constants/mux-node-config.tsx
@@ -0,0 +1,7 @@
+export const GRID_SIZE = 90
+export const PADDING_GROUP = 12
+export const HEIGHT_GROUP_HEADER = 40
+export const HEIGHT_NODE = 64
+export const HEIGHT_CONTAINER = 512
+export const WIDTH_GROUP = 500
+export const WIDTH_NODE = WIDTH_GROUP - PADDING_GROUP * 2
diff --git a/src/routes/route-mux-config.tsx b/src/routes/route-mux-config.tsx
new file mode 100644
index 00000000..b847f2a6
--- /dev/null
+++ b/src/routes/route-mux-config.tsx
@@ -0,0 +1,550 @@
+import { PageContainer } from '@/components/page-container'
+import {
+  Node,
+  ReactFlow,
+  Controls,
+  Background,
+  applyNodeChanges,
+  applyEdgeChanges,
+  addEdge,
+  Position,
+  Handle,
+  ConnectionLineType,
+  OnNodeDrag,
+} from '@xyflow/react'
+import { useCallback, useState } from 'react'
+import * as config from '../features/muxing/constants/mux-node-config'
+import '@xyflow/react/dist/style.css'
+import {
+  Button,
+  ComboBox,
+  ComboBoxInput,
+  FieldGroup,
+  Heading,
+  Input,
+  Select,
+  SelectButton,
+  TextField,
+  Tooltip,
+  TooltipInfoButton,
+  TooltipTrigger,
+} from '@stacklok/ui-kit'
+import { tv } from 'tailwind-variants'
+import { PageHeading } from '@/components/heading'
+import {
+  ChartBreakoutCircle,
+  Lock01,
+  Plus,
+  SearchMd,
+} from '@untitled-ui/icons-react'
+import { twMerge } from 'tailwind-merge'
+import SvgDrag from '@/components/icons/Drag'
+import { IconRegex } from '@/components/icons/icon-regex'
+import { MuxNodePrompt } from '@/features/muxing/components/mux-node-prompt'
+import { MuxNodeMatcher } from '@/features/muxing/components/mux-node-matcher'
+
+const nodeStyles = tv({
+  base: 'w-full rounded border border-gray-200 bg-base p-4 shadow-sm',
+})
+const groupStyles = tv({
+  base: `bg-gray-50/50 -z-10 h-auto min-h-[calc(100%-48px)] rounded-lg !border
+  !border-gray-200 stroke-gray-200 backdrop-blur-sm`,
+})
+
+enum NodeType {
+  MATCHER_GROUP = 'matcherGroup',
+  MODEL_GROUP = 'modelGroup',
+  PROMPT = 'prompt',
+  MATCHER = 'matcher',
+  MODEL = 'model',
+}
+
+function computeGroupNodeY(index: number) {
+  return (
+    config.PADDING_GROUP * 4 +
+    config.HEIGHT_GROUP_HEADER +
+    config.HEIGHT_NODE * index +
+    config.PADDING_GROUP * index
+  )
+}
+
+const initialNodes: Node[] = [
+  {
+    id: 'prompt',
+    type: NodeType.PROMPT,
+    data: { label: 'Prompt' },
+    position: { x: 50, y: config.HEIGHT_CONTAINER / 2 },
+    origin: [0.5, 0.5],
+    sourcePosition: Position.Right,
+    draggable: false,
+  },
+  {
+    id: 'matcher-group',
+    type: NodeType.MATCHER_GROUP,
+    data: {
+      title: 'Matchers',
+      description:
+        'Matchers use regex patterns to route requests to specific models.',
+    },
+    position: {
+      x: 200,
+      y: config.HEIGHT_CONTAINER / 2 - config.HEIGHT_GROUP_HEADER / 2,
+    },
+    origin: [0, 0.5],
+    style: {
+      width: config.WIDTH_GROUP,
+      // height: '100%',
+    },
+    draggable: false,
+  },
+  {
+    id: 'model-group',
+    type: NodeType.MODEL_GROUP,
+    data: {
+      title: 'Models',
+      description: 'Add model nodes here',
+    },
+    position: {
+      x: 720,
+      y: config.HEIGHT_CONTAINER / 2 - config.HEIGHT_GROUP_HEADER / 2,
+    },
+    origin: [0, 0.5],
+    style: {
+      width: config.WIDTH_GROUP,
+      // height: '100%',
+    },
+    draggable: false,
+  },
+
+  {
+    id: 'matcher-0',
+    type: NodeType.MATCHER,
+    data: { label: 'catch-all', isDisabled: true },
+    position: {
+      x: 250,
+      y: computeGroupNodeY(0),
+    },
+    parentId: 'matcher-group',
+    origin: [0.5, 0.5],
+    extent: 'parent',
+    targetPosition: Position.Left,
+    sourcePosition: Position.Right,
+  },
+]
+
+const EDGE = {
+  type: ConnectionLineType.Bezier,
+  animated: true,
+}
+
+const initialEdges = [
+  {
+    id: 'edge-0',
+    source: 'prompt',
+    target: 'matcher-0',
+    ...EDGE,
+  },
+  {
+    id: 'edge-1',
+    source: 'matcher-0',
+    target: 'model-0',
+    ...EDGE,
+  },
+]
+
+/**
+ * Ensures correct ordering of "matcher" nodes,
+ * both visually, and in the list of nodes.
+ */
+function alignMatcherNodes(nodes: Node[]) {
+  const matcherNodes = nodes.filter((n) => n.type === NodeType.MATCHER)
+
+  // Ensure that the last matcher node is always
+  // `matcher-0` (the catch-all matcher)
+  if (
+    matcherNodes.length > 0 &&
+    matcherNodes[matcherNodes.length - 1]?.id !== 'matcher-0'
+  ) {
+    const catchallNodeIndex = matcherNodes.findIndex(
+      (n) => n.id === 'matcher-0'
+    )
+    if (catchallNodeIndex !== -1) {
+      const fallbackNode = matcherNodes.splice(catchallNodeIndex, 1)[0]
+      if (fallbackNode) matcherNodes.push(fallbackNode)
+    }
+  }
+
+  // Update Y position of matcher nodes, so that their
+  // visual position reflects their position in the list
+  matcherNodes.forEach((n, i) => {
+    n.position.y = computeGroupNodeY(i)
+  })
+
+  // Re-integrate the matcher nodes into the node list
+  return nodes.map((n) =>
+    n.type === NodeType.MATCHER ? matcherNodes.shift() : n
+  )
+}
+
+function isOverlapping(node1: Node, node2: Node) {
+  const node1Bottom = node1.position.y + config.HEIGHT_NODE
+  const node2Bottom = node2.position.y + config.HEIGHT_NODE
+
+  return (
+    node1.position.y < node2Bottom &&
+    node1Bottom > node2.position.y &&
+    node1.position.x < node2.position.x + config.WIDTH_NODE &&
+    node1.position.x + config.WIDTH_NODE > node2.position.x
+  )
+}
+
+export function RouteMuxes() {
+  const [nodes, setNodes] = useState(initialNodes)
+  const [edges, setEdges] = useState(initialEdges)
+
+  const onNodesChange = (changes) =>
+    setNodes((nds) => applyNodeChanges(changes, nds))
+  const onEdgesChange = (changes) =>
+    setEdges((eds) =>
+      applyEdgeChanges(
+        {
+          ...changes,
+          ...EDGE,
+        },
+        eds
+      )
+    )
+
+  const onConnect = useCallback(
+    (params) =>
+      setEdges((eds) =>
+        addEdge(
+          {
+            ...params,
+            ...EDGE,
+          },
+          eds
+        )
+      ),
+    []
+  )
+
+  const addMatcherNode = () => {
+    const matcherNodes = nodes.filter(
+      (node) => node.type === 'matcher' && node.id !== 'matcher-0'
+    )
+    const newNode: Node = {
+      id: `matcher-${matcherNodes.length + 1}`,
+      type: 'matcher',
+      data: { label: '', onChange: handleNodeChange },
+      position: {
+        x: config.WIDTH_GROUP / 2,
+        y: computeGroupNodeY(0),
+      },
+      parentId: 'matcher-group',
+      origin: [0.5, 0.5],
+      extent: 'parent',
+      targetPosition: Position.Left,
+      sourcePosition: Position.Right,
+    }
+    setNodes((nds) => {
+      const updatedNodes = [...nds, newNode]
+      return alignMatcherNodes(updatedNodes)
+    })
+
+    const newEdge = {
+      id: `edge-${nodes.length}`,
+      source: 'prompt',
+      target: newNode.id,
+      type: ConnectionLineType.Bezier,
+      animated: true,
+    }
+    setEdges((eds) => [...eds, newEdge])
+  }
+
+  const addModelNode = useCallback(() => {
+    const newNode: Node = {
+      id: `model-${nodes.length}`,
+      type: NodeType.MODEL,
+      data: { label: 'Qwen', onChange: handleNodeChange },
+      position: {
+        x: 250,
+        y:
+          nodes.filter((node) => node.id.startsWith('model')).length *
+          GRID_SIZE,
+      },
+      origin: [0.5, 0.5],
+      parentId: 'model-group',
+      extent: 'parent',
+      targetPosition: Position.Right,
+    }
+    setNodes((nds) => [...nds, newNode])
+  }, [nodes])
+
+  const handleNodeChange = (id, value) => {
+    setNodes((nds) =>
+      nds.map((node) =>
+        node.id === id
+          ? { ...node, data: { ...node.data, label: value } }
+          : node
+      )
+    )
+  }
+
+  const onNodeDragStop = useCallback<OnNodeDrag<Node>>((event, node) => {
+    setNodes((nds) => {
+      const updatedNodes = nds.map((n) =>
+        n.id === node.id ? { ...n, position: node.position } : n
+      )
+
+      const overlappingNode = updatedNodes.find(
+        (n) => n.id !== node.id && isOverlapping(n, node)
+      )
+
+      if (overlappingNode) {
+        const nodeIndex = updatedNodes.findIndex((n) => n.id === node.id)
+        const overlappingNodeIndex = updatedNodes.findIndex(
+          (n) => n.id === overlappingNode.id
+        )
+
+        if (nodeIndex !== -1 && overlappingNodeIndex !== -1) {
+          const temp = updatedNodes[nodeIndex]
+          if (
+            updatedNodes[overlappingNodeIndex] &&
+            updatedNodes[nodeIndex] &&
+            temp
+          ) {
+            updatedNodes[nodeIndex] = updatedNodes[overlappingNodeIndex]
+            updatedNodes[overlappingNodeIndex] = temp
+
+            updatedNodes[nodeIndex].position.y = computeGroupNodeY(nodeIndex)
+            updatedNodes[overlappingNodeIndex].position.y =
+              computeGroupNodeY(overlappingNodeIndex)
+          }
+        }
+      }
+
+      return alignMatcherNodes(updatedNodes)
+    })
+  }, [])
+
+  return (
+    <PageContainer className="flex min-h-dvh flex-col">
+      <PageHeading level={1} title="Muxing" />
+      <p className="mb-2 max-w-6xl text-balance text-secondary">
+        Model muxing (or multiplexing), allows you to configure your AI
+        assistant once and use CodeGate workspaces to switch between LLM
+        providers and models without reconfiguring your development environment.
+      </p>
+      <p className="mb-8 max-w-6xl text-balance text-secondary">
+        Configure your IDE integration to send OpenAI-compatible requests to{' '}
+        <code className="rounded-sm border border-gray-200 bg-gray-50 px-1 py-0.5">
+          http://localhost:8989/v1/mux
+        </code>{' '}
+        and configure the routing from here.
+      </p>
+      <div
+        style={{
+          height: config.HEIGHT_CONTAINER,
+        }}
+        className="border border-gray-200"
+      >
+        <ReactFlow
+          nodes={nodes}
+          edges={edges}
+          onNodesChange={onNodesChange}
+          onEdgesChange={onEdgesChange}
+          onConnect={onConnect}
+          onNodeDragStop={(...args) => console.log('onNodeDragStop', args)}
+          onNodeDragStart={onNodeDragStop}
+          nodeTypes={{
+            prompt: MuxNodePrompt,
+            matcher: MuxNodeMatcher,
+            model: ModelNode,
+            matcherGroup: (props) => (
+              <GroupNode
+                {...props}
+                data={{
+                  ...props.data,
+                  onAddNode: addMatcherNode,
+                  numNodes: nodes.filter((n) => n.type === NodeType.MATCHER)
+                    .length,
+                }}
+              />
+            ),
+            modelGroup: (props) => (
+              <GroupNode
+                {...props}
+                data={{
+                  ...props.data,
+                  onAddNode: addModelNode,
+                  numNodes: nodes.filter((n) => n.type === NodeType.MODEL)
+                    .length,
+                }}
+              />
+            ),
+          }}
+        >
+          <Controls />
+          <Background className="bg-gray-100" />
+        </ReactFlow>
+      </div>
+    </PageContainer>
+  )
+}
+
+const GroupNode = ({
+  id,
+  data,
+  ...rest
+}: Partial<Node> & {
+  data: {
+    title: string
+    description: string
+    onAddNode: (id: string | undefined) => void
+    numNodes: number
+  }
+}) => {
+  console.debug('👉 data:', rest)
+  return (
+    <div
+      style={{
+        // padding: config.PADDING_GROUP,
+        height:
+          config.HEIGHT_GROUP_HEADER +
+          config.PADDING_GROUP * 2 + // space around all nodes
+          (data.numNodes - 1) * config.PADDING_GROUP + // space between nodes
+          data.numNodes * config.HEIGHT_NODE,
+      }}
+      className={`-z-10 flex h-auto min-h-[calc(100%-48px)] flex-col rounded-lg !border-2
+        border-dashed !border-gray-200`}
+    >
+      <div
+        style={{
+          height: config.HEIGHT_GROUP_HEADER,
+        }}
+        className="flex items-center gap-1 rounded-t-lg px-3"
+      >
+        <Heading level={3} className="mb-0 text-lg">
+          {data.title} ({data.numNodes})
+        </Heading>
+        <TooltipTrigger delay={0}>
+          <TooltipInfoButton />
+          <Tooltip placement="right">{data.description}</Tooltip>
+        </TooltipTrigger>
+
+        <Button
+          className="ml-auto h-7 px-2"
+          variant="secondary"
+          onPress={() => data.onAddNode(id)}
+        >
+          <Plus />
+          Add Node
+        </Button>
+      </div>
+    </div>
+  )
+}
+
+const MatcherNode = ({
+  id,
+  data,
+}: Partial<Node> & {
+  data: {
+    isDisabled?: boolean
+    onChange: (id: string | undefined, v: string) => void
+  }
+}) => {
+  return (
+    <>
+      <Handle type="target" position={Position.Left} />
+
+      <div
+        style={{
+          height: config.HEIGHT_NODE,
+          width: config.WIDTH_NODE,
+        }}
+        className={twMerge(
+          nodeStyles(),
+          'grid grid-cols-[32px_1fr_2fr] items-center gap-4'
+        )}
+      >
+        <SvgDrag className="size-8" />
+        <Select
+          defaultSelectedKey={'all'}
+          items={[
+            {
+              textValue: 'All',
+              id: 'all',
+            },
+            {
+              textValue: 'FIM',
+              id: 'fim',
+            },
+            {
+              textValue: 'Chat',
+              id: 'chat',
+            },
+          ]}
+        >
+          <SelectButton />
+        </Select>
+        <TextField
+          isDisabled={data.isDisabled}
+          type="text"
+          aria-label="Matcher"
+          value={data.label}
+          onChange={(v) => data.onChange(id, v)}
+        >
+          <Input
+            icon={data.isDisabled ? <Lock01 /> : <IconRegex />}
+            placeholder="e.g. *.ts"
+          />
+        </TextField>
+      </div>
+      <Handle type="source" position={Position.Right} />
+    </>
+  )
+}
+
+const ModelNode = ({ id, data }) => {
+  return (
+    <>
+      <Handle type="target" position={Position.Left} />
+
+      <div
+        style={{
+          height: config.HEIGHT_NODE,
+          width: config.WIDTH_NODE,
+        }}
+        className={nodeStyles()}
+      >
+        <ComboBox
+          aria-label="Model"
+          items={[
+            {
+              textValue: 'anthropic/claude-3.7-sonnet',
+              id: 'anthropic/claude-3.7-sonnet',
+            },
+            {
+              textValue: 'deepseek-r1',
+              id: 'deepseek-r1',
+            },
+            {
+              textValue: 'mistral:7b-instruct',
+              id: 'mistral:7b-instruct',
+            },
+          ]}
+        >
+          <FieldGroup>
+            <ComboBoxInput
+              icon={<SearchMd />}
+              isBorderless
+              placeholder="Search for a model..."
+            />
+          </FieldGroup>
+        </ComboBox>
+      </div>
+    </>
+  )
+}