diff --git a/.github/workflows/pr_agent.yml b/.github/workflows/pr_agent.yml
index 4e86dfbc55a223672dbe56b2145f2660cae3d74e..e9db72d81ed26d07d1cef34e272139a8f98cedab 100644
--- a/.github/workflows/pr_agent.yml
+++ b/.github/workflows/pr_agent.yml
@@ -14,5 +14,5 @@ jobs:
         id: pragent
         uses: Codium-ai/pr-agent@main
         env:
-          OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
+          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/walkthrough.ipynb b/docs/00_introduction.ipynb
similarity index 97%
rename from walkthrough.ipynb
rename to docs/00_introduction.ipynb
index d008739c8e1c6b1ba3b2ef89ebed75e953e89b45..7a9b5283506f9b7bc0e89b8563ff87955b3a0af3 100644
--- a/walkthrough.ipynb
+++ b/docs/00_introduction.ipynb
@@ -4,7 +4,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "# Semantic Router Walkthrough"
+    "# Semantic Router Intro"
    ]
   },
   {
@@ -34,7 +34,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "!pip install -qU semantic-router==0.0.8"
+    "!pip install -qU semantic-router==0.0.13"
    ]
   },
   {
@@ -198,7 +198,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.11.3"
+   "version": "3.11.5"
   }
  },
  "nbformat": 4,
diff --git a/docs/01_save_load_from_file.ipynb b/docs/01_save_load_from_file.ipynb
new file mode 100644
index 0000000000000000000000000000000000000000..925878f29a97c216f86ad53deef6f8a753af0a1b
--- /dev/null
+++ b/docs/01_save_load_from_file.ipynb
@@ -0,0 +1,253 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Route Layers from File\n",
+    "\n",
+    "Here we will show how to save routers to YAML or JSON files, and how to load a route layer from file."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Getting Started"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We start by installing the library:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "!pip install -qU semantic-router==0.0.13"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Saving to JSON"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "First let's create a list of routes:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "/Users/jamesbriggs/opt/anaconda3/envs/decision-layer/lib/python3.11/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n",
+      "  from .autonotebook import tqdm as notebook_tqdm\n",
+      "None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.\n"
+     ]
+    }
+   ],
+   "source": [
+    "from semantic_router import Route\n",
+    "\n",
+    "politics = Route(\n",
+    "    name=\"politics\",\n",
+    "    utterances=[\n",
+    "        \"isn't politics the best thing ever\",\n",
+    "        \"why don't you tell me about your political opinions\",\n",
+    "        \"don't you just love the president\" \"don't you just hate the president\",\n",
+    "        \"they're going to destroy this country!\",\n",
+    "        \"they will save the country!\",\n",
+    "    ],\n",
+    ")\n",
+    "chitchat = Route(\n",
+    "    name=\"chitchat\",\n",
+    "    utterances=[\n",
+    "        \"how's the weather today?\",\n",
+    "        \"how are things going?\",\n",
+    "        \"lovely weather today\",\n",
+    "        \"the weather is horrendous\",\n",
+    "        \"let's go to the chippy\",\n",
+    "    ],\n",
+    ")\n",
+    "\n",
+    "routes = [politics, chitchat]"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We define a route layer using these routes and using the default Cohere encoder."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import os\n",
+    "from semantic_router import RouteLayer\n",
+    "\n",
+    "# dashboard.cohere.ai\n",
+    "os.environ[\"COHERE_API_KEY\"] = \"<YOUR_API_KEY>\"\n",
+    "\n",
+    "layer = RouteLayer(routes=routes)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To save our route layer we call the `to_json` method:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "\u001b[32m2023-12-26 23:38:56 INFO semantic_router.utils.logger Saving route config to layer.json\u001b[0m\n"
+     ]
+    }
+   ],
+   "source": [
+    "layer.to_json(\"layer.json\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Loading from JSON"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We can view the router file we just saved to see what information is stored."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "{'encoder_type': 'cohere', 'encoder_name': 'embed-english-v3.0', 'routes': [{'name': 'politics', 'utterances': [\"isn't politics the best thing ever\", \"why don't you tell me about your political opinions\", \"don't you just love the presidentdon't you just hate the president\", \"they're going to destroy this country!\", 'they will save the country!'], 'description': None, 'function_schema': None}, {'name': 'chitchat', 'utterances': [\"how's the weather today?\", 'how are things going?', 'lovely weather today', 'the weather is horrendous', \"let's go to the chippy\"], 'description': None, 'function_schema': None}]}\n"
+     ]
+    }
+   ],
+   "source": [
+    "import json\n",
+    "\n",
+    "with open(\"layer.json\", \"r\") as f:\n",
+    "    router_json = json.load(f)\n",
+    "\n",
+    "print(router_json)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "It tells us our encoder type, encoder name, and routes. This is everything we need to initialize a new router. To do so, we use the `from_json` method."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "\u001b[32m2023-12-26 23:38:56 INFO semantic_router.utils.logger Loading route config from layer.json\u001b[0m\n"
+     ]
+    }
+   ],
+   "source": [
+    "layer = RouteLayer.from_json(\"layer.json\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We can confirm that our layer has been initialized with the expected attributes by viewing the `RouteLayer` object:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "RouteLayer(encoder=Encoder(type=<EncoderType.COHERE: 'cohere'>, name='embed-english-v3.0', model=CohereEncoder(name='embed-english-v3.0', type='cohere', client=<cohere.client.Client object at 0x12e40d510>)), routes=[Route(name='politics', utterances=[\"isn't politics the best thing ever\", \"why don't you tell me about your political opinions\", \"don't you just love the presidentdon't you just hate the president\", \"they're going to destroy this country!\", 'they will save the country!'], description=None, function_schema=None), Route(name='chitchat', utterances=[\"how's the weather today?\", 'how are things going?', 'lovely weather today', 'the weather is horrendous', \"let's go to the chippy\"], description=None, function_schema=None)])"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "layer"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "---"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "decision-layer",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.11.5"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/docs/02_dynamic_routes.ipynb b/docs/02_dynamic_routes.ipynb
new file mode 100644
index 0000000000000000000000000000000000000000..2ffc0ce0920f3d18bcfbf6c6288a45205ded9b93
--- /dev/null
+++ b/docs/02_dynamic_routes.ipynb
@@ -0,0 +1,310 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Dynamic Routes"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "In semantic-router there are two types of routes that can be chosen. Both routes belong to the `Route` object, the only difference between them is that _static_ routes return a `Route.name` when chosen, whereas _dynamic_ routes use an LLM call to produce parameter input values.\n",
+    "\n",
+    "For example, a _static_ route will tell us if a query is talking about mathematics by returning the route name (which could be `\"math\"` for example). A _dynamic_ route can generate additional values, so it may decide a query is talking about maths, but it can also generate Python code that we can later execute to answer the user's query, this output may look like `\"math\", \"import math; output = math.sqrt(64)`."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Initializing Routes and RouteLayer"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Dynamic routes are treated in the same way as static routes, let's begin by initializing a `RouteLayer` consisting of static routes."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "/Users/jamesbriggs/opt/anaconda3/envs/decision-layer/lib/python3.11/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n",
+      "  from .autonotebook import tqdm as notebook_tqdm\n",
+      "None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.\n"
+     ]
+    }
+   ],
+   "source": [
+    "from semantic_router import Route\n",
+    "\n",
+    "politics = Route(\n",
+    "    name=\"politics\",\n",
+    "    utterances=[\n",
+    "        \"isn't politics the best thing ever\",\n",
+    "        \"why don't you tell me about your political opinions\",\n",
+    "        \"don't you just love the president\" \"don't you just hate the president\",\n",
+    "        \"they're going to destroy this country!\",\n",
+    "        \"they will save the country!\",\n",
+    "    ],\n",
+    ")\n",
+    "chitchat = Route(\n",
+    "    name=\"chitchat\",\n",
+    "    utterances=[\n",
+    "        \"how's the weather today?\",\n",
+    "        \"how are things going?\",\n",
+    "        \"lovely weather today\",\n",
+    "        \"the weather is horrendous\",\n",
+    "        \"let's go to the chippy\",\n",
+    "    ],\n",
+    ")\n",
+    "\n",
+    "routes = [politics, chitchat]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import os\n",
+    "from semantic_router import RouteLayer\n",
+    "\n",
+    "os.environ[\"COHERE_API_KEY\"] = \"<YOUR_API_KEY>\"\n",
+    "\n",
+    "layer = RouteLayer(routes=routes)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We run the solely static routes layer:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "RouteChoice(name='chitchat', function_call=None)"
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "layer(\"how's the weather today?\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Creating a Dynamic Route"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "As with static routes, we must create a dynamic route before adding it to our route layer. To make a route dynamic, we need to provide a `function_schema`. The function schema provides instructions on what a function is, so that an LLM can decide how to use it correctly."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from datetime import datetime\n",
+    "from zoneinfo import ZoneInfo\n",
+    "\n",
+    "\n",
+    "def get_time(timezone: str) -> str:\n",
+    "    \"\"\"Finds the current time in a specific timezone.\n",
+    "\n",
+    "    :param timezone: The timezone to find the current time in, should\n",
+    "        be a valid timezone from the IANA Time Zone Database like\n",
+    "        \"America/New_York\" or \"Europe/London\".\n",
+    "    :type timezone: str\n",
+    "    :return: The current time in the specified timezone.\"\"\"\n",
+    "    now = datetime.now(ZoneInfo(timezone))\n",
+    "    return now.strftime(\"%H:%M\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "'17:50'"
+      ]
+     },
+     "execution_count": 5,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "get_time(\"America/New_York\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To get the function schema we can use the `get_schema` function from the `function_call` module."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "{'name': 'get_time',\n",
+       " 'description': 'Finds the current time in a specific timezone.\\n\\n:param timezone: The timezone to find the current time in, should\\n    be a valid timezone from the IANA Time Zone Database like\\n    \"America/New_York\" or \"Europe/London\".\\n:type timezone: str\\n:return: The current time in the specified timezone.',\n",
+       " 'signature': '(timezone: str) -> str',\n",
+       " 'output': \"<class 'str'>\"}"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "from semantic_router.utils.function_call import get_schema\n",
+    "\n",
+    "schema = get_schema(get_time)\n",
+    "schema"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We use this to define our dynamic route:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "time_route = Route(\n",
+    "    name=\"get_time\",\n",
+    "    utterances=[\n",
+    "        \"what is the time in new york city?\",\n",
+    "        \"what is the time in london?\",\n",
+    "        \"I live in Rome, what time is it?\",\n",
+    "    ],\n",
+    "    function_schema=schema,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Add the new route to our `layer`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "layer.add(time_route)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Now we can ask our layer a time related question to trigger our new dynamic route."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "\u001b[32m2023-12-26 23:50:55 INFO semantic_router.utils.logger Extracting function input...\u001b[0m\n"
+     ]
+    },
+    {
+     "data": {
+      "text/plain": [
+       "RouteChoice(name='get_time', function_call={'timezone': 'America/New_York'})"
+      ]
+     },
+     "execution_count": 9,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "os.environ[\"OPENROUTER_API_KEY\"] = \"<YOUR_API_KEY>\"\n",
+    "\n",
+    "layer(\"what is the time in new york city?\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "---"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "decision-layer",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.11.5"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/docs/examples/route_config.json b/docs/examples/route_config.json
deleted file mode 100644
index f76a73859e4c3534f37bc99c0aec17627e0d2ee6..0000000000000000000000000000000000000000
--- a/docs/examples/route_config.json
+++ /dev/null
@@ -1 +0,0 @@
-[{"name": "get_time", "utterances": ["What's the time in New York?", "Can you tell me the time in Tokyo?", "What's the current time in London?", "Can you give me the time in Sydney?", "What's the time in Paris?"], "description": null}, {"name": "get_news", "utterances": ["Tell me the latest news from the United States", "What's happening in India today?", "Can you give me the top stories from Japan", "Get me the breaking news from the UK", "What's the latest in Germany?"], "description": null}]
diff --git a/poetry.lock b/poetry.lock
index 7efeda7e28a550d04e2e108fa1901478a7483589..63248ed235772f93d252b60125797bf96601e1a3 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
 
 [[package]]
 name = "aiohttp"
@@ -1716,6 +1716,7 @@ files = [
     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+    {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
     {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
     {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
     {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
@@ -1723,8 +1724,15 @@ files = [
     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+    {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
     {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
     {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+    {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+    {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+    {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+    {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+    {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+    {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
     {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
     {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
     {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
@@ -1741,6 +1749,7 @@ files = [
     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+    {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
     {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
     {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
     {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
@@ -1748,6 +1757,7 @@ files = [
     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+    {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
     {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
     {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
     {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
diff --git a/pyproject.toml b/pyproject.toml
index 0741dac08d7b3961d12a6e361286d1ef9b131b1a..71ef163be7594118fe0b5b6b6e5d8ad272bcbd04 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "semantic-router"
-version = "0.0.12"
+version = "0.0.13"
 description = "Super fast semantic router for AI decision making"
 authors = [
     "James Briggs <james@aurelio.ai>",
diff --git a/semantic_router/__init__.py b/semantic_router/__init__.py
index 2659bfe3bf4cebe2b022c01ec7139658aeb43eb1..07735312c6cd8a682726373dcf20fa000a020c13 100644
--- a/semantic_router/__init__.py
+++ b/semantic_router/__init__.py
@@ -1,5 +1,5 @@
-from .hybrid_layer import HybridRouteLayer
-from .layer import RouteLayer
-from .route import Route, RouteConfig
+from semantic_router.hybrid_layer import HybridRouteLayer
+from semantic_router.layer import RouteLayer, LayerConfig
+from semantic_router.route import Route
 
-__all__ = ["RouteLayer", "HybridRouteLayer", "Route", "RouteConfig"]
+__all__ = ["RouteLayer", "HybridRouteLayer", "Route", "LayerConfig"]
diff --git a/semantic_router/encoders/__init__.py b/semantic_router/encoders/__init__.py
index 30ad624a2104ec3c48b8684615b89e3a35d43be2..ac27ebb4a5aac24d777c4f39929349acab8a438a 100644
--- a/semantic_router/encoders/__init__.py
+++ b/semantic_router/encoders/__init__.py
@@ -1,6 +1,6 @@
-from .base import BaseEncoder
-from .bm25 import BM25Encoder
-from .cohere import CohereEncoder
-from .openai import OpenAIEncoder
+from semantic_router.encoders.base import BaseEncoder
+from semantic_router.encoders.bm25 import BM25Encoder
+from semantic_router.encoders.cohere import CohereEncoder
+from semantic_router.encoders.openai import OpenAIEncoder
 
 __all__ = ["BaseEncoder", "CohereEncoder", "OpenAIEncoder", "BM25Encoder"]
diff --git a/semantic_router/encoders/base.py b/semantic_router/encoders/base.py
index 632ebc7924a5a74088068bfb329a4e04c68cb6df..bd9524037a2cc6decd60d7674124c717aea6bba6 100644
--- a/semantic_router/encoders/base.py
+++ b/semantic_router/encoders/base.py
@@ -1,8 +1,9 @@
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
 
 
 class BaseEncoder(BaseModel):
     name: str
+    type: str = Field(default="base")
 
     class Config:
         arbitrary_types_allowed = True
diff --git a/semantic_router/encoders/bm25.py b/semantic_router/encoders/bm25.py
index c9da628e1493e53760f6c060dcd64e4dfccdc3d4..f43e1780cace53529bc7a0b5b5f9eb15a98fd9da 100644
--- a/semantic_router/encoders/bm25.py
+++ b/semantic_router/encoders/bm25.py
@@ -8,6 +8,7 @@ from semantic_router.encoders import BaseEncoder
 class BM25Encoder(BaseEncoder):
     model: Any | None = None
     idx_mapping: dict[int, int] | None = None
+    type: str = "sparse"
 
     def __init__(self, name: str = "bm25"):
         super().__init__(name=name)
diff --git a/semantic_router/encoders/cohere.py b/semantic_router/encoders/cohere.py
index 9cddcb58baa74a767dfc47fcd3de70c44c505cc9..f7aef0e6227938ef174d867f12e05ac19f58524d 100644
--- a/semantic_router/encoders/cohere.py
+++ b/semantic_router/encoders/cohere.py
@@ -7,12 +7,15 @@ from semantic_router.encoders import BaseEncoder
 
 class CohereEncoder(BaseEncoder):
     client: cohere.Client | None = None
+    type: str = "cohere"
 
     def __init__(
         self,
-        name: str = os.getenv("COHERE_MODEL_NAME", "embed-english-v3.0"),
+        name: str | None = None,
         cohere_api_key: str | None = None,
     ):
+        if name is None:
+            name = os.getenv("COHERE_MODEL_NAME", "embed-english-v3.0")
         super().__init__(name=name)
         cohere_api_key = cohere_api_key or os.getenv("COHERE_API_KEY")
         if cohere_api_key is None:
diff --git a/semantic_router/encoders/openai.py b/semantic_router/encoders/openai.py
index c6d4cc962b7b9ac38400f527ac20baa6543490d9..173fe94a493fe750156ef8f26bac8d9c9346d30d 100644
--- a/semantic_router/encoders/openai.py
+++ b/semantic_router/encoders/openai.py
@@ -11,12 +11,15 @@ from semantic_router.utils.logger import logger
 
 class OpenAIEncoder(BaseEncoder):
     client: openai.Client | None
+    type: str = "openai"
 
     def __init__(
         self,
-        name: str = os.getenv("OPENAI_MODEL_NAME", "text-embedding-ada-002"),
+        name: str | None = None,
         openai_api_key: str | None = None,
     ):
+        if name is None:
+            name = os.getenv("OPENAI_MODEL_NAME", "text-embedding-ada-002")
         super().__init__(name=name)
         api_key = openai_api_key or os.getenv("OPENAI_API_KEY")
         if api_key is None:
diff --git a/semantic_router/hybrid_layer.py b/semantic_router/hybrid_layer.py
index 475a12f09b4bcac340d2ceabf77199ceee9cb071..22f6573c8d5d88b1ac083628075a9fed3ec28037 100644
--- a/semantic_router/hybrid_layer.py
+++ b/semantic_router/hybrid_layer.py
@@ -9,7 +9,7 @@ from semantic_router.encoders import (
 )
 from semantic_router.utils.logger import logger
 
-from .route import Route
+from semantic_router.route import Route
 
 
 class HybridRouteLayer:
diff --git a/semantic_router/layer.py b/semantic_router/layer.py
index 2fa3b8634de794fa59592c20af5180a4a5dee851..dae040a5d7537f0f22f0c7edeeb035f58e7eb0fb 100644
--- a/semantic_router/layer.py
+++ b/semantic_router/layer.py
@@ -1,4 +1,5 @@
 import json
+import os
 
 import numpy as np
 import yaml
@@ -11,17 +12,144 @@ from semantic_router.encoders import (
 from semantic_router.linear import similarity_matrix, top_scores
 from semantic_router.utils.logger import logger
 
-from .route import Route
+from semantic_router.route import Route
+from semantic_router.schema import Encoder, EncoderType, RouteChoice
+
+
+def is_valid(layer_config: str) -> bool:
+    try:
+        output_json = json.loads(layer_config)
+        required_keys = ["encoder_name", "encoder_type", "routes"]
+
+        if isinstance(output_json, list):
+            for item in output_json:
+                missing_keys = [key for key in required_keys if key not in item]
+                if missing_keys:
+                    logger.warning(
+                        f"Missing keys in layer config: {', '.join(missing_keys)}"
+                    )
+                    return False
+            return True
+        else:
+            missing_keys = [key for key in required_keys if key not in output_json]
+            if missing_keys:
+                logger.warning(
+                    f"Missing keys in layer config: {', '.join(missing_keys)}"
+                )
+                return False
+            else:
+                return True
+    except json.JSONDecodeError as e:
+        logger.error(e)
+        return False
+
+
+class LayerConfig:
+    """
+    Generates a LayerConfig object that can be used for initializing a
+    RouteLayer.
+    """
+
+    routes: list[Route] = []
+
+    def __init__(
+        self,
+        routes: list[Route] = [],
+        encoder_type: str = "openai",
+        encoder_name: str | None = None,
+    ):
+        self.encoder_type = encoder_type
+        if encoder_name is None:
+            # if encoder_name is not provided, use the default encoder for type
+            if encoder_type == EncoderType.OPENAI:
+                encoder_name = "text-embedding-ada-002"
+            elif encoder_type == EncoderType.COHERE:
+                encoder_name = "embed-english-v3.0"
+            elif encoder_type == EncoderType.HUGGINGFACE:
+                raise NotImplementedError
+            logger.info(f"Using default {encoder_type} encoder: {encoder_name}")
+        self.encoder_name = encoder_name
+        self.routes = routes
+
+    @classmethod
+    def from_file(cls, path: str):
+        """Load the routes from a file in JSON or YAML format"""
+        logger.info(f"Loading route config from {path}")
+        _, ext = os.path.splitext(path)
+        with open(path, "r") as f:
+            if ext == ".json":
+                layer = json.load(f)
+            elif ext in [".yaml", ".yml"]:
+                layer = yaml.safe_load(f)
+            else:
+                raise ValueError(
+                    "Unsupported file type. Only .json and .yaml are supported"
+                )
+
+            route_config_str = json.dumps(layer)
+            if is_valid(route_config_str):
+                encoder_type = layer["encoder_type"]
+                encoder_name = layer["encoder_name"]
+                routes = [Route.from_dict(route) for route in layer["routes"]]
+                return cls(
+                    encoder_type=encoder_type, encoder_name=encoder_name, routes=routes
+                )
+            else:
+                raise Exception("Invalid config JSON or YAML")
+
+    def to_dict(self):
+        return {
+            "encoder_type": self.encoder_type,
+            "encoder_name": self.encoder_name,
+            "routes": [route.to_dict() for route in self.routes],
+        }
+
+    def to_file(self, path: str):
+        """Save the routes to a file in JSON or YAML format"""
+        logger.info(f"Saving route config to {path}")
+        _, ext = os.path.splitext(path)
+        with open(path, "w") as f:
+            if ext == ".json":
+                json.dump(self.to_dict(), f, indent=4)
+            elif ext in [".yaml", ".yml"]:
+                yaml.safe_dump(self.to_dict(), f)
+            else:
+                raise ValueError(
+                    "Unsupported file type. Only .json and .yaml are supported"
+                )
+
+    def add(self, route: Route):
+        self.routes.append(route)
+        logger.info(f"Added route `{route.name}`")
+
+    def get(self, name: str) -> Route | None:
+        for route in self.routes:
+            if route.name == name:
+                return route
+        logger.error(f"Route `{name}` not found")
+        return None
+
+    def remove(self, name: str):
+        if name not in [route.name for route in self.routes]:
+            logger.error(f"Route `{name}` not found")
+        else:
+            self.routes = [route for route in self.routes if route.name != name]
+            logger.info(f"Removed route `{name}`")
 
 
 class RouteLayer:
-    index = None
-    categories = None
-    score_threshold = 0.82
+    index: np.ndarray | None = None
+    categories: np.ndarray | None = None
+    score_threshold: float = 0.82
 
-    def __init__(self, encoder: BaseEncoder | None = None, routes: list[Route] = []):
+    def __init__(
+        self, encoder: BaseEncoder | None = None, routes: list[Route] | None = None
+    ):
+        logger.info("Initializing RouteLayer")
+        self.index = None
+        self.categories = None
         self.encoder = encoder if encoder is not None else CohereEncoder()
-        self.routes: list[Route] = routes
+        self.routes: list[Route] = routes if routes is not None else []
         # decide on default threshold based on encoder
         if isinstance(encoder, OpenAIEncoder):
             self.score_threshold = 0.82
@@ -30,49 +158,69 @@ class RouteLayer:
         else:
             self.score_threshold = 0.82
         # if routes list has been passed, we initialize index now
-        if routes:
+        if len(self.routes) > 0:
             # initialize index now
-            self._add_routes(routes=routes)
+            self._add_routes(routes=self.routes)
 
-    def __call__(self, text: str) -> str | None:
+    def __call__(self, text: str) -> RouteChoice:
         results = self._query(text)
         top_class, top_class_scores = self._semantic_classify(results)
         passed = self._pass_threshold(top_class_scores, self.score_threshold)
         if passed:
-            return top_class
+            # get chosen route object
+            route = [route for route in self.routes if route.name == top_class][0]
+            return route(text)
         else:
-            return None
+            # if no route passes threshold, return empty route choice
+            return RouteChoice()
+
+    def __str__(self):
+        return (
+            f"RouteLayer(encoder={self.encoder}, "
+            f"score_threshold={self.score_threshold}, "
+            f"routes={self.routes})"
+        )
 
     @classmethod
     def from_json(cls, file_path: str):
-        with open(file_path, "r") as f:
-            routes_data = json.load(f)
-        routes = [Route.from_dict(route_data) for route_data in routes_data]
-        return cls(routes=routes)
+        config = LayerConfig.from_file(file_path)
+        encoder = Encoder(type=config.encoder_type, name=config.encoder_name).model
+        return cls(encoder=encoder, routes=config.routes)
 
     @classmethod
     def from_yaml(cls, file_path: str):
-        with open(file_path, "r") as f:
-            routes_data = yaml.load(f, Loader=yaml.FullLoader)
-        routes = [Route.from_dict(route_data) for route_data in routes_data]
-        return cls(routes=routes)
+        config = LayerConfig.from_file(file_path)
+        encoder = Encoder(type=config.encoder_type, name=config.encoder_name).model
+        return cls(encoder=encoder, routes=config.routes)
+
+    @classmethod
+    def from_config(cls, config: LayerConfig):
+        encoder = Encoder(type=config.encoder_type, name=config.encoder_name).model
+        return cls(encoder=encoder, routes=config.routes)
 
     def add(self, route: Route):
+        print(f"Adding route `{route.name}`")
         # create embeddings
         embeds = self.encoder(route.utterances)
 
         # create route array
         if self.categories is None:
+            print("Initializing categories array")
             self.categories = np.array([route.name] * len(embeds))
         else:
+            print("Adding route to categories")
             str_arr = np.array([route.name] * len(embeds))
             self.categories = np.concatenate([self.categories, str_arr])
         # create utterance array (the index)
         if self.index is None:
+            print("Initializing index array")
             self.index = np.array(embeds)
         else:
+            print("Adding route to index")
             embed_arr = np.array(embeds)
             self.index = np.concatenate([self.index, embed_arr])
+        # add route to routes list
+        self.routes.append(route)
 
     def _add_routes(self, routes: list[Route]):
         # create embeddings for all routes
@@ -144,7 +292,17 @@ class RouteLayer:
         else:
             return False
 
+    def to_config(self) -> LayerConfig:
+        return LayerConfig(
+            encoder_type=self.encoder.type,
+            encoder_name=self.encoder.name,
+            routes=self.routes,
+        )
+
     def to_json(self, file_path: str):
-        routes = [route.to_dict() for route in self.routes]
-        with open(file_path, "w") as f:
-            json.dump(routes, f, indent=4)
+        config = self.to_config()
+        config.to_file(file_path)
+
+    def to_yaml(self, file_path: str):
+        config = self.to_config()
+        config.to_file(file_path)
diff --git a/semantic_router/route.py b/semantic_router/route.py
index 99a7945bf35563941ddd2d49685bc06b421e734f..30c20887ebcd7073fe2a32630d0d566ada3c945c 100644
--- a/semantic_router/route.py
+++ b/semantic_router/route.py
@@ -1,14 +1,13 @@
 import json
-import os
 import re
 from typing import Any, Callable, Union
 
-import yaml
 from pydantic import BaseModel
 
 from semantic_router.utils import function_call
 from semantic_router.utils.llm import llm
 from semantic_router.utils.logger import logger
+from semantic_router.schema import RouteChoice
 
 
 def is_valid(route_config: str) -> bool:
@@ -43,6 +42,19 @@ class Route(BaseModel):
     name: str
     utterances: list[str]
     description: str | None = None
+    function_schema: dict[str, Any] | None = None
+
+    def __call__(self, query: str) -> RouteChoice:
+        if self.function_schema:
+            # if a function schema is provided we generate the inputs
+            extracted_inputs = function_call.extract_function_inputs(
+                query=query, function_schema=self.function_schema
+            )
+            func_call = extracted_inputs
+        else:
+            # otherwise we just pass None for the call
+            func_call = None
+        return RouteChoice(name=self.name, function_call=func_call)
 
     def to_dict(self):
         return self.dict()
@@ -52,12 +64,12 @@ class Route(BaseModel):
         return cls(**data)
 
     @classmethod
-    async def from_dynamic_route(cls, entity: Union[BaseModel, Callable]):
+    def from_dynamic_route(cls, entity: Union[BaseModel, Callable]):
         """
         Generate a dynamic Route object from a function or Pydantic model using LLM
         """
         schema = function_call.get_schema(item=entity)
-        dynamic_route = await cls._generate_dynamic_route(function_schema=schema)
+        dynamic_route = cls._generate_dynamic_route(function_schema=schema)
         return dynamic_route
 
     @classmethod
@@ -73,7 +85,7 @@ class Route(BaseModel):
             raise ValueError("No <config></config> tags found in the output.")
 
     @classmethod
-    async def _generate_dynamic_route(cls, function_schema: dict[str, Any]):
+    def _generate_dynamic_route(cls, function_schema: dict[str, Any]):
         logger.info("Generating dynamic route...")
 
         prompt = f"""
@@ -101,7 +113,7 @@ class Route(BaseModel):
         {function_schema}
         """
 
-        output = await llm(prompt)
+        output = llm(prompt)
         if not output:
             raise Exception("No output generated for dynamic route")
 
@@ -112,71 +124,3 @@ class Route(BaseModel):
         if is_valid(route_config):
             return Route.from_dict(json.loads(route_config))
         raise Exception("No config generated")
-
-
-class RouteConfig:
-    """
-    Generates a RouteConfig object from a list of Route objects
-    """
-
-    routes: list[Route] = []
-
-    def __init__(self, routes: list[Route] = []):
-        self.routes = routes
-
-    @classmethod
-    def from_file(cls, path: str):
-        """Load the routes from a file in JSON or YAML format"""
-        logger.info(f"Loading route config from {path}")
-        _, ext = os.path.splitext(path)
-        with open(path, "r") as f:
-            if ext == ".json":
-                routes = json.load(f)
-            elif ext in [".yaml", ".yml"]:
-                routes = yaml.safe_load(f)
-            else:
-                raise ValueError(
-                    "Unsupported file type. Only .json and .yaml are supported"
-                )
-
-            route_config_str = json.dumps(routes)
-            if is_valid(route_config_str):
-                routes = [Route.from_dict(route) for route in routes]
-                return cls(routes=routes)
-            else:
-                raise Exception("Invalid config JSON or YAML")
-
-    def to_dict(self):
-        return [route.to_dict() for route in self.routes]
-
-    def to_file(self, path: str):
-        """Save the routes to a file in JSON or YAML format"""
-        logger.info(f"Saving route config to {path}")
-        _, ext = os.path.splitext(path)
-        with open(path, "w") as f:
-            if ext == ".json":
-                json.dump(self.to_dict(), f)
-            elif ext in [".yaml", ".yml"]:
-                yaml.safe_dump(self.to_dict(), f)
-            else:
-                raise ValueError(
-                    "Unsupported file type. Only .json and .yaml are supported"
-                )
-
-    def add(self, route: Route):
-        self.routes.append(route)
-        logger.info(f"Added route `{route.name}`")
-
-    def get(self, name: str) -> Route | None:
-        for route in self.routes:
-            if route.name == name:
-                return route
-        logger.error(f"Route `{name}` not found")
-        return None
-
-    def remove(self, name: str):
-        if name not in [route.name for route in self.routes]:
-            logger.error(f"Route `{name}` not found")
-        else:
-            self.routes = [route for route in self.routes if route.name != name]
-            logger.info(f"Removed route `{name}`")
diff --git a/semantic_router/schema.py b/semantic_router/schema.py
index 4646a637dbffd4ed7ad1b8e2d4dd23cef6df22de..be486888a70f9a0b27d705636038931206e3f1cc 100644
--- a/semantic_router/schema.py
+++ b/semantic_router/schema.py
@@ -1,8 +1,8 @@
 from enum import Enum
 
 from pydantic.dataclasses import dataclass
+from pydantic import BaseModel
 
-from semantic_router import Route
 from semantic_router.encoders import (
     BaseEncoder,
     CohereEncoder,
@@ -16,13 +16,18 @@ class EncoderType(Enum):
     COHERE = "cohere"
 
 
+class RouteChoice(BaseModel):
+    name: str | None = None
+    function_call: dict | None = None
+
+
 @dataclass
 class Encoder:
     type: EncoderType
-    name: str
+    name: str | None
     model: BaseEncoder
 
-    def __init__(self, type: str, name: str):
+    def __init__(self, type: str, name: str | None):
         self.type = EncoderType(type)
         self.name = name
         if self.type == EncoderType.HUGGINGFACE:
@@ -31,20 +36,8 @@ class Encoder:
             self.model = OpenAIEncoder(name)
         elif self.type == EncoderType.COHERE:
             self.model = CohereEncoder(name)
+        else:
+            raise ValueError
 
     def __call__(self, texts: list[str]) -> list[list[float]]:
         return self.model(texts)
-
-
-@dataclass
-class SemanticSpace:
-    id: str
-    routes: list[Route]
-    encoder: str = ""
-
-    def __init__(self, routes: list[Route] = []):
-        self.id = ""
-        self.routes = routes
-
-    def add(self, route: Route):
-        self.routes.append(route)
diff --git a/semantic_router/utils/function_call.py b/semantic_router/utils/function_call.py
index c1b4fceed0492124fc98401f0f1789e89dfddb21..2ead3ab58dd5c54eaf26fdc5b2f73ea95f4bef9c 100644
--- a/semantic_router/utils/function_call.py
+++ b/semantic_router/utils/function_call.py
@@ -40,7 +40,7 @@ def get_schema(item: Union[BaseModel, Callable]) -> dict[str, Any]:
     return schema
 
 
-async def extract_function_inputs(query: str, function_schema: dict[str, Any]) -> dict:
+def extract_function_inputs(query: str, function_schema: dict[str, Any]) -> dict:
     logger.info("Extracting function input...")
 
     prompt = f"""
@@ -72,7 +72,7 @@ async def extract_function_inputs(query: str, function_schema: dict[str, Any]) -
     Result:
     """
 
-    output = await llm(prompt)
+    output = llm(prompt)
     if not output:
         raise Exception("No output generated for extract function input")
 
@@ -117,11 +117,11 @@ async def route_and_execute(query: str, functions: list[Callable], route_layer):
     function_name = route_layer(query)
     if not function_name:
         logger.warning("No function found, calling LLM...")
-        return await llm(query)
+        return llm(query)
 
     for function in functions:
         if function.__name__ == function_name:
             print(f"Calling function: {function.__name__}")
             schema = get_schema(function)
-            inputs = await extract_function_inputs(query, schema)
+            inputs = extract_function_inputs(query, schema)
             call_function(function, inputs)
diff --git a/semantic_router/utils/llm.py b/semantic_router/utils/llm.py
index e912ee1f8ea53cdeaa69a83669384a5d6d165c1c..e92c1bcf7752b5fce5a071dde41da4a24d0851a9 100644
--- a/semantic_router/utils/llm.py
+++ b/semantic_router/utils/llm.py
@@ -5,14 +5,14 @@ import openai
 from semantic_router.utils.logger import logger
 
 
-async def llm(prompt: str) -> str | None:
+def llm(prompt: str) -> str | None:
     try:
-        client = openai.AsyncOpenAI(
+        client = openai.OpenAI(
             base_url="https://openrouter.ai/api/v1",
             api_key=os.getenv("OPENROUTER_API_KEY"),
         )
 
-        completion = await client.chat.completions.create(
+        completion = client.chat.completions.create(
             model="mistralai/mistral-7b-instruct",
             messages=[
                 {
@@ -32,3 +32,33 @@ async def llm(prompt: str) -> str | None:
     except Exception as e:
         logger.error(f"LLM error: {e}")
         raise Exception(f"LLM error: {e}")
+
+
+# TODO integrate async LLM function
+# async def allm(prompt: str) -> str | None:
+#     try:
+#         client = openai.AsyncOpenAI(
+#             base_url="https://openrouter.ai/api/v1",
+#             api_key=os.getenv("OPENROUTER_API_KEY"),
+#         )
+
+#         completion = await client.chat.completions.create(
+#             model="mistralai/mistral-7b-instruct",
+#             messages=[
+#                 {
+#                     "role": "user",
+#                     "content": prompt,
+#                 },
+#             ],
+#             temperature=0.01,
+#             max_tokens=200,
+#         )
+
+#         output = completion.choices[0].message.content
+
+#         if not output:
+#             raise Exception("No output generated")
+#         return output
+#     except Exception as e:
+#         logger.error(f"LLM error: {e}")
+#         raise Exception(f"LLM error: {e}")
diff --git a/tests/unit/encoders/test_openai.py b/tests/unit/encoders/test_openai.py
index cc79d27207f7847439b74b0c29f4fb75d42d5381..4679ee939f7d4b494150a8a52f4b4c33a0e6c8db 100644
--- a/tests/unit/encoders/test_openai.py
+++ b/tests/unit/encoders/test_openai.py
@@ -20,9 +20,8 @@ class TestOpenAIEncoder:
 
     def test_openai_encoder_init_no_api_key(self, mocker):
         mocker.patch("os.getenv", return_value=None)
-        with pytest.raises(ValueError) as e:
+        with pytest.raises(ValueError) as _:
             OpenAIEncoder()
-        assert "OpenAI API key cannot be 'None'." in str(e.value)
 
     def test_openai_encoder_call_uninitialized_client(self, openai_encoder):
         # Set the client to None to simulate an uninitialized client
diff --git a/tests/unit/test_layer.py b/tests/unit/test_layer.py
index 21b489172b943ca09bb1bf81e31f88e92aa18a08..b8f317935e656c705b3f1d64a47d02e7a53d8ac7 100644
--- a/tests/unit/test_layer.py
+++ b/tests/unit/test_layer.py
@@ -1,7 +1,9 @@
+import os
 import pytest
+from unittest.mock import mock_open, patch
 
 from semantic_router.encoders import BaseEncoder, CohereEncoder, OpenAIEncoder
-from semantic_router.layer import RouteLayer
+from semantic_router.layer import LayerConfig, RouteLayer
 from semantic_router.route import Route
 
 
@@ -17,6 +19,52 @@ def mock_encoder_call(utterances):
     return [mock_responses.get(u, [0, 0, 0]) for u in utterances]
 
 
+def layer_json():
+    return """{
+    "encoder_type": "cohere",
+    "encoder_name": "embed-english-v3.0",
+    "routes": [
+        {
+            "name": "politics",
+            "utterances": [
+                "isn't politics the best thing ever",
+                "why don't you tell me about your political opinions"
+            ],
+            "description": null,
+            "function_schema": null
+        },
+        {
+            "name": "chitchat",
+            "utterances": [
+                "how's the weather today?",
+                "how are things going?"
+            ],
+            "description": null,
+            "function_schema": null
+        }
+    ]
+}"""
+
+
+def layer_yaml():
+    return """encoder_name: embed-english-v3.0
+encoder_type: cohere
+routes:
+- description: null
+  function_schema: null
+  name: politics
+  utterances:
+  - isn't politics the best thing ever
+  - why don't you tell me about your political opinions
+- description: null
+  function_schema: null
+  name: chitchat
+  utterances:
+  - how's the weather today?
+  - how are things going?
+    """
+
+
 @pytest.fixture
 def base_encoder():
     return BaseEncoder(name="test-encoder")
@@ -67,30 +115,31 @@ class TestRouteLayer:
 
         route_layer.add(route=route1)
         assert route_layer.index is not None and route_layer.categories is not None
-        assert len(route_layer.index) == 2
+        assert route_layer.index.shape[0] == 2
         assert len(set(route_layer.categories)) == 1
         assert set(route_layer.categories) == {"Route 1"}
 
         route_layer.add(route=route2)
-        assert len(route_layer.index) == 4
+        assert route_layer.index.shape[0] == 4
         assert len(set(route_layer.categories)) == 2
         assert set(route_layer.categories) == {"Route 1", "Route 2"}
+        del route_layer
 
     def test_add_multiple_routes(self, openai_encoder, routes):
         route_layer = RouteLayer(encoder=openai_encoder)
         route_layer._add_routes(routes=routes)
         assert route_layer.index is not None and route_layer.categories is not None
-        assert len(route_layer.index) == 5
+        assert route_layer.index.shape[0] == 5
         assert len(set(route_layer.categories)) == 2
 
     def test_query_and_classification(self, openai_encoder, routes):
         route_layer = RouteLayer(encoder=openai_encoder, routes=routes)
-        query_result = route_layer("Hello")
+        query_result = route_layer("Hello").name
         assert query_result in ["Route 1", "Route 2"]
 
     def test_query_with_no_index(self, openai_encoder):
         route_layer = RouteLayer(encoder=openai_encoder)
-        assert route_layer("Anything") is None
+        assert route_layer("Anything").name is None
 
     def test_semantic_classify(self, openai_encoder, routes):
         route_layer = RouteLayer(encoder=openai_encoder, routes=routes)
@@ -124,5 +173,120 @@ class TestRouteLayer:
         route_layer = RouteLayer(encoder=base_encoder)
         assert route_layer.score_threshold == 0.82
 
+    def test_json(self, openai_encoder, routes):
+        os.environ["OPENAI_API_KEY"] = "test_api_key"
+        route_layer = RouteLayer(encoder=openai_encoder, routes=routes)
+        route_layer.to_json("test_output.json")
+        assert os.path.exists("test_output.json")
+        route_layer_from_file = RouteLayer.from_json("test_output.json")
+        assert (
+            route_layer_from_file.index is not None
+            and route_layer_from_file.categories is not None
+        )
+        os.remove("test_output.json")
+
+    def test_yaml(self, openai_encoder, routes):
+        os.environ["OPENAI_API_KEY"] = "test_api_key"
+        route_layer = RouteLayer(encoder=openai_encoder, routes=routes)
+        route_layer.to_yaml("test_output.yaml")
+        assert os.path.exists("test_output.yaml")
+        route_layer_from_file = RouteLayer.from_yaml("test_output.yaml")
+        assert (
+            route_layer_from_file.index is not None
+            and route_layer_from_file.categories is not None
+        )
+        os.remove("test_output.yaml")
+
+    def test_config(self, openai_encoder, routes):
+        os.environ["OPENAI_API_KEY"] = "test_api_key"
+        route_layer = RouteLayer(encoder=openai_encoder, routes=routes)
+        # confirm route creation functions as expected
+        layer_config = route_layer.to_config()
+        assert layer_config.routes == routes
+        # now load from config and confirm it's the same
+        route_layer_from_config = RouteLayer.from_config(layer_config)
+        assert (route_layer_from_config.index == route_layer.index).all()
+        assert (route_layer_from_config.categories == route_layer.categories).all()
+        assert route_layer_from_config.score_threshold == route_layer.score_threshold
+
 
 # Add more tests for edge cases and error handling as needed.
+
+
+class TestLayerConfig:
+    def test_init(self):
+        layer_config = LayerConfig()
+        assert layer_config.routes == []
+
+    def test_to_file_json(self):
+        route = Route(name="test", utterances=["utterance"])
+        layer_config = LayerConfig(routes=[route])
+        with patch("builtins.open", mock_open()) as mocked_open:
+            layer_config.to_file("data/test_output.json")
+            mocked_open.assert_called_once_with("data/test_output.json", "w")
+
+    def test_to_file_yaml(self):
+        route = Route(name="test", utterances=["utterance"])
+        layer_config = LayerConfig(routes=[route])
+        with patch("builtins.open", mock_open()) as mocked_open:
+            layer_config.to_file("data/test_output.yaml")
+            mocked_open.assert_called_once_with("data/test_output.yaml", "w")
+
+    def test_to_file_invalid(self):
+        route = Route(name="test", utterances=["utterance"])
+        layer_config = LayerConfig(routes=[route])
+        with pytest.raises(ValueError):
+            layer_config.to_file("test_output.txt")
+
+    def test_from_file_json(self):
+        mock_json_data = layer_json()
+        with patch("builtins.open", mock_open(read_data=mock_json_data)) as mocked_open:
+            layer_config = LayerConfig.from_file("data/test.json")
+            mocked_open.assert_called_once_with("data/test.json", "r")
+            assert isinstance(layer_config, LayerConfig)
+
+    def test_from_file_yaml(self):
+        mock_yaml_data = layer_yaml()
+        with patch("builtins.open", mock_open(read_data=mock_yaml_data)) as mocked_open:
+            layer_config = LayerConfig.from_file("data/test.yaml")
+            mocked_open.assert_called_once_with("data/test.yaml", "r")
+            assert isinstance(layer_config, LayerConfig)
+
+    def test_from_file_invalid(self):
+        with open("test.txt", "w") as f:
+            f.write("dummy content")
+        with pytest.raises(ValueError):
+            LayerConfig.from_file("test.txt")
+        os.remove("test.txt")
+
+    def test_to_dict(self):
+        route = Route(name="test", utterances=["utterance"])
+        layer_config = LayerConfig(routes=[route])
+        assert layer_config.to_dict()["routes"] == [route.to_dict()]
+
+    def test_add(self):
+        route = Route(name="test", utterances=["utterance"])
+        route2 = Route(name="test2", utterances=["utterance2"])
+        layer_config = LayerConfig()
+        layer_config.add(route)
+        # confirm route added
+        assert layer_config.routes == [route]
+        # add second route and check updates
+        layer_config.add(route2)
+        assert layer_config.routes == [route, route2]
+
+    def test_get(self):
+        route = Route(name="test", utterances=["utterance"])
+        layer_config = LayerConfig(routes=[route])
+        assert layer_config.get("test") == route
+
+    def test_get_not_found(self):
+        route = Route(name="test", utterances=["utterance"])
+        layer_config = LayerConfig(routes=[route])
+        assert layer_config.get("not_found") is None
+
+    def test_remove(self):
+        route = Route(name="test", utterances=["utterance"])
+        layer_config = LayerConfig(routes=[route])
+        layer_config.remove("test")
+        assert layer_config.routes == []
diff --git a/tests/unit/test_route.py b/tests/unit/test_route.py
index 1de3f0e5174faf9e5bc7a7e66cf069a0170025fd..44cf2276b937e6e36faf01a496106b297c15b29d 100644
--- a/tests/unit/test_route.py
+++ b/tests/unit/test_route.py
@@ -1,9 +1,8 @@
-import os
-from unittest.mock import AsyncMock, mock_open, patch
+from unittest.mock import Mock, patch  # , AsyncMock
 
-import pytest
+# import pytest
 
-from semantic_router.route import Route, RouteConfig, is_valid
+from semantic_router.route import Route, is_valid
 
 
 # Is valid test:
@@ -44,9 +43,8 @@ def test_is_valid_with_invalid_json():
 
 
 class TestRoute:
-    @pytest.mark.asyncio
-    @patch("semantic_router.route.llm", new_callable=AsyncMock)
-    async def test_generate_dynamic_route(self, mock_llm):
+    @patch("semantic_router.route.llm", new_callable=Mock)
+    def test_generate_dynamic_route(self, mock_llm):
         print(f"mock_llm: {mock_llm}")
         mock_llm.return_value = """
         <config>
@@ -62,7 +60,7 @@ class TestRoute:
         </config>
         """
         function_schema = {"name": "test_function", "type": "function"}
-        route = await Route._generate_dynamic_route(function_schema)
+        route = Route._generate_dynamic_route(function_schema)
         assert route.name == "test_function"
         assert route.utterances == [
             "example_utterance_1",
@@ -72,12 +70,42 @@ class TestRoute:
             "example_utterance_5",
         ]
 
+    # TODO add async version
+    # @pytest.mark.asyncio
+    # @patch("semantic_router.route.allm", new_callable=Mock)
+    # async def test_generate_dynamic_route_async(self, mock_llm):
+    #     print(f"mock_llm: {mock_llm}")
+    #     mock_llm.return_value = """
+    #     <config>
+    #     {
+    #         "name": "test_function",
+    #         "utterances": [
+    #             "example_utterance_1",
+    #             "example_utterance_2",
+    #             "example_utterance_3",
+    #             "example_utterance_4",
+    #             "example_utterance_5"]
+    #     }
+    #     </config>
+    #     """
+    #     function_schema = {"name": "test_function", "type": "function"}
+    #     route = await Route._generate_dynamic_route(function_schema)
+    #     assert route.name == "test_function"
+    #     assert route.utterances == [
+    #         "example_utterance_1",
+    #         "example_utterance_2",
+    #         "example_utterance_3",
+    #         "example_utterance_4",
+    #         "example_utterance_5",
+    #     ]
+
     def test_to_dict(self):
         route = Route(name="test", utterances=["utterance"])
         expected_dict = {
             "name": "test",
             "utterances": ["utterance"],
             "description": None,
+            "function_schema": None,
         }
         assert route.to_dict() == expected_dict
 
@@ -87,9 +115,8 @@ class TestRoute:
         assert route.name == "test"
         assert route.utterances == ["utterance"]
 
-    @pytest.mark.asyncio
-    @patch("semantic_router.route.llm", new_callable=AsyncMock)
-    async def test_from_dynamic_route(self, mock_llm):
+    @patch("semantic_router.route.llm", new_callable=Mock)
+    def test_from_dynamic_route(self, mock_llm):
         # Mock the llm function
         mock_llm.return_value = """
         <config>
@@ -109,7 +136,7 @@ class TestRoute:
             """Test function docstring"""
             pass
 
-        dynamic_route = await Route.from_dynamic_route(test_function)
+        dynamic_route = Route.from_dynamic_route(test_function)
 
         assert dynamic_route.name == "test_function"
         assert dynamic_route.utterances == [
@@ -120,6 +147,40 @@ class TestRoute:
             "example_utterance_5",
         ]
 
+    # TODO add async functions
+    # @pytest.mark.asyncio
+    # @patch("semantic_router.route.allm", new_callable=AsyncMock)
+    # async def test_from_dynamic_route_async(self, mock_llm):
+    #     # Mock the llm function
+    #     mock_llm.return_value = """
+    #     <config>
+    #     {
+    #         "name": "test_function",
+    #         "utterances": [
+    #             "example_utterance_1",
+    #             "example_utterance_2",
+    #             "example_utterance_3",
+    #             "example_utterance_4",
+    #             "example_utterance_5"]
+    #     }
+    #     </config>
+    #     """
+
+    #     def test_function(input: str):
+    #         """Test function docstring"""
+    #         pass
+
+    #     dynamic_route = await Route.from_dynamic_route(test_function)
+
+    #     assert dynamic_route.name == "test_function"
+    #     assert dynamic_route.utterances == [
+    #         "example_utterance_1",
+    #         "example_utterance_2",
+    #         "example_utterance_3",
+    #         "example_utterance_4",
+    #         "example_utterance_5",
+    #     ]
+
     def test_parse_route_config(self):
         config = """
         <config>
@@ -146,77 +207,3 @@ class TestRoute:
         }
         """
         assert Route._parse_route_config(config).strip() == expected_config.strip()
-
-
-class TestRouteConfig:
-    def test_init(self):
-        route_config = RouteConfig()
-        assert route_config.routes == []
-
-    def test_to_file_json(self):
-        route = Route(name="test", utterances=["utterance"])
-        route_config = RouteConfig(routes=[route])
-        with patch("builtins.open", mock_open()) as mocked_open:
-            route_config.to_file("data/test_output.json")
-            mocked_open.assert_called_once_with("data/test_output.json", "w")
-
-    def test_to_file_yaml(self):
-        route = Route(name="test", utterances=["utterance"])
-        route_config = RouteConfig(routes=[route])
-        with patch("builtins.open", mock_open()) as mocked_open:
-            route_config.to_file("data/test_output.yaml")
-            mocked_open.assert_called_once_with("data/test_output.yaml", "w")
-
-    def test_to_file_invalid(self):
-        route = Route(name="test", utterances=["utterance"])
-        route_config = RouteConfig(routes=[route])
-        with pytest.raises(ValueError):
-            route_config.to_file("test_output.txt")
-
-    def test_from_file_json(self):
-        mock_json_data = '[{"name": "test", "utterances": ["utterance"]}]'
-        with patch("builtins.open", mock_open(read_data=mock_json_data)) as mocked_open:
-            route_config = RouteConfig.from_file("data/test.json")
-            mocked_open.assert_called_once_with("data/test.json", "r")
-            assert isinstance(route_config, RouteConfig)
-
-    def test_from_file_yaml(self):
-        mock_yaml_data = "- name: test\n  utterances:\n  - utterance"
-        with patch("builtins.open", mock_open(read_data=mock_yaml_data)) as mocked_open:
-            route_config = RouteConfig.from_file("data/test.yaml")
-            mocked_open.assert_called_once_with("data/test.yaml", "r")
-            assert isinstance(route_config, RouteConfig)
-
-    def test_from_file_invalid(self):
-        with open("test.txt", "w") as f:
-            f.write("dummy content")
-        with pytest.raises(ValueError):
-            RouteConfig.from_file("test.txt")
-        os.remove("test.txt")
-
-    def test_to_dict(self):
-        route = Route(name="test", utterances=["utterance"])
-        route_config = RouteConfig(routes=[route])
-        assert route_config.to_dict() == [route.to_dict()]
-
-    def test_add(self):
-        route = Route(name="test", utterances=["utterance"])
-        route_config = RouteConfig()
-        route_config.add(route)
-        assert route_config.routes == [route]
-
-    def test_get(self):
-        route = Route(name="test", utterances=["utterance"])
-        route_config = RouteConfig(routes=[route])
-        assert route_config.get("test") == route
-
-    def test_get_not_found(self):
-        route = Route(name="test", utterances=["utterance"])
-        route_config = RouteConfig(routes=[route])
-        assert route_config.get("not_found") is None
-
-    def test_remove(self):
-        route = Route(name="test", utterances=["utterance"])
-        route_config = RouteConfig(routes=[route])
-        route_config.remove("test")
-        assert route_config.routes == []
diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py
index 27c73c9fc781850011d2f1732fe4c6958a5bcd3b..97b5028e448ea4683c2df09aa93c8946447c8b28 100644
--- a/tests/unit/test_schema.py
+++ b/tests/unit/test_schema.py
@@ -1,12 +1,10 @@
 import pytest
 
-from semantic_router.route import Route
 from semantic_router.schema import (
     CohereEncoder,
     Encoder,
     EncoderType,
     OpenAIEncoder,
-    SemanticSpace,
 )
 
 
@@ -40,20 +38,3 @@ class TestEncoderDataclass:
         encoder = Encoder(type="openai", name="test-engine")
         result = encoder(["test"])
         assert result == [0.1, 0.2, 0.3]
-
-
-class TestSemanticSpaceDataclass:
-    def test_semanticspace_initialization(self):
-        semantic_space = SemanticSpace()
-        assert semantic_space.id == ""
-        assert semantic_space.routes == []
-
-    def test_semanticspace_add_route(self):
-        route = Route(name="test", utterances=["hello", "hi"], description="greeting")
-        semantic_space = SemanticSpace()
-        semantic_space.add(route)
-
-        assert len(semantic_space.routes) == 1
-        assert semantic_space.routes[0].name == "test"
-        assert semantic_space.routes[0].utterances == ["hello", "hi"]
-        assert semantic_space.routes[0].description == "greeting"