Repository: GoogleCloudPlatform/dfcx-scrapi (language: python)

Issue:
Adding a new match type for knowledge connector
Without this match type, the NLU tests encounter the following error: 

```
/usr/local/lib/python3.8/dist-packages/proto/marshal/rules/enums.py:37: UserWarning: Unrecognized MatchType enum value: 8
  warnings.warn(
Exception in thread Thread-5690:
Traceback (most recent call last):
  File "/usr/lib/python3.8/threading.py", line 932, in _bootstrap_inner
    self.run()
  File "/usr/lib/python3.8/threading.py", line 870, in run
    self._target(*self._args, **self._kwargs)
  File "/usr/local/lib/python3.8/dist-packages/dfcx_scrapi/core/conversation.py", line 287, in _get_reply_results
    response = self.reply(
  File "/usr/local/lib/python3.8/dist-packages/dfcx_scrapi/core/conversation.py", line 504, in reply
    reply["match_type"] = self._get_match_type_from_map(
  File "/usr/local/lib/python3.8/dist-packages/dfcx_scrapi/core/conversation.py", line 101, in _get_match_type_from_map
    return match_type_map[match_type]
KeyError: 8
```



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml
new file mode 100644
index 00000000..1bf663d7
--- /dev/null
+++ b/.github/workflows/linter.yml
@@ -0,0 +1,27 @@
+name: Pylint
+
+on:
+  push:
+    branches:
+      - '**'
+  pull_request:
+    branches:
+      - main
+
+jobs:
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Checkout
+      uses: actions/checkout@v3
+    - name: Set up Python
+      uses: actions/setup-python@v4
+      with:
+        python-version: "3.8"
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install pylint
+    - name: Running Pylint
+      run: |
+        pylint --rcfile=.pylintrc $(git ls-files '*.py')
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
deleted file mode 100644
index aac6a586..00000000
--- a/.gitlab-ci.yml
+++ /dev/null
@@ -1,100 +0,0 @@
-# Only run pipelines for MRs and master.
-workflow:
-    rules:
-        - if: $CI_MERGE_REQUEST_IID
-        - if: $CI_COMMIT_TAG
-        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
-        - when: never
-
-stages:
-    - lint
-    - test
-    # - build
-
-# Official language image. Look for the different tagged releases at:
-# https://hub.docker.com/r/library/python/tags/
-# image: python:latest 
-image: python:3.9.7
-
-# Change pip's cache directory to be inside the project directory since we can
-# only cache local items.
-variables:
-    PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
-
-before_script:
-    - python -V 
-    - python -m venv venv
-    - source venv/bin/activate
-    - pip install --upgrade pip
-
-# Job to run if there's nothing to lint or test.
-noop:
-    image: alpine:3.7
-    stage: lint
-    rules:
-        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
-          when: never
-        - if: $CI_MERGE_REQUEST_IID
-          changes:
-            - '**/*.{py}'
-            - tests/**/*
-          when: never
-        - when: on_success
-    cache: {}
-    before_script: []
-    script: echo 'noop'
-
-linting:
-    stage: lint
-    needs: []
-    rules:
-        - changes:
-            - '**/*.{py}'
-            # - tests/license/license_check.sh
-          when: on_success
-        - when: never
-    script:
-        # - chmod +x tests/license/license_check.sh
-        # - tests/license/license_check.sh
-        - pip install --upgrade pip
-        - pip install pylint==2.8.3
-        - pylint --rcfile=.pylintrc --jobs=0 $(git ls-files '*.py')
-
-# Run unit tests. Always runs unit tests on the default branch.
-#
-# If this is a merge request, only run tests if certain files were modified.
-# Tests need to always be run on the default branch to update the test coverage
-# metric in GitLab.
-e2e_agents_test:
-    stage: test
-    needs: []
-    rules:
-        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
-          when: on_success
-        - if: $CI_MERGE_REQUEST_IID
-          changes:
-            - '**/agents.{py}'
-            - tests/test_agents.{py}
-          when: on_success
-        - when: never
-    script:
-        # Run Tests
-        - pip install -r requirements.txt
-        - pytest tests --cov=tests --project=$SCRAPI_TEST_PROJECT --gcs_bucket=$SCRAPI_TEST_GCS_BUCKET --creds=$SCRAPI_CI_SA
-    coverage: '/TOTAL.+ ([0-9]{1,3}%)/'
-    tags:
-      - k8s-dev-runner
-
-# building:
-#     stage: build
-#     rules:
-#         - changes:
-#             - '**/*.{py}'
-#             - setup.py
-#           when: on_success
-#     script:
-#         # Prep to build and deploy to Gitlab Package repo
-#         - pip install twine
-#         - pip install wheel
-#         - python setup.py sdist bdist_wheel
-#         - TWINE_PASSWORD=${CI_JOB_TOKEN} TWINE_USERNAME=gitlab-ci-token python -m twine upload --repository-url ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi dist/*
\ No newline at end of file
diff --git a/.pylintrc b/.pylintrc
index 3d4548c0..e1ec406e 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -153,12 +153,6 @@ disable=abstract-method,
 # mypackage.mymodule.MyReporterClass.
 output-format=text
 
-# Put messages in a separate file for each module / package specified on the
-# command line instead of printing them on stdout. Reports (if any) will be
-# written in a file name "pylint_global.[txt|html]". This option is deprecated
-# and it will be removed in Pylint 2.0.
-files-output=no
-
 # Tells whether to display a full report or only the messages
 reports=no
 
@@ -277,12 +271,6 @@ ignore-long-lines=(?x)(
 # else.
 single-line-if-stmt=yes
 
-# List of optional constructs for which whitespace checking is disabled. `dict-
-# separator` is used to allow tabulation in dicts, etc.: {1  : 1,\n222: 2}.
-# `trailing-comma` allows a space between comma and closing bracket: (a, ).
-# `empty-line` allows space-only lines.
-no-space-check=
-
 # Maximum number of lines in a module
 max-module-lines=99999
 
@@ -435,4 +423,4 @@ valid-metaclass-classmethod-first-arg=mcs
 # "Exception"
 overgeneral-exceptions=StandardError,
                        Exception,
-                       BaseException
\ No newline at end of file
+                       BaseException
diff --git a/README.md b/README.md
index e1b87289..721a1f73 100644
--- a/README.md
+++ b/README.md
@@ -147,7 +147,7 @@ Distributed under the Apache 2.0 License. See [LICENSE](LICENSE.txt) for more in
 # Contact
 Patrick Marlow - pmarlow@google.com  - [@kmaphoenix](https://github.com/kmaphoenix)  
 David "DC" Collier - dcollier@google.com  - [@DCsan](https://github.com/dcsan)  
-Henry Drescher - drescher@google.com  - [@Hgithubacct](https://github.com/Hgithubacct)  
+Lauren Greenford - greenford@google.com - [@Greenford](https://github.com/Greenford)
 
 Project Link: [https://github.com/GoogleCloudPlatform/dfcx-scrapi](https://github.com/GoogleCloudPlatform/dfcx-scrapi)
 
diff --git a/data/ccai_service_kit/conf_score_cfx/main.py b/data/ccai_service_kit/conf_score_cfx/main.py
index dafd04cd..c28f5f96 100644
--- a/data/ccai_service_kit/conf_score_cfx/main.py
+++ b/data/ccai_service_kit/conf_score_cfx/main.py
@@ -49,8 +49,8 @@ def conf_score(request, debugging=False):
         logging.info('* [cx_webhook] CONF TEST TAG RECEIVED.')
         score = wu.get_conf_score(req)
         session_info = wu.build_session_info({'webhook_conf': score})
-        message = wu.build_response('The Confidence Score is: {}'.format(
-          score), session_info=session_info)
+        message = wu.build_response(
+            f'The Confidence Score is: {score}', session_info=session_info)
 
     else:
         logging.info('* [cx_webhook] No Webhook Tag Received.')
@@ -62,7 +62,7 @@ def conf_score(request, debugging=False):
 
 if __name__ == '__main__':
     FILE = '/Users/pmarlow/eng/cloud_functions/tests/conf_score_payload.json'
-    with open(FILE) as json_file:
+    with open(FILE, encoding='UTF-8') as json_file:
         data = json.load(json_file)
 
     conf_score(data, False)
diff --git a/data/intent_sample_params.csv b/data/intent_sample_params.csv
new file mode 100644
index 00000000..16a7f5b2
--- /dev/null
+++ b/data/intent_sample_params.csv
@@ -0,0 +1,2 @@
+display_name,id,entity_type
+head_intent.order_pizza,numberOfPizza,projects/-/locations/-/agents/-/entityTypes/sys.number
\ No newline at end of file
diff --git a/data/intent_sample_with_parts.csv b/data/intent_sample_with_parts.csv
new file mode 100644
index 00000000..fb54c615
--- /dev/null
+++ b/data/intent_sample_with_parts.csv
@@ -0,0 +1,9 @@
+display_name,training_phrase,part,text,parameter_id
+head_intent.order_pizza,0,0,I want to order a pizza,
+head_intent.order_pizza,1,0,I\'d like a cheese pizza with pepporoni please!,
+head_intent.order_pizza,2,0,A slice of cheese with extra basil,
+head_intent.order_pizza,3,0,2 ,numberOfPizza
+head_intent.order_pizza,3,1,large thin crust extra pepp add olives,
+head_intent.order_pizza,4,0,I need to place an order for ,
+head_intent.order_pizza,4,1,15 ,numberOfPizza
+head_intent.order_pizza,4,2,large cheese pizzas for a birthday party!,
diff --git a/examples/bot_building_series/bot_building_102_intents_with_annotated_tp.ipynb b/examples/bot_building_series/bot_building_102_intents_with_annotated_tp.ipynb
new file mode 100644
index 00000000..72ef1a89
--- /dev/null
+++ b/examples/bot_building_series/bot_building_102_intents_with_annotated_tp.ipynb
@@ -0,0 +1,212 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Copyright 2021 Google LLC\n",
+    "#\n",
+    "# Licensed under the Apache License, Version 2.0 (the \"License\");\n",
+    "# you may not use this file except in compliance with the License.\n",
+    "# You may obtain a copy of the License at\n",
+    "#\n",
+    "#     https://www.apache.org/licenses/LICENSE-2.0\n",
+    "#\n",
+    "# Unless required by applicable law or agreed to in writing, software\n",
+    "# distributed under the License is distributed on an \"AS IS\" BASIS,\n",
+    "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n",
+    "# See the License for the specific language governing permissions and\n",
+    "# limitations under the License."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Introduction\n",
+    "In this notebook, we will show you how to build a Dialogflow CX Agent from scratch using plain text inputs and a simple CSV file.\n",
+    "\n",
+    "In this example some of the training phrases will be anotated with the @sys.number entity\n",
+    "\n",
+    "## Prerequisites\n",
+    "- Ensure you have a GCP Service Account key with the Dialogflow API Admin privileges assigned to it."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "#If you haven't already, make sure you install the `dfcx-scrapi` library\n",
+    "#Link to the GitHub repo: https://github.com/GoogleCloudPlatform/dfcx-scrapi\n",
+    "\n",
+    "!pip install --user dfcx-scrapi\n",
+    "!pip install --user -r ./dfcx-scrapi/requirements.txt"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Imports"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pandas as pd\n",
+    "from dfcx_scrapi.core.agents import Agents\n",
+    "from dfcx_scrapi.core.intents import Intents\n",
+    "from dfcx_scrapi.core.flows import Flows\n",
+    "from dfcx_scrapi.core.pages import Pages\n",
+    "from dfcx_scrapi.tools.dataframe_functions import DataframeFunctions\n",
+    "from dfcx_scrapi.tools.maker_util import MakerUtil\n",
+    "\n",
+    "#Input here your Service Account Key path\n",
+    "creds_path = '/PATH/TO/YOU/KEY.JSON'"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Method 1 - Creating an Agent from Simple Text Inputs"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Create Your Agent\n",
+    "Creating an agent requires a minimum of 2 pieces of information:\n",
+    "- `project_id`, which is your GCP Project ID\n",
+    "- `display_name`, (i.e. 'My Cool Agent!')\n",
+    "- `gcp_region`, (Optional) This defaults to `global` region, but you can provide any GCP region that is currently available for Dialogflow CX."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# First we will instantiate our Agent object\n",
+    "a = Agents(creds_path=creds_path)\n",
+    "\n",
+    "# Next, we will set some variables for our agent creation or retrieval args\n",
+    "project_id = '<PROJECT_ID>'\n",
+    "display_name = 'My Awesome Agent!'\n",
+    "gcp_region = '<GCP_REGION>' #(eg. 'global', 'us-cenral1', etc... See region availability for DFCX at https://cloud.google.com/dialogflow/cx/docs/concept/region\n",
+    "\n",
+    "# Then we will call the `create_agent` or get_agent method (if agent already exists) and capture the result in a var call `my_agent`\n",
+    "#Option 1: If agent does not exist\n",
+    "#my_agent = a.create_agent(project_id, display_name, gcp_region)\n",
+    "\n",
+    "#Option 2: If agent already exists\n",
+    "#The agent ID must be entered as a string \"projects/<PROJECT_ID>/locations/<GCP_REGION>/agents/<AGENT_ID>\"\n",
+    "my_agent = a.get_agent(\"projects/<PROJECT_ID>/locations/<GCP_REGION>/agents/<AGENT_ID>\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "If you inspect your `my_agent` variable, you will see it is of types.agent.Agent"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "my_agent"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Create Your First Intent\n",
+    "For this demo agent, we'll build a basic intent from list of Training Phrases (TPs)\n",
+    "\n",
+    "To simplify the Intent creation, we'll utilize the `DataframeFunctions` class from the `tools` portion of the SCRAPI library.   \n",
+    "This will allow us to build our intent into a simple Pandas DataFrame, and then push this DataFrame directly into our bot that we just created.\n",
+    "\n",
+    "A common method of building Intents and Training Phrases for Dialogflow CX agents is to use Google Sheets or CSVs to store the Intent/TP data.  \n",
+    "For this demo, we are working with annotated training phrases. To do so, we've included \n",
+    "* `intent_sample_with_parts.csv` which contains all the training phrases parts. It will be pulled into a dataframe \n",
+    "* `intent_sample_params`, that defines the parameters used for each intent. It will also be pulled into a dataframe"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# First, we will instantiate our DataframeFunctions (dffx) object\n",
+    "dffx = DataframeFunctions(creds_path=creds_path)\n",
+    "\n",
+    "# Next, we will read in our sample CSV with Intent training phrases data as well as the entities used into 2 distinct Pandas DataFrames\n",
+    "df = pd.read_csv('../../data/intent_sample_with_parts.csv')\n",
+    "params_df = pd.read_csv('../../data/intent_sample_params.csv')\n",
+    "\n",
+    "# Finally, we will use `dffx` to push our Intents to our Agent\n",
+    "#If intents do not exist, use bulk_create_intent_from_dataframe\n",
+    "my_intents = dffx.bulk_create_intent_from_dataframe(my_agent.name, df, params_df, update_flag=True, mode=\"advanced\")\n",
+    "\n",
+    "#If intent already exist, use bulk_update_intents_from_dataframe\n",
+    "#my_intents = dffx.bulk_update_intents_from_dataframe(my_agent.name, df, params_df, update_flag=True, mode=\"advanced\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "tags": []
+   },
+   "source": [
+    "# Bot Building 102 End\n",
+    "## And there you have it!   \n",
+    "We've created a simple Dialogflow CX agent using only Python in a Jupyter notebook.\n",
+    "You can see how this could be easily scaled up using .py files, git repos, and other scripts to speed up the bot building process."
+   ]
+  }
+ ],
+ "metadata": {
+  "environment": {
+   "kernel": "conda-env-python38-py",
+   "name": "common-cpu.m90",
+   "type": "gcloud",
+   "uri": "gcr.io/deeplearning-platform-release/base-cpu:m90"
+  },
+  "interpreter": {
+   "hash": "a46639dc4ce59764707f7d29e7ddf4b543fd040f828fe9a3cb5baeb9324df219"
+  },
+  "kernelspec": {
+   "display_name": "Python [conda env:python38]",
+   "language": "python",
+   "name": "conda-env-python38-py"
+  },
+  "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.8.12"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/examples/bot_building_series/bot_building_102_intents_with_annotated_tp.py b/examples/bot_building_series/bot_building_102_intents_with_annotated_tp.py
new file mode 100644
index 00000000..35292e40
--- /dev/null
+++ b/examples/bot_building_series/bot_building_102_intents_with_annotated_tp.py
@@ -0,0 +1,153 @@
+"""Example of building Dialogflow CX Agents using SCRAPI library."""
+#!/usr/bin/env python
+# coding: utf-8
+
+# Copyright 2021 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+import pandas as pd
+from dfcx_scrapi.core.agents import Agents
+from dfcx_scrapi.tools.dataframe_functions import DataframeFunctions
+
+# # Method 1 - Creating an Agent from Simple Text Inputs
+
+# ## Create Your Agent
+# Creating an agent requires a minimum of 2 pieces of information:
+# - `project_id`, which is your GCP Project ID
+# - `display_name`, (i.e. 'My Cool Agent!')
+# - `gcp_region`, (Optional) This defaults to `global` region, but you can
+# 	provide any GCP region that is currently available for Dialogflow CX.
+
+
+def build_agent(creds_path, project_id, gcp_region, display_name):
+    """Build out CX Agent."""
+
+    # First we will instantiate our Agent object
+    agent_instance = Agents(creds_path=creds_path)
+
+    # Next, we will set some variables for our agent creation or retrieval args
+
+    # Then we will call the `create_agent` and capture the result in a var call
+	# `my_agent`
+    my_agent = agent_instance.create_agent(project_id, display_name, gcp_region)
+
+    # Option 2: If agent already exists
+    # The agent ID must be entered as a string
+    # "projects/<PROJECT_ID>/locations/<GCP_REGION>/agents/<AGENT_ID>"
+    # my_agent = a.get_agent(
+	#   "projects/<PROJECT_ID>/locations/<GCP_REGION>/agents/<AGENT_ID>")
+
+    # ## Create Your First Intent
+    # For this demo agent, we'll build a basic intent from list of Training
+	# Phrases (TPs)
+
+    # To simplify the Intent creation, we'll utilize the `DataframeFunctions`
+    # class from the `tools` portion of the SCRAPI library.
+    # This will allow us to build our intent into a simple Pandas DataFrame,
+    # and then push this DataFrame directly into our bot that we just created.
+
+    # A common method of building Intents and Training Phrases for Dialogflow
+    # CX agents is to use Google Sheets or CSVs to store the Intent/TP data.
+    # For this demo, we are working with annotated training phrases. To do so,
+    # we've included
+    # * `intent_sample_with_parts.csv` which contains all the training phrases
+    # 	parts. It will be pulled into a dataframe
+    # * `intent_sample_params`, that defines the parameters used for each
+    # 	intent. It will also be pulled into a dataframe
+
+    # First, we will instantiate our DataframeFunctions (dffx) object
+    dffx = DataframeFunctions(creds_path=creds_path)
+
+    # Next, we will read in our sample CSV with Intent training phrases data as
+    # well as the entities used into 2 distinct Pandas DataFrames
+    df = pd.read_csv("../../data/intent_sample_with_parts.csv")  # pylint: disable=C0103
+    params_df = pd.read_csv("../../data/intent_sample_params.csv")
+
+    # Finally, we will use `dffx` to push our Intents to our Agent
+    # If intents do not exist, use bulk_create_intent_from_dataframe
+    _ = dffx.bulk_create_intent_from_dataframe(
+        my_agent.name, df, params_df, update_flag=True, mode="advanced"
+    )
+
+
+# If intent already exist, use bulk_update_intents_from_dataframe
+# my_intents = dffx.bulk_update_intents_from_dataframe(
+# my_agent.name, df, params_df, update_flag=True, mode="advanced")
+
+
+# # Bot Building 101 End
+# ## And there you have it!
+# We've created a simple Dialogflow CX agent using only Python in a Jupyter
+# notebook.
+# You can see how this could be easily scaled up using .py files, git repos,
+# and other scripts to speed up the bot building process.
+
+
+# The agent ID must be entered as a string
+# 	"projects/<PROJECT_ID>/locations/<GCP_REGION>/agents/<AGENT_ID>"
+def update_agent(creds_path, agent_id):
+    """Updating the agent."""
+    # First we will instantiate our Agent object and retrieve the agent using
+    # its id
+    agent_instance = Agents(creds_path=creds_path)
+    my_agent = agent_instance.get_agent(
+        f"projects/<PROJECT_ID>/locations/<GCP_REGION>/agents/{agent_id}"
+    )
+
+    # ## Create Your First Intent
+    # For this demo agent, we'll build a basic intent from list of Training
+    # Phrases (TPs)
+
+    # To simplify the Intent creation, we'll utilize the `DataframeFunctions`
+    # class from the `tools` portion of the SCRAPI library. This will allow
+    # us to build our intent into a simple Pandas DataFrame, and then push
+    # this DataFrame directly into our bot that we just created.
+
+    # A common method of building Intents and Training Phrases for Dialogflow
+    # CX agents is to use Google Sheets or CSVs to store the Intent/TP data.
+    # For this demo, we are working with annotated training phrases. To do so,
+    # we've included:
+    #   * `intent_sample_with_parts.csv` which contains all the training
+    #     phrases parts. It will be pulled into a dataframe
+    #   * `intent_sample_params`, that defines the parameters used for each
+    #     intent. It will also be pulled into a dataframe
+
+    # First, we will instantiate our DataframeFunctions (dffx) object
+    dffx = DataframeFunctions(creds_path=creds_path)
+
+    # Next, we will read in our sample CSV with Intent training phrases data as
+    #  well as the entities used into 2 distinct Pandas DataFrames
+    df = pd.read_csv("../../data/intent_sample_with_parts.csv")  # pylint: disable=C0103
+    params_df = pd.read_csv("../../data/intent_sample_params.csv")
+
+    # bulk update the existing intents
+    _ = dffx.bulk_update_intents_from_dataframe(
+        my_agent.name, df, params_df, update_flag=True, mode="advanced"
+    )
+
+
+# # Bot Building 102 End
+# ## And there you have it!
+# We've created a simple Dialogflow CX agent using only Python in a Jupyter
+# notebook. You can see how this could be easily scaled up using .py files,
+# git repos, and other scripts to speed up the bot building process.
+
+if __name__ == "__main__":
+    CREDS_PATH = str(sys.argv[1])
+    PROJECT_ID = str(sys.argv[2])
+    REGION = str(sys.argv[3])
+    DISPLAY_NAME = str(sys.argv[4])
+
+    build_agent(CREDS_PATH, PROJECT_ID, REGION, DISPLAY_NAME)
diff --git a/examples/bot_building_series/builders_101.ipynb b/examples/bot_building_series/builders_101.ipynb
new file mode 100644
index 00000000..9bfbc5e8
--- /dev/null
+++ b/examples/bot_building_series/builders_101.ipynb
@@ -0,0 +1,553 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "f0502974",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Copyright 2022 Google LLC\n",
+    "#\n",
+    "# Licensed under the Apache License, Version 2.0 (the \"License\");\n",
+    "# you may not use this file except in compliance with the License.\n",
+    "# You may obtain a copy of the License at\n",
+    "#\n",
+    "#     https://www.apache.org/licenses/LICENSE-2.0\n",
+    "#\n",
+    "# Unless required by applicable law or agreed to in writing, software\n",
+    "# distributed under the License is distributed on an \"AS IS\" BASIS,\n",
+    "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n",
+    "# See the License for the specific language governing permissions and\n",
+    "# limitations under the License."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "9564d9d3",
+   "metadata": {},
+   "source": [
+    "# Builders 101 - Using Builder Classes to Build Bots Programmatically\n",
+    "In this notebook, we will show you how to utilize the new Builder classes to build out different sections of Dialogflow CX Agents.\n",
+    "\n",
+    "## Prerequisites\n",
+    "- Ensure you have a GCP Service Account key with the following privileges assigned to it, based on your needs:\n",
+    "  - Dialogflow API Reader, use this permission level if you are just exploring the Dialogflow CX resources and developing offline\n",
+    "  - Dialogflow API Admin, use this permissions level if you are planning to Read/Write to your Dialogflow CX Agent and/or resources"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9ba925c8",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# If you haven't already, make sure you install the `dfcx-scrapi` library\n",
+    "\n",
+    "!pip install dfcx-scrapi --quiet"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "0a4461fc",
+   "metadata": {},
+   "source": [
+    "# Imports"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "id": "3c930991",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from dfcx_scrapi.core.agents import Agents\n",
+    "from dfcx_scrapi.core.intents import Intents\n",
+    "\n",
+    "from dfcx_scrapi.builders.agents import AgentBuilder\n",
+    "from dfcx_scrapi.builders.intents import IntentBuilder\n",
+    "from dfcx_scrapi.builders.entity_types import EntityTypeBuilder"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "59e575ff",
+   "metadata": {},
+   "source": [
+    "# Agent Builder Class\n",
+    "In this section, we'll explore the Agent Builder Class.  \n",
+    "We'll build a new Agent Proto from scratch, and then use the SCRAPI Core Agents class to send the API call to actually create it!"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "6fbcadba",
+   "metadata": {},
+   "source": [
+    "### Build an Agent Proto\n",
+    "All Dialogflow CX resources are protobuf objects.  \n",
+    "Here we will create a new Agent Proto object.\n",
+    "\n",
+    "The builder classes allow us to create these proto objects with very simple text inputs.  \n",
+    "The result of each method is a new or modified proto object."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "14307d6d",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "display_name: \"Patrick Awesome Agent\"\n",
+       "default_language_code: \"en\"\n",
+       "time_zone: \"America/Chicago\"\n",
+       "description: \"I built this agent from the Builder file!\""
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "# Instantiate Class\n",
+    "ab = AgentBuilder()\n",
+    "\n",
+    "# Create New Agent\n",
+    "ab.create_new_agent(\n",
+    "    display_name = 'Patrick Awesome Agent',\n",
+    "    time_zone='America/Chicago',\n",
+    "    default_language_code='en',\n",
+    "    description='I built this agent from the Builder file!',\n",
+    "    overwrite=True\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "9f903fdd",
+   "metadata": {},
+   "source": [
+    "### Show Agent\n",
+    "In the above output, we get the Agent Proto object.  \n",
+    "Below, we can use the `show_agent_info` method to pretty print other details of our Agent proto, including default settings."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "5bb70f41",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "display_name: Patrick Awesome Agent\n",
+      "time_zone: America/Chicago\n",
+      "default_language_code: en\n",
+      "description: I built this agent from the Builder file!\n",
+      "avatar_uri: \n",
+      "enable_speech_adaptation: False\n",
+      "enable_spell_correction: False\n",
+      "supported_language_codes: []\n",
+      "enable_stackdriver_logging: False\n",
+      "enable_interaction_logging: False\n",
+      "security_settings: \n"
+     ]
+    }
+   ],
+   "source": [
+    "ab.show_agent_info()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b04fdebe",
+   "metadata": {},
+   "source": [
+    "### Security, Logging, Language and Speech Settings\n",
+    "We can continue to modify our Agent Proto by adding various settings or configurations.\n",
+    "\n",
+    "We can see in the proto output below that the default settings have now been modified."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "f845a729",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "display_name: \"Patrick Awesome Agent\"\n",
+       "default_language_code: \"en\"\n",
+       "time_zone: \"America/Chicago\"\n",
+       "description: \"I built this agent from the Builder file!\"\n",
+       "speech_to_text_settings {\n",
+       "  enable_speech_adaptation: true\n",
+       "}\n",
+       "enable_spell_correction: true\n",
+       "advanced_settings {\n",
+       "  logging_settings {\n",
+       "    enable_stackdriver_logging: true\n",
+       "    enable_interaction_logging: true\n",
+       "  }\n",
+       "}"
+      ]
+     },
+     "execution_count": 5,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "# Language and Speech Settings\n",
+    "ab.language_and_speech_settings(\n",
+    "    enable_speech_adaptation=True,\n",
+    "    enable_spell_correction=True\n",
+    ")\n",
+    "\n",
+    "# Security and Logging Settings\n",
+    "ab.security_and_logging_settings(\n",
+    "    enable_stackdriver_logging=True,\n",
+    "    enable_interaction_logging=True\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "716f993a",
+   "metadata": {},
+   "source": [
+    "### Create Agent\n",
+    "Now it's time to take our Agent Proto and actually create it in Dialogflow CX.\n",
+    "\n",
+    "The below proto response shows us that the agent was successfully created!"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "93807f48",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "name: \"projects/pmarlow-ccai-dev/locations/global/agents/3f1c2b1e-83d5-4454-99b2-70fd42d7987d\"\n",
+       "display_name: \"Patrick Awesome Agent\"\n",
+       "default_language_code: \"en\"\n",
+       "time_zone: \"America/Chicago\"\n",
+       "description: \"I built this agent from the Builder file!\"\n",
+       "speech_to_text_settings {\n",
+       "  enable_speech_adaptation: true\n",
+       "}\n",
+       "start_flow: \"projects/pmarlow-ccai-dev/locations/global/agents/3f1c2b1e-83d5-4454-99b2-70fd42d7987d/flows/00000000-0000-0000-0000-000000000000\"\n",
+       "enable_stackdriver_logging: true\n",
+       "enable_spell_correction: true\n",
+       "advanced_settings {\n",
+       "  logging_settings {\n",
+       "    enable_stackdriver_logging: true\n",
+       "    enable_interaction_logging: true\n",
+       "  }\n",
+       "}"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "# Instantiate Core Agent Class\n",
+    "a = Agents()\n",
+    "\n",
+    "# Push Agent to DFCX\n",
+    "a.create_agent(\n",
+    "    '<YOUR_GCP_PROJECT_HERE>',\n",
+    "    gcp_region='global',\n",
+    "    obj=ab.proto_obj)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d8893e1e",
+   "metadata": {},
+   "source": [
+    "# Intent Builder Class\n",
+    "Similar to the Agent Builde Class above, you can use the Intents Builder class to create new Intent Protos\n",
+    "\n",
+    "Try some of the methods below.  \n",
+    "Uncomment as needed."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e28123e8",
+   "metadata": {},
+   "source": [
+    "### Intent Builder Methods"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "75b60c12",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Instantiate Class\n",
+    "ib = IntentBuilder()\n",
+    "\n",
+    "# Create Intent\n",
+    "display_name = 'Patrick Cool Intent'\n",
+    "description = 'This is my test intent from SCRAPI part 2'\n",
+    "\n",
+    "ib.create_new_intent(display_name=display_name, description=description, overwrite=True)\n",
+    "\n",
+    "# Show Intent\n",
+    "ib.show_intent()\n",
+    "\n",
+    "# Show Stats\n",
+    "ib.show_stats()\n",
+    "\n",
+    "# Parameter Checking\n",
+    "# ib.parameter_checking()\n",
+    "\n",
+    "# Add Training Phrase - Default Spacing\n",
+    "# ib.add_training_phrase(  \n",
+    "#     phrase=['my', 'name', 'is', 'Caro'],\n",
+    "#     annotations=['', '', '', 'people'])\n",
+    "\n",
+    "# Add Training Phrase - Overwrite Spacing\n",
+    "# ib.add_training_phrase(  \n",
+    "#     phrase=['my', 'name', 'is', 'Mud'],\n",
+    "#     annotations=['', '', '', 'people'],\n",
+    "#     include_spaces=False)\n",
+    "\n",
+    "# Add Parameter\n",
+    "# ib.add_parameter(\n",
+    "#     parameter_id='people',\n",
+    "#     entity_type='projects/-/locations/-/agents/-/entityTypes/sys.person',\n",
+    "#     redact=True\n",
+    "# )\n",
+    "\n",
+    "# Add Label\n",
+    "# ib.add_label({'lob':'accounting'})\n",
+    "# ib.add_label({'my_key2':'my-value-dashed'})"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "857be1c8",
+   "metadata": {},
+   "source": [
+    "### Create Intent\n",
+    "Now you're ready to push your new Intent Proto object to your Dialogflow CX Agent!"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a70fa5b3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Instantiate Core Intents Class\n",
+    "i = Intents()\n",
+    "\n",
+    "# Create Intent in DFCX\n",
+    "i.create_intent(\n",
+    "    '<YOUR_AGENT_ID_HERE>',\n",
+    "    obj=ib.proto_obj)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "092fa9d8",
+   "metadata": {},
+   "source": [
+    "# Entity Types Builder Class\n",
+    "Same as Agent and Intents, but with Entity Types!\n",
+    "\n",
+    "Try some of the methods below."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "8cb31232",
+   "metadata": {},
+   "source": [
+    "### Entity Type Methods"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "id": "8e3fbaad",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "display_name: my_builder_entity_type\n",
+      "kind: KIND_MAP\n",
+      "auto_expansion_mode: 0\n",
+      "enable_fuzzy_extraction: True\n",
+      "redact: True\n",
+      "excluded phrases:\n",
+      "\t\n",
+      "entities:\n",
+      "  vegetable:\n",
+      "\tcelery, lettuce, tomato\n",
+      "  fruit:\n",
+      "\torange, apple\n"
+     ]
+    }
+   ],
+   "source": [
+    "# Instantiate Class\n",
+    "eb = EntityTypeBuilder()\n",
+    "\n",
+    "# Create New Entity Type\n",
+    "eb.create_new_entity_type(\n",
+    "    display_name='my_builder_entity_type',\n",
+    "    kind=1,\n",
+    "    enable_fuzzy_extraction=True,\n",
+    "    redact=True,\n",
+    "    overwrite=True\n",
+    ")\n",
+    "\n",
+    "# Add Entity\n",
+    "eb.add_entity('vegetable', ['celery','lettuce','tomato'])\n",
+    "eb.add_entity('fruit', ['apple','orange','grape'])\n",
+    "\n",
+    "# Remove Entity Type\n",
+    "eb.remove_entity('fruit', ['grape'])\n",
+    "\n",
+    "# Show Entity Type\n",
+    "eb.show_entity_type()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ebc453fa",
+   "metadata": {},
+   "source": [
+    "### Entity Type Methods Continued"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "id": "a9fd02d0",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "display_name: my_builder_entity_type\n",
+      "kind: KIND_MAP\n",
+      "auto_expansion_mode: 0\n",
+      "enable_fuzzy_extraction: True\n",
+      "redact: True\n",
+      "excluded phrases:\n",
+      "\tcauliflower\n",
+      "\tturkey\n",
+      "\tpotato\n",
+      "entities:\n",
+      "  vegetable:\n",
+      "\tcelery, lettuce, tomato\n",
+      "  fruit:\n",
+      "\torange, apple\n",
+      "display_name: my_builder_entity_type\n",
+      "kind: KIND_MAP\n",
+      "auto_expansion_mode: 0\n",
+      "enable_fuzzy_extraction: True\n",
+      "redact: True\n",
+      "excluded phrases:\n",
+      "\tcauliflower\n",
+      "entities:\n",
+      "  vegetable:\n",
+      "\tcelery, lettuce, tomato\n",
+      "  fruit:\n",
+      "\torange, apple\n"
+     ]
+    }
+   ],
+   "source": [
+    "# Add Excluded Phrase\n",
+    "eb.add_excluded_phrase(['cauliflower', 'turkey'])\n",
+    "eb.add_excluded_phrase('potato')\n",
+    "\n",
+    "eb.show_entity_type()\n",
+    "\n",
+    "# Remove Excluded Phrase\n",
+    "eb.remove_excluded_phrase('potato')\n",
+    "eb.remove_excluded_phrase(['turkey'])\n",
+    "\n",
+    "# Show Entity Type\n",
+    "eb.show_entity_type()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Final Thoughts and Wrap-Up\n",
+    "In this notebook, we've demonstrated how to use the new SCRAPI Builder classes to build offline Dialogflow CX Agent resources.  \n",
+    "These classes are helpful when building and modifying hundreds or even thousands of resources at scale, without the need to call the Dialogflow CX API.  \n",
+    "\n",
+    "Build and experiment offline, then push to the Dialogflow CX API once!\n",
+    "\n",
+    "Happy Coding!"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "f93983c0",
+   "metadata": {},
+   "source": []
+  }
+ ],
+ "metadata": {
+  "environment": {
+   "name": "common-cpu.m73",
+   "type": "gcloud",
+   "uri": "gcr.io/deeplearning-platform-release/base-cpu:m73"
+  },
+  "kernelspec": {
+   "display_name": "Python 3.8.12 ('scrapi-local')",
+   "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.8.12"
+  },
+  "vscode": {
+   "interpreter": {
+    "hash": "7413f51bec3c978f0622000f59fce9c6157925f7fef8079c4a26e2f8ecdaa214"
+   }
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/copy_paste_series/copy_paste_pages.ipynb b/examples/copy_paste_series/copy_paste_pages.ipynb
index 73ec5432..54bce639 100644
--- a/examples/copy_paste_series/copy_paste_pages.ipynb
+++ b/examples/copy_paste_series/copy_paste_pages.ipynb
@@ -3,6 +3,8 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "# Copyright 2021 Google LLC\n",
     "#\n",
@@ -17,12 +19,11 @@
     "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n",
     "# See the License for the specific language governing permissions and\n",
     "# limitations under the License."
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Introduction\n",
     "In this notebook, we will demonstrate how to copy and paste Page resources within the _SAME_ agent from a `Source` Flow to a `Target` Flow.\n",
@@ -31,38 +32,38 @@
     "\n",
     "## Prerequisites\n",
     "- Ensure you have a GCP Service Account key with the Dialogflow API Admin privileges assigned to it."
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "#If you haven't already, make sure you install the `dfcx-scrapi` library\n",
     "\n",
     "!pip install dfcx-scrapi"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Imports\n"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "from dfcx_scrapi.tools.copy_util import CopyUtil"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# User Inputs\n",
     "In the next section, we will collect runtime variables needed to execute this notebook.   \n",
@@ -72,72 +73,72 @@
     "\n",
     "- `creds_path`: Your local path to your GCP Service Account Credentials\n",
     "- `agent_id`: Your Dialogflow CX Agent ID in String format"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "creds_path = '<YOUR_CREDS_FILE>'\n",
     "agent_id = '<YOUR_AGENT_ID>'\n",
     "source_flow = 'Default Start Flow'\n",
     "target_flow = 'My Target Flow'"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Get Flows Map from Agent\n",
     "First, we will extract a `map` of the Flow IDs and Flow Display Names from the Agent.   \n",
     "We pass `reverse=True` to the `get_flows_map` function which provides the Display Names as keys and the IDs as values.   \n",
     "This allows us to refernce the map using Display Names vs. the long, cumbersome ID names."
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "cu = CopyUtil(creds_path)\n",
     "\n",
     "flows_map = cu.flows.get_flows_map(agent_id, reverse=True)"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Get All Pages from Source Flow\n",
     "Once we have our `flows_map`, we will use it to extract all of the Page objects in our `Source` Flow.   \n",
     "In our case, we want to extract pages from the Flow named `Default Start Flow`.  \n",
     "We will call the `list_pages` function and pass in the appropriate dictionary reference."
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 16,
-   "source": [
-    "my_pages = cu.pages.list_pages(flows_map[source_flow])\n",
-    "print('{} Page Count = {}'.format(source_flow, len(my_pages)))"
-   ],
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "stream",
      "name": "stdout",
+     "output_type": "stream",
      "text": [
       "Default Start Flow Page Count = 3\n"
      ]
     }
    ],
-   "metadata": {}
+   "source": [
+    "my_pages = cu.pages.list_pages(flows_map[source_flow])\n",
+    "print('{} Page Count = {}'.format(source_flow, len(my_pages)))"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Extract Subset of Pages To Copy\n",
     "Now that we have `my_pages` (a List of Page objects) we need to extract the subset of pages that we plan on copying over to our `Target` Flow.   \n",
@@ -149,33 +150,33 @@
     "- MyFlow - Page 1\n",
     "- MyFlow - Page 2\n",
     "- MyFlow - Page N"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 17,
-   "source": [
-    "subset_pages = [] # define a list placeholder for your Page proto objects\n",
-    "for page in my_pages:\n",
-    "    if 'MyFlow -' in page.display_name:\n",
-    "        subset_pages.append(page)\n",
-    "\n",
-    "print('Total Pages to Copy = {}'.format(len(subset_pages)))"
-   ],
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "stream",
      "name": "stdout",
+     "output_type": "stream",
      "text": [
       "Total Pages to Copy = 2\n"
      ]
     }
    ],
-   "metadata": {}
+   "source": [
+    "subset_pages = [] # define a list placeholder for your Page proto objects\n",
+    "for page in my_pages:\n",
+    "    if 'MyFlow -' in page.display_name: # Comment out this line to copy the whole flow\n",
+    "        subset_pages.append(page)\n",
+    "\n",
+    "print('Total Pages to Copy = {}'.format(len(subset_pages)))"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Create Page Shells in Target Flow\n",
     "Using the `subset_pages` that we just collected, we will iterate through them and create a \"shell\" page in the `Target` flow.  \n",
@@ -183,22 +184,42 @@
     "\n",
     "Remember to pass in the `Target Flow ID` using the reverse flows map from above.\n",
     "\n",
-    "**NOTE** - If you have a lot of pages, consider a `time.sleep(.5)` in your loop so as to not overrun your API limits!"
-   ],
-   "metadata": {}
+    "**NOTE** - If you have a lot of pages, consider a `time.sleep(.5)` in your loop so as to not overrun your API limits!\n",
+    "\n",
+    "Note that this only needs to be run once. \n",
+    "Once the empty pages have been created in a given flow, they cannot be created again.\n",
+    "Trying to create them again will give an AlreadyExists error."
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 18,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "for page in subset_pages:\n",
     "    cu.pages.create_page(flows_map[target_flow], display_name=page.display_name)"
-   ],
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Copy Route Groups to Target Flow"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
    "outputs": [],
-   "metadata": {}
+   "source": [
+    "cu.copy_route_groups(agent_id, source_flow, target_flow)"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Modify Page Objects\n",
     "In a previous step, we collected the `subset_pages` list of Page objects.  \n",
@@ -221,53 +242,87 @@
     "\n",
     "This will perform a reverse dictionary lookup on all of the previously modified resources using internal map functions and give them their newly assigned UUIDs.   \n",
     "We will store the results in a variable called `final_pages`"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 19,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "# Step 1\n",
     "subset_pages_prepped = cu.convert_from_source_page_dependencies(agent_id, subset_pages, source_flow)\n",
     "\n",
     "# Step 2\n",
     "final_pages = cu.convert_to_destination_page_dependencies(agent_id, subset_pages_prepped, target_flow)"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {
+    "tags": []
+   },
    "source": [
     "# Update Pages in Target Flow\n",
     "Our final step is to loop through our `final_pages` list and call the `update_page` function for each Page in the list.   \n",
     "This will write the modified Page objects to our Dialogflow CX Agent."
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 20,
-   "source": [
-    "for page in final_pages:\n",
-    "    cu.pages.update_page(page.name, page)\n",
-    "    print('Updated Page: {}'.format(page.display_name))"
-   ],
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "stream",
      "name": "stdout",
+     "output_type": "stream",
      "text": [
       "Updated Page: MyFlow - Test2\n",
       "Updated Page: MyFlow - Test1\n"
      ]
     }
    ],
-   "metadata": {}
+   "source": [
+    "for page in final_pages:\n",
+    "    cu.pages.update_page(page.name, page)\n",
+    "    print('Updated Page: {}'.format(page.display_name))"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Update Start Page in Target Flow\n",
+    "\n",
+    "Since the Start Page of a given flow is not a Page object in CX, but rather a Flow object, the above will not copy over the contents of the Start Page. If you are copying an entire flow into another flow, and need to copy the Start Page as well, the `convert_start_page_dependencies` function can be used to copy the Start Page contents."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Get the source and target flow objects\n",
+    "source_flow_obj = cu.flows.get_flow_by_display_name(source_flow, agent_id)\n",
+    "target_flow_obj = cu.flows.get_flow_by_display_name(target_flow, agent_id)\n",
+    "\n",
+    "# Convert resources of start page in source flow to update start page in target flow\n",
+    "converted_source = cu.convert_start_page_dependencies(\n",
+    "    agent_id, source_flow_obj, agent_type='source', flow=source_flow\n",
+    ")\n",
+    "converted_target = cu.convert_start_page_dependencies(\n",
+    "    agent_id, converted_source, agent_type='destination', flow=target_flow\n",
+    ")\n",
+    "\n",
+    "# Update start page in target flow\n",
+    "cu.flows.update_flow(converted_target.name, converted_target)\n",
+    "print('Updated Flow: {}'.format(target_flow))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Final Thoughts and Wrap-Up\n",
     "All of the above could be combined into a single function to streamline the process.  \n",
@@ -275,20 +330,24 @@
     "\n",
     "In addition, other modifications not covered in this tutorial could be applied inline prior to pushing the Page updates in the final step.   \n",
     "As noted at the start, this notebook can also be modified to copy pages _BETWEEN_ agents as well."
-   ],
-   "metadata": {}
+   ]
   }
  ],
  "metadata": {
   "environment": {
-   "name": "common-cpu.mnightly-2021-01-20-debian-10-test",
+   "kernel": "python3",
+   "name": "common-cpu.m96",
    "type": "gcloud",
-   "uri": "gcr.io/deeplearning-platform-release/base-cpu:mnightly-2021-01-20-debian-10-test"
+   "uri": "gcr.io/deeplearning-platform-release/base-cpu:m96"
   },
   "file_extension": ".py",
+  "interpreter": {
+   "hash": "19fe958eff886c70bc7b0837ba1e6b09536c8944c54196036e51b6ba767223fc"
+  },
   "kernelspec": {
-   "name": "python3",
-   "display_name": "Python 3.8.11 64-bit ('scrapi-local': conda)"
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
   },
   "language_info": {
    "codemirror_mode": {
@@ -300,17 +359,14 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.11"
+   "version": "3.7.12"
   },
   "mimetype": "text/x-python",
   "name": "python",
   "npconvert_exporter": "python",
   "pygments_lexer": "ipython3",
-  "version": 3,
-  "interpreter": {
-   "hash": "19fe958eff886c70bc7b0837ba1e6b09536c8944c54196036e51b6ba767223fc"
-  }
+  "version": 3
  },
  "nbformat": 4,
  "nbformat_minor": 4
-}
\ No newline at end of file
+}
diff --git a/examples/google_sheets_series/custom_entities_to_sheets.ipynb b/examples/google_sheets_series/custom_entities_to_sheets.ipynb
new file mode 100644
index 00000000..b8f11c88
--- /dev/null
+++ b/examples/google_sheets_series/custom_entities_to_sheets.ipynb
@@ -0,0 +1,235 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Copyright 2022 Google LLC\n",
+    "#\n",
+    "# Licensed under the Apache License, Version 2.0 (the \"License\");\n",
+    "# you may not use this file except in compliance with the License.\n",
+    "# You may obtain a copy of the License at\n",
+    "#\n",
+    "#     https://www.apache.org/licenses/LICENSE-2.0\n",
+    "#\n",
+    "# Unless required by applicable law or agreed to in writing, software\n",
+    "# distributed under the License is distributed on an \"AS IS\" BASIS,\n",
+    "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n",
+    "# See the License for the specific language governing permissions and\n",
+    "# limitations under the License."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Exporting Custom Entities to a Google Sheet\n",
+    "In this notebook, we will show you how to extract custom entities and synonyms from a Dialogflow CX Agent to a user specified Google Sheet.\n",
+    "\n",
+    "## Prerequisites\n",
+    "- Ensure you have a GCP Service Account key with the following privileges assigned to it, based on your needs:\n",
+    "  - Dialogflow API Reader, use this permission level if you are just exploring the Dialogflow CX resources and developing offline\n",
+    "  - Dialogflow API Admin, use this permissions level if you are planning to Read/Write to your Dialogflow CX Agent and/or resources"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# If you haven't already, make sure you install the `dfcx-scrapi` library\n",
+    "\n",
+    "!pip install dfcx-scrapi --quiet"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Imports\n",
+    "During import, Colab will ask you to auth with your Google credentials.  \n",
+    "The creds are used to access Google Sheets where your training data lives."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from dfcx_scrapi.core.entity_types import EntityTypes\n",
+    "from dfcx_scrapi.tools.dataframe_functions import DataframeFunctions\n",
+    "\n",
+    "import numpy as np\n",
+    "import pandas as pd\n",
+    "from typing import List\n",
+    "\n",
+    "## Below code will auth with Google Drive if you are using a Google Colab environment\n",
+    "# from google.colab import auth\n",
+    "# from google.auth import default\n",
+    "\n",
+    "# # Mount your Google Drive.\n",
+    "# from google.colab import drive\n",
+    "# drive.mount(\"/content/drive\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# User Inputs\n",
+    "In the next section, we will collect runtime variables needed to execute this notebook.   \n",
+    "This should be the only cell of the notebook you need to edit in order for this notebook to run.\n",
+    "\n",
+    "Getting an the training phrsaes data from your existing DFCX agent requires the following information:\n",
+    "- `creds_path`, path to your service account credentials file.\n",
+    "- `agent_id`, your Dialogflow CX Agent ID\n",
+    "- `sheet_name`, the display name of your Google Sheet\n",
+    "- `worksheet_name`, the display name of your Google Worksheet or tab where you want to write your data\n",
+    "- `entity_subset`, (Optional) a List of entity type display names to filter on"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "creds_path = \"<YOUR_CREDS_PATH_HERE>\"\n",
+    "agent_id = \"<YOUR_GCP_PROJECT_ID>\"\n",
+    "sheet_name = \"<YOUR_GOOGLE_SHEET_NAME>\"\n",
+    "worksheet_name = \"<YOUR_GOOGLE_WORKSHEET_NAME>\"\n",
+    "entity_subset = []"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Extract Custom Entities to Google Sheets\n",
+    "Here we will perform the following steps:\n",
+    "1. Instantiate our classes\n",
+    "2. Get the data from our Dialogflow CX Agent\n",
+    "3. Write the data to a Google Sheet"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "f49cd729",
+   "metadata": {},
+   "source": [
+    "### Instantiate Classes"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2022-09-29 15:22:46 INFO     create dfcx creds /Users/pmarlow/eng/creds/nj-pods-dev-pmarlow.json\n"
+     ]
+    }
+   ],
+   "source": [
+    "# Instantiate the core classes\n",
+    "entity_types_instance = EntityTypes(creds_path=creds_path)\n",
+    "\n",
+    "# Instantiate the tools classes\n",
+    "dffx_instance = DataframeFunctions(creds_path=creds_path)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b2488b8c",
+   "metadata": {},
+   "source": [
+    "### Get Entity Types from Agent"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "58d43eba",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "df = entity_types_instance.entity_types_to_df(agent_id)\n",
+    "df"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "cda86aea",
+   "metadata": {},
+   "source": [
+    "### Write to Google Sheets"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "68bbb759",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dffx_instance.dataframe_to_sheets(sheet_name, worksheet_name, df)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Final Thoughts and Wrap-Up\n",
+    "In this notebook, we've provided the simple steps to Export Custom Entities and Synonyms to a Google Sheet for further review.  \n",
+    "\n",
+    "These methods can always be modified to push data to other storage formats like CSV, BigQuery, or other database connections.\n",
+    "\n",
+    "Happy Coding!"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "f93983c0",
+   "metadata": {},
+   "source": []
+  }
+ ],
+ "metadata": {
+  "environment": {
+   "name": "common-cpu.m73",
+   "type": "gcloud",
+   "uri": "gcr.io/deeplearning-platform-release/base-cpu:m73"
+  },
+  "kernelspec": {
+   "display_name": "Python 3.8.12 ('scrapi-local')",
+   "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.8.12"
+  },
+  "vscode": {
+   "interpreter": {
+    "hash": "7413f51bec3c978f0622000f59fce9c6157925f7fef8079c4a26e2f8ecdaa214"
+   }
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/intent_analysis/compare_intent_tp_data.ipynb b/examples/nlu_analysis_series/intent_levenshtein_demo.ipynb
similarity index 97%
rename from examples/intent_analysis/compare_intent_tp_data.ipynb
rename to examples/nlu_analysis_series/intent_levenshtein_demo.ipynb
index 717b7dc3..72236033 100644
--- a/examples/intent_analysis/compare_intent_tp_data.ipynb
+++ b/examples/nlu_analysis_series/intent_levenshtein_demo.ipynb
@@ -25,8 +25,8 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "# Introduction\n",
-    "In this notebook, we will show you how to evaluate the levenshtein ration between the training phrases of two intents.\n",
+    "# Intent Analysis Using Levenshtein Ratios\n",
+    "In this notebook, we will show you how to evaluate the levenshtein ratio between the training phrases of two intents.\n",
     "\n",
     "## Prerequisites\n",
     "- Ensure you have a GCP Service Account key with the Dialogflow API Admin privileges assigned to it."
@@ -252,7 +252,7 @@
    "uri": "gcr.io/deeplearning-platform-release/base-cpu:m73"
   },
   "kernelspec": {
-   "display_name": "Python 3",
+   "display_name": "Python 3.9.6 64-bit",
    "language": "python",
    "name": "python3"
   },
@@ -266,7 +266,12 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.7.10"
+   "version": "3.9.6"
+  },
+  "vscode": {
+   "interpreter": {
+    "hash": "31f2aee4e71d21fbe5cf8b01ff0e069b9275f58929596ceb00d14d90e3e16cd6"
+   }
   }
  },
  "nbformat": 4,
diff --git a/examples/nlu_analysis_series/nlu_semantic_similarity_demo.ipynb b/examples/nlu_analysis_series/nlu_semantic_similarity_demo.ipynb
new file mode 100644
index 00000000..00581b63
--- /dev/null
+++ b/examples/nlu_analysis_series/nlu_semantic_similarity_demo.ipynb
@@ -0,0 +1,288 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Copyright 2022 Google LLC\n",
+    "#\n",
+    "# Licensed under the Apache License, Version 2.0 (the \"License\");\n",
+    "# you may not use this file except in compliance with the License.\n",
+    "# You may obtain a copy of the License at\n",
+    "#\n",
+    "#     https://www.apache.org/licenses/LICENSE-2.0\n",
+    "#\n",
+    "# Unless required by applicable law or agreed to in writing, software\n",
+    "# distributed under the License is distributed on an \"AS IS\" BASIS,\n",
+    "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n",
+    "# See the License for the specific language governing permissions and\n",
+    "# limitations under the License."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Semantic Similarity Analysis of Dialogflow CX Pages and Ad Hoc Input Data\n",
+    "In this notebook, we will show you how to uses NLU sentence embeddings to determine how similar different utterances are. We use this information to perform the following analyses:\n",
+    "\n",
+    "* Find similar training phrases in different intents that will cause confusion for the NLU model.\n",
+    "* Identify the most similar training phrases for a user-supplied set of utterances. This will explain where incorrect predictions are coming from on an eval set.\n",
+    "* Identify clusters of utterances that are unlike any of the phrases in the training data. This can be used to search through utterances that produced NO_MATCH in the logs and identify missing intents/training phrases.\n",
+    "\n",
+    "## Prerequisites\n",
+    "- Ensure you have a GCP Service Account key with the Dialogflow API Admin privileges assigned to it.\n",
+    "\n",
+    "## NOTE!\n",
+    "_This colab notebook was intended to run on [Google Colab](https://colab.sandbox.google.com/) infrastrucutre due to the `scann` library dependency._"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# If you haven't already, make sure you install the `dfcx-scrapi` library\n",
+    "\n",
+    "!pip install dfcx-scrapi\n",
+    "!pip install scann"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Imports\n",
+    "During import, Colab will ask you to auth with your Google credentials.  \n",
+    "The creds are used to access Google Sheets where your training data lives."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import gspread\n",
+    "from google.colab import auth\n",
+    "from google.auth import default\n",
+    "\n",
+    "from dfcx_scrapi.tools.nlu_util import KonaEmbeddingModel, SheetsLoader, NaturalLanguageUnderstandingUtil\n",
+    "\n",
+    "from google.colab import drive\n",
+    "drive.mount('/content/drive')\n",
+    "\n",
+    "auth.authenticate_user()\n",
+    "creds, _ = default()\n",
+    "\n",
+    "gc = gspread.authorize(creds)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# User Inputs\n",
+    "In the next section, we will collect runtime variables needed to execute this notebook.   \n",
+    "This should be the only cell of the notebook you need to edit in order for this notebook to run.\n",
+    "\n",
+    "Getting an the training phrsaes data from your existing DFCX agent requires the following information:\n",
+    "- `creds_path`, path to your service account credentials file.\n",
+    "- `agent_id`, which is your GCP agent ID.\n",
+    "- `flow_display_name`, the Display Name of the Flow to use\n",
+    "- `page_display_name`, the Display Name of the Page to use"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "creds_path = '<PATH_TO_YOUR_CREDS_FILE>'\n",
+    "agent_id = '<YOUR_AGENT_ID>'\n",
+    "flow_display_name = '<YOUR_DFCX_FLOW_DISPLAY_NAME>'\n",
+    "page_display_name = '<YOUR_DFCX_PAGE_DISPLAY_NAME>'"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Load Agent\n",
+    "First, we will instantiate our `embedder` by loading in Agent, Flow, and Page information."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "embedder = NaturalLanguageUnderstandingUtil(agent_id, flow_display_name, page_display_name, creds_path)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Analysis 1: Find conflicting training phrases\n",
+    "\n",
+    "This analysis identifies pairs of training phrases in different intents that are confusing for the NLU model.\n",
+    "\n",
+    "We recommend reviewing all training phrases with a similarity above 0.9. In most cases the conflicts should be resolved by deleting one of the training phrases.\n",
+    "\n",
+    "No additional inputs are needed to run this analysis. Just run this cell after loading the agent."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "similar_df = embedder.find_similar_training_phrases_in_different_intents()\n",
+    "similar_df"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e35360e9",
+   "metadata": {},
+   "source": [
+    "# Analysis 2: Find the most similar training phrases for a set of utterances\n",
+    "\n",
+    "This analysis finds the most similar training phrases in the agent data for a set of utterances. \n",
+    "\n",
+    "Example use cases:\n",
+    "* Identifying why utterances in an eval set were classified as some intent.\n",
+    "* Looking for any similar training phrases for a list of utterances from the logs.\n",
+    "\n",
+    "We read the utterances from a Google Sheet. That sheet must be shared with your service account creds file that was provided in this notebook.\n",
+    "\n",
+    "We'll collect the following input arguments to access your Google Sheet:\n",
+    "* `sheet_name`, the display name of your Google Sheet\n",
+    "* `worksheet_name`, the display name of the Worksheet or tab where your data lives\n",
+    "* `utterance_column_name`, the name of the column where your Utterance data lives\n",
+    "\n",
+    "Input data can be a single column of utterances like:\n",
+    "|  utterances   |\n",
+    "| --- | \n",
+    "| new york |\n",
+    "| big apple |\n",
+    "| I like traveling to nyc |\n",
+    "| some people call it the big apple |"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "aa387eba",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sheet_name = \"<YOUR_GOOGLE_SHEET_NAME>\"\n",
+    "worksheet_name = \"<YOUR_GOOGLE_SHEET_WORKSHEET_NAME>\"\n",
+    "utterance_column_name = \"utterances\"\n",
+    "\n",
+    "sheet_loader = SheetsLoader(creds_path)\n",
+    "utterances = sheet_loader.load_column_from_sheet(sheet_name, worksheet_name, utterance_column_name).astype(str)\n",
+    "embedder.find_similar_phrases(utterances)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "74cda60e",
+   "metadata": {},
+   "source": [
+    "# Analysis 3: Find clusters of utterances that don't match training phrases\n",
+    "\n",
+    "This analysis finds clusters of utterances that don't match any training phrases. Example use cases:\n",
+    "\n",
+    "* Running on a set of utterances that were labeled NO_MATCH in logs. To fix any entries that show up, consider:\n",
+    "  * Adding the identified utterances as training phrases in an existing intent (the most similar intent will be displayed).\n",
+    "  * Adding new intents.\n",
+    "  * Enabling inactive intents.\n",
+    "* Running on a set of eval/log utterances to identify regions where an intent is lacking training phrases.\n",
+    "  * To fix any entries that show up, we recommend adding the identified utterances as training phrases.\n",
+    "\n",
+    "We read the utterances from a Google Sheet. That sheet must be shared with your service account creds file that was provided in this notebook.\n",
+    "\n",
+    "We'll collect the following input arguments to access your Google Sheet:\n",
+    "* `sheet_name`, the display name of your Google Sheet\n",
+    "* `worksheet_name`, the display name of the Worksheet or tab where your data lives\n",
+    "* `utterance_column_name`, the name of the column where your Utterance data lives\n",
+    "\n",
+    "Input data can be a single column of utterances like:\n",
+    "|  no_match_utterances   |\n",
+    "| --- | \n",
+    "| new york |\n",
+    "| big apple |\n",
+    "| I like traveling to nyc |\n",
+    "| some people call it the big apple |"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c19fe87c",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sheet_name = \"<YOUR_GOOGLE_SHEET_NAME>\"\n",
+    "worksheet_name = \"<YOUR_GOOGLE_SHEET_WORKSHEET_NAME>\"\n",
+    "utterance_column_name = \"no_match_utterances\"\n",
+    "\n",
+    "sheet_loader = SheetsLoader(creds_path)\n",
+    "utterances = sheet_loader.load_column_from_sheet(sheet_name, \n",
+    "                                                 worksheet_name, \n",
+    "                                                 utterance_column_name).astype(str)\n",
+    "embedder.find_new_groups(utterances)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Final Thoughts and Wrap-Up\n",
+    "In this notebook, we've shown you how to uses NLU sentence embeddings to determine how similar different utterances are.\n",
+    "\n",
+    "For further instruction, please contact: [cgibson6279](https://github.com/cgibson6279)"
+   ]
+  }
+ ],
+ "metadata": {
+  "environment": {
+   "name": "common-cpu.m73",
+   "type": "gcloud",
+   "uri": "gcr.io/deeplearning-platform-release/base-cpu:m73"
+  },
+  "kernelspec": {
+   "display_name": "Python 3.9.6 64-bit",
+   "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.9.6"
+  },
+  "vscode": {
+   "interpreter": {
+    "hash": "31f2aee4e71d21fbe5cf8b01ff0e069b9275f58929596ceb00d14d90e3e16cd6"
+   }
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/scrapi_prebuilt.ipynb b/examples/scrapi_prebuilt.ipynb
new file mode 100644
index 00000000..0e811663
--- /dev/null
+++ b/examples/scrapi_prebuilt.ipynb
@@ -0,0 +1,369 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Copyright 2022 Google LLC\n",
+    "#\n",
+    "# Licensed under the Apache License, Version 2.0 (the \"License\");\n",
+    "# you may not use this file except in compliance with the License.\n",
+    "# You may obtain a copy of the License at\n",
+    "#\n",
+    "#     https://www.apache.org/licenses/LICENSE-2.0\n",
+    "#\n",
+    "# Unless required by applicable law or agreed to in writing, software\n",
+    "# distributed under the License is distributed on an \"AS IS\" BASIS,\n",
+    "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n",
+    "# See the License for the specific language governing permissions and\n",
+    "# limitations under the License."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# SCRAPI Prebuilt Development Notebook\n",
+    "In this notebook, we provide a launching point for developement on any Dialogflow CX Agent.\n",
+    "\n",
+    "We will show you how to load all of the Agent Resources so that you can begin development on any area of your Agent.  \n",
+    "The motivation behind this notebook is to provide a checkpoint for you to start any DFCX development or scripting project.\n",
+    "\n",
+    "## Prerequisites\n",
+    "- Ensure you have a GCP Service Account key with the following privileges assigned to it, based on your needs:\n",
+    "  - Dialogflow API Reader, use this permission level if you are just exploring the Dialogflow CX resources and developing offline\n",
+    "  - Dialogflow API Admin, use this permissions level if you are planning to Read/Write to your Dialogflow CX Agent and/or resources"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# If you haven't already, make sure you install the `dfcx-scrapi` library\n",
+    "\n",
+    "!pip install dfcx-scrapi --quiet"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Imports\n",
+    "During import, Colab will ask you to auth with your Google credentials.  \n",
+    "The creds are used to access Google Sheets where your training data lives."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import os\n",
+    "import time\n",
+    "\n",
+    "import numpy as np\n",
+    "import pandas as pd\n",
+    "\n",
+    "from dfcx_scrapi.core.agents import Agents\n",
+    "from dfcx_scrapi.core.flows import Flows\n",
+    "from dfcx_scrapi.core.intents import Intents\n",
+    "from dfcx_scrapi.core.pages import Pages\n",
+    "from dfcx_scrapi.core.webhooks import Webhooks\n",
+    "from dfcx_scrapi.core.entity_types import EntityTypes\n",
+    "from dfcx_scrapi.core.environments import Environments\n",
+    "from dfcx_scrapi.core.transition_route_groups import TransitionRouteGroups\n",
+    "from dfcx_scrapi.tools.dataframe_functions import DataframeFunctions\n",
+    "\n",
+    "from google.cloud.dialogflowcx_v3beta1 import types\n",
+    "\n",
+    "## -- Uncomment the below section if you want to auth with Google Drive -- ##\n",
+    "# from google.colab import auth\n",
+    "# from google.auth import default\n",
+    "\n",
+    "# # Mount your Google Drive.\n",
+    "# from google.colab import drive\n",
+    "# drive.mount(\"/content/drive\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# User Inputs\n",
+    "In the next section, we will collect runtime variables needed to execute this notebook.   \n",
+    "This should be the only cell of the notebook you need to edit in order for this notebook to run.\n",
+    "\n",
+    "Getting an the training phrsaes data from your existing DFCX agent requires the following information:\n",
+    "- `creds_path`, path to your service account credentials file.\n",
+    "- `project_id`, your GCP project ID\n",
+    "- `agent_display_name`, the display name of your Dialogflow CX Agent\n",
+    "- `gcp_region`, (Optional) the GCP Region your Dialogflow CX Agent resides in\n",
+    "- `gcs_bucket_uri`, (Optional) a GCS bucket used to store data for your Dialogflow CX Agent"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "creds_path = \"<YOUR_CREDS_PATH_HERE>\"\n",
+    "project_id = \"<YOUR_GCP_PROJECT_ID>\"\n",
+    "agent_display_name = \"<YOUR_AGENT_DISPLAY_NAME>\"\n",
+    "gcp_region = \"<GCP_REGION>\" # Optional\n",
+    "gcs_bucket_uri = \"<GCS_BUCKET_URI>\" # Optional\n",
+    "\n",
+    "os.environ[\"GOOGLE_APPLICATION_CREDENTIALS\"] = creds_path"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Load Agent\n",
+    "First, we will instantiate our core classes."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 12,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2022-09-29 15:01:46 INFO     create dfcx creds /Users/pmarlow/eng/creds/nj-pods-dev-pmarlow.json\n"
+     ]
+    }
+   ],
+   "source": [
+    "# Instantiate the core classes\n",
+    "agents_instance = Agents(creds_path=creds_path)\n",
+    "flows_instance = Flows(creds_path=creds_path)\n",
+    "intents_instance = Intents(creds_path=creds_path)\n",
+    "pages_instance = Pages(creds_path=creds_path)\n",
+    "entity_types_instance = EntityTypes(creds_path=creds_path)\n",
+    "webhooks_instance = Webhooks(creds_path=creds_path)\n",
+    "environments_instance = Environments(creds_path=creds_path)\n",
+    "route_groups_instance = TransitionRouteGroups(creds_path=creds_path)\n",
+    "\n",
+    "# Instantiate the tools classes\n",
+    "dffx_instance = DataframeFunctions(creds_path=creds_path)\n",
+    "\n",
+    "# Get the Agent and its ID\n",
+    "my_agent = agents_instance.get_agent_by_display_name(\n",
+    "    project_id=project_id, display_name=agent_display_name\n",
+    ")\n",
+    "\n",
+    "agent_id = my_agent.name"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "46f6ac36",
+   "metadata": {},
+   "source": [
+    "# Load Agent Components\n",
+    "In this section, we'll load up all of the various Agent Resource components like Intents, Flows, Pages, etc."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "92f80f60",
+   "metadata": {},
+   "source": [
+    "## Load Flows and Pages"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 15,
+   "id": "3ff7d2a7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Load the flows\n",
+    "all_flows = flows_instance.list_flows(agent_id=agent_id)\n",
+    "\n",
+    "flows_map = flows_instance.get_flows_map(agent_id=agent_id)\n",
+    "flows_map_reverse = flows_instance.get_flows_map(\n",
+    "    agent_id=agent_id, reverse=True\n",
+    ")\n",
+    "\n",
+    "# Load the pages\n",
+    "all_pages_per_flow = {}\n",
+    "\n",
+    "pages_map_per_flow = {}\n",
+    "pages_map_per_flow_reverse = {}\n",
+    "\n",
+    "for flow_name, flow_id in flows_map_reverse.items():\n",
+    "    all_pages_per_flow[flow_name] = pages_instance.list_pages(flow_id=flow_id)\n",
+    "    time.sleep(1)\n",
+    "    \n",
+    "    pages_map_per_flow[flow_name] = pages_instance.get_pages_map(\n",
+    "        flow_id=flow_id\n",
+    "    )\n",
+    "    time.sleep(1)\n",
+    "    pages_map_per_flow_reverse[flow_name] = pages_instance.get_pages_map(\n",
+    "        flow_id=flow_id, reverse=True\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e1b9d427",
+   "metadata": {},
+   "source": [
+    "## Load Intents and Entity Types"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 16,
+   "id": "0b75f1eb",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Load the intents\n",
+    "all_intents = intents_instance.list_intents(agent_id=agent_id)\n",
+    "\n",
+    "intents_map = intents_instance.get_intents_map(agent_id=agent_id)\n",
+    "intents_map_reverse = intents_instance.get_intents_map(\n",
+    "    agent_id=agent_id, reverse=True\n",
+    ")\n",
+    "\n",
+    "# Load the entity types\n",
+    "all_entity_types = entity_types_instance.list_entity_types(agent_id=agent_id)\n",
+    "\n",
+    "entity_types_map = entity_types_instance.get_entities_map(agent_id=agent_id)\n",
+    "entity_types_map_reverse = entity_types_instance.get_entities_map(\n",
+    "    agent_id=agent_id, reverse=True\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "32165dbe",
+   "metadata": {},
+   "source": [
+    "## Load Webhooks, Environments, and Transition Route Groups"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 17,
+   "id": "ead728ad",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Load the webhooks\n",
+    "all_webhooks = webhooks_instance.list_webhooks(agent_id=agent_id)\n",
+    "\n",
+    "webhooks_map = webhooks_instance.get_webhooks_map(agent_id=agent_id)\n",
+    "webhooks_map_reverse = webhooks_instance.get_webhooks_map(\n",
+    "    agent_id=agent_id, reverse=True\n",
+    ")\n",
+    "\n",
+    "# Load the environments\n",
+    "all_environments = environments_instance.list_environments(agent_id=agent_id)\n",
+    "\n",
+    "environments_map = environments_instance.get_environments_map(agent_id=agent_id)\n",
+    "environments_map_reverse = environments_instance.get_environments_map(\n",
+    "    agent_id=agent_id, reverse=True\n",
+    ")\n",
+    "\n",
+    "# Load the transition route groups\n",
+    "all_route_groups_per_flow = {}\n",
+    "\n",
+    "route_groups_map_per_flow = {}\n",
+    "route_groups_map_per_flow_reverse = {}\n",
+    "\n",
+    "for flow_name, flow_id in flows_map_reverse.items():\n",
+    "    route_groups_list = route_groups_instance.list_transition_route_groups(\n",
+    "        flow_id=flow_id\n",
+    "    )\n",
+    "    all_route_groups_per_flow[flow_name] = route_groups_list\n",
+    "    time.sleep(1)\n",
+    "    \n",
+    "    route_groups_map = route_groups_instance.get_route_groups_map(\n",
+    "        flow_id=flow_id\n",
+    "    )\n",
+    "    route_groups_map_per_flow[flow_name] = route_groups_map\n",
+    "    time.sleep(1)\n",
+    "    route_groups_map_reverse = route_groups_instance.get_route_groups_map(\n",
+    "        flow_id=flow_id, reverse=True\n",
+    "    )\n",
+    "    route_groups_map_per_flow_reverse[flow_name] = route_groups_map_reverse"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "9c9ac065",
+   "metadata": {},
+   "source": [
+    "# Start Coding!\n",
+    "Now that we have all of our Agent Resources loaded, you can start exploring them in many ways below.  "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 18,
+   "id": "a673c39a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# < - - Amazing Code Goes Here! -- > "
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Final Thoughts and Wrap-Up\n",
+    "In this notebook, we've provided a quick start template to load all of your Dialogflow CX Agent Resources so you can start exploring, scripting, and building prodution level bots!\n",
+    "\n",
+    "Feel free to import more classes and try them too!  \n",
+    "We suggest checking out the `tools/` directory for other great things you can do with SCRAPI!\n",
+    "\n",
+    "Happy Coding!"
+   ]
+  }
+ ],
+ "metadata": {
+  "environment": {
+   "name": "common-cpu.m73",
+   "type": "gcloud",
+   "uri": "gcr.io/deeplearning-platform-release/base-cpu:m73"
+  },
+  "kernelspec": {
+   "display_name": "Python 3.8.12 ('scrapi-local')",
+   "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.8.12"
+  },
+  "vscode": {
+   "interpreter": {
+    "hash": "7413f51bec3c978f0622000f59fce9c6157925f7fef8079c4a26e2f8ecdaa214"
+   }
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/setup.py b/setup.py
index d01ec4b7..2f2d6442 100644
--- a/setup.py
+++ b/setup.py
@@ -23,7 +23,7 @@
 
 setup(
     name='dfcx-scrapi',
-    version='1.3.0',
+    version='1.5.1',
     description='A high level scripting API for bot builders, developers, and\
       maintainers.',
     long_description=long_description,
diff --git a/src/agent_assist/__init__.py b/src/agent_assist/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/agent_assist/agent_assist.py b/src/agent_assist/agent_assist.py
new file mode 100644
index 00000000..1c015b45
--- /dev/null
+++ b/src/agent_assist/agent_assist.py
@@ -0,0 +1,550 @@
+"""Agent Assist Resource functions."""
+
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+
+from dfcx_scrapi.core import scrapi_base
+from typing import Dict
+from google.cloud.dialogflow_v2beta1 import services
+from google.cloud.dialogflow_v2beta1 import types
+from google.cloud.dialogflow_v2beta1.services.knowledge_bases import KnowledgeBasesClient
+from google.protobuf import field_mask_pb2
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+
+class AgentAssist(scrapi_base.ScrapiBase):
+    """Core Class for Agent Assist Resource functions."""
+
+    def __init__(
+        self,
+        creds_path: str = None,
+        creds_dict: Dict = None,
+        creds=None,
+        scope=False,
+        conversation_profile_id: str = None,
+    ):
+        super().__init__(
+            creds_path=creds_path,
+            creds_dict=creds_dict,
+            creds=creds,
+            scope=scope,
+        )
+        self.conversation_profile_id = None
+
+        if conversation_profile_id:
+            self.conversation_profile_id = conversation_profile_id
+
+    def _set_conversation_profile(
+        self,
+        conversation_profile_id: str,
+        project_id: str = None
+    ):
+        """Updating the conversation profile for the object
+        Args:
+            conversation_profile_id (reqd):
+                Unique identifier for the conversation profile.
+            project_id (str):
+                Optional Field. Required if the full conv path is not supplied.
+        """
+        logging.info(f"Setting Conversation Profile: {conversation_profile_id}")
+
+        if len(conversation_profile_id.split("/")) >= 4:
+            profile_path = conversation_profile_id
+        else:
+            logging.info("Inferring Profile Path using the Project ID")
+            if not project_id:
+                raise ValueError(
+                    "Project ID is required if the full conevrsation profile"
+                    " path is not provided."
+                )
+            project_path = f"projects/{project_id}"
+            conv_path = f"/conversationProfiles/{conversation_profile_id}"
+            profile_path = project_path + conv_path
+
+        logging.info(f"Conversation Profile Path :{profile_path}")
+        self.conversation_profile_id = profile_path
+
+    def create_conversation_profile(
+        self,
+        display_name,
+        knowledge_base_id: str,
+        project_id,
+        suggestion_type: str,
+        no_small_talk: bool = True,
+        only_end_user: bool = True,
+        max_results: int = 3,
+        language_code: str = "en-US") -> types.ConversationProfile:
+        """ Create & configure a conversation profile as per the given input.
+
+        Args:
+            display_name (str):
+                Required. Human readable name for this
+                profile. Max length 1024 bytes.
+            project_id (str):
+                Required. The project to create a conversation profile for.
+            knowledge_base_id (str):
+                The knowledge base id of the knowledge base to be configured
+                with the conversation profile.
+            suggestion_type (str):
+                The agent assist feature to be configured with the
+                conversation profile.
+                Allowed values are: ARTICLE_SUGGESTION, FAQ & SMART_REPLY.
+            no_small_talk (bool):
+                Do not trigger if last utterance is small talk.
+            only_end_user (bool):
+                Only trigger suggestion if participant role of last
+                utterance is END_USER.
+            max_results (int):
+                Maximum number of results to return.
+                Currently, if unset, defaults to 10. And the max number is 20.
+            language_code (str):
+                Language code for the conversation profile. If not
+                specified, the language is en-US. Language at
+                ConversationProfile should be set for all non en-us
+                languages. This should be a
+                `BCP-47 <https://www.rfc-editor.org/rfc/bcp/bcp47.txt>`__
+                language tag. Example: "en-US".
+
+        Returns:
+            Conversation profile to be used for incoming
+            Dialogueflow conversations.
+            type: google.cloud.dialogflow_v2beta1.types.ConversationProfile
+        """
+
+        client = services.conversation_profiles.ConversationProfilesClient(
+            credentials = self.creds
+            )
+
+        project_path = client.common_project_path(project_id)
+        logging.info(f"Project Path : {project_path}")
+
+        conversation_profile = types.ConversationProfile()
+        conversation_profile.display_name = display_name
+        conversation_profile.language_code = language_code
+
+        hac_config = types.HumanAgentAssistantConfig
+        feature_config = hac_config.SuggestionFeatureConfig()
+
+        # Configuring Suggestion Type
+        suggestion_feature = types.SuggestionFeature()
+        suggestion_feature.type_ = suggestion_type
+
+        feature_config.suggestion_feature = suggestion_feature
+
+        # Configuring the trigger settings
+        trigger_settings = hac_config.SuggestionTriggerSettings()
+        trigger_settings.no_small_talk = no_small_talk
+        trigger_settings.only_end_user = only_end_user
+
+        feature_config.suggestion_trigger_settings = trigger_settings
+
+        # Configuring the query config.
+        query_config = hac_config.SuggestionQueryConfig()
+
+        kb_path = KnowledgeBasesClient.knowledge_base_path(
+            project_id, knowledge_base_id
+            )
+        logging.info(f"Knowledge Base Path : {kb_path}")
+        query_config.knowledge_base_query_source.knowledge_bases=[kb_path]
+
+        query_config.max_results = max_results
+        feature_config.query_config = query_config
+
+        conversation_profile.human_agent_assistant_config.human_agent_suggestion_config.feature_configs = [feature_config] # pylint: disable=C0301
+
+        request = types.conversation_profile.CreateConversationProfileRequest(
+                    parent= project_path,
+                    conversation_profile=conversation_profile,
+                )
+
+        # Make the request
+        response = client.create_conversation_profile(request=request)
+        logging.info("Conversation Profile created...")
+        logging.info(f"Display Name: {response.display_name}")
+        logging.info(f"Name: {response.name}")
+
+        return response
+
+    def get_conversation_profile(
+        self,
+        conversation_profile_id: str = None
+    ) -> types.ConversationProfile:
+        """Returns the conversation profile object
+        Args:
+            conversation_profile_id: str
+                Optional Field. The profile id for requested object.
+
+        Returns:
+            Conversation profile Object.
+            type: google.cloud.dialogflow_v2beta1.types.ConversationProfile
+        """
+        client = services.conversation_profiles.ConversationProfilesClient(
+            credentials = self.creds
+            )
+        if not conversation_profile_id:
+            conversation_profile_id = self.conversation_profile_id
+
+        request = types.conversation_profile.GetConversationProfileRequest(
+                    name=conversation_profile_id,
+                )
+
+        response = client.get_conversation_profile(request=request)
+
+        return response
+
+    def list_conversation_profiles(
+        self,
+        project_id: str
+    ):
+        """List all the existing conversation profile in the given project
+        Args:
+            project_id :
+            Required. The project to list the conversation profile for.
+
+        Returns:
+            List of tuples [(profile display name, profile name)]
+        """
+        client = services.conversation_profiles.ConversationProfilesClient(
+            credentials = self.creds
+            )
+
+        project_path = client.common_project_path(project_id)
+        logging.info(f"Project Path : {project_path}")
+
+        response = client.list_conversation_profiles(parent = project_path)
+
+        profile_list = []
+        for profile in response:
+            profile_list.append((profile.display_name, profile.name))
+
+        return profile_list
+
+    def delete_conversation_profile(
+        self,
+        conversation_profile_id: str
+    ):
+        """Delete the given conversation profile
+        Args:
+            conversation_profile_id:
+                The unique identifier of this conversation profile. Format:
+                ``projects/<Project ID>/locations/<Location ID>
+                  /conversationProfiles/<Conversation Profile ID>``.
+        """
+        client = services.conversation_profiles.ConversationProfilesClient(
+            credentials = self.creds
+            )
+
+        logging.info(f"Deleting : {conversation_profile_id}")
+
+        client.delete_conversation_profile(name = conversation_profile_id)
+
+    def update_conversation_profile(
+        self,
+        conversation_profile_id: str = None,
+        obj: types.ConversationProfile = None,
+        **kwargs
+    ) -> types.ConversationProfile:
+        """
+        Updates the conversation profile object based on the provided args.
+        Args:
+            conversation_profile_id: str
+                The unique identifier of this conversation profile.
+                Format:
+                ``projects/<Project ID>/locations/<Location ID>/
+                  conversationProfiles/<Conversation Profile ID>``.
+            obj: The Conversation Profile object to be updated.
+
+        Returns:
+            The updated conversation profile object.
+        """
+        if obj:
+            conversation_profile = obj
+            conversation_profile.name = conversation_profile_id
+
+        else:
+            if not conversation_profile_id:
+                conversation_profile_id = self.conversation_profile_id
+
+            conversation_profile = self.get_conversation_profile(
+                conversation_profile_id = conversation_profile_id
+                )
+
+        # set conversation profile attributes from kwargs
+        for key, value in kwargs.items():
+            setattr(conversation_profile, key, value)
+            logging.info(f"Updating field {key} to {value}")
+
+        paths = kwargs.keys()
+        mask = field_mask_pb2.FieldMask(paths=paths)
+
+        client = services.conversation_profiles.ConversationProfilesClient(
+            credentials = self.creds
+            )
+
+        request = types.UpdateConversationProfileRequest()
+
+        request.conversation_profile = conversation_profile
+        request.update_mask = mask
+
+        response = client.update_conversation_profile(request)
+
+        return response
+
+    def create_conversation(
+        self,
+        project_id: str,
+        conversation_profile_id: str = None
+        ) -> types.Conversation:
+        """Creates a conversation with given values
+
+        Args:
+            project_id (str):
+                Required. The project to create a conversation profile for.
+            conversation_profile_id (str):
+                Optional: If not set before, user can pass a profile id to
+                be used for creating a conversation.
+
+        Returns:
+            Response for the created conversation.
+            type: google.cloud.dialogflow_v2beta1.types.Conversation
+        """
+
+        client = services.conversations.ConversationsClient(
+            credentials = self.creds
+            )
+
+        if conversation_profile_id:
+            self._set_conversation_profile(
+                conversation_profile_id = conversation_profile_id,
+                project_id = project_id
+                )
+
+        if not self.conversation_profile_id and not conversation_profile_id:
+            raise ValueError(
+                "Conversation Profile ID required, as it isn't set for this"
+                " instance of the AgentAssist class."
+            )
+
+        project_path = client.common_project_path(project_id)
+
+        conversation = {"conversation_profile": self.conversation_profile_id}
+        response =  client.create_conversation(
+            parent=project_path,
+            conversation=conversation
+            )
+
+        logging.info(f"Conversation Created: {response.name}")
+
+        return response
+
+    def complete_conversation(
+        self,
+        conversation_id
+        ) -> types.Conversation:
+        """Completes the specified conversation.
+            Finished conversations are purged from the database after 30 days.
+
+        Args:
+            conversation_id (str)
+                The conversation id that needs to be completed.
+
+        Returns:
+            Response for the completed conversation.
+            type: google.cloud.dialogflow_v2beta1.types.Conversation
+        """
+
+        client = services.conversations.ConversationsClient(
+            credentials = self.creds
+            )
+        conversation =  client.complete_conversation(
+            name = conversation_id
+            )
+        logging.info(f"Closed Conversation {conversation_id}")
+
+        return conversation
+
+    def create_participant(
+        self,
+        conversation_id,
+        role) -> types.Participant:
+        """Creates a participant in a given conversation.
+
+        Args:
+            conversation_id (str):
+                Required. The id of the conversation to which to
+                add the participant.
+            role (str):
+                The role of the added participant.
+                Allowed Values: HUMAN_AGENT, AUTOMATED_AGENT, END_USER
+
+        Returns:
+            Response for the created participant.
+            type: google.cloud.dialogflow_v2beta1.types.Participant
+        """
+
+        client = services.participants.ParticipantsClient(
+            credentials=self.creds
+            )
+
+        response = client.create_participant(
+            parent = conversation_id,
+            participant = {"role": role},
+            timeout=600
+            )
+
+        logging.info(f"Participant created for the role {role}")
+        logging.info(f"Participant Name: {response.name}")
+
+        return response
+
+    def list_participants(
+        self,
+        conversation_id: str
+    ):
+        """list all the participants added to a coversation
+        Args:
+        conversation_id (str):
+                Required. The conversation to list all participants
+                from. Format:
+                ``projects/<Project ID>/locations/<Location ID>/conversations
+                /<Conversation ID>``.
+        Returns:
+            A dict object with the participant information:
+            {participant role: participant name}
+        """
+        client = services.participants.ParticipantsClient(
+            credentials=self.creds
+            )
+        response = client.list_participants(parent = conversation_id)
+
+        participant_dict = {}
+        for participant in response:
+            participant_dict[participant.role.name] = participant.name
+
+        return participant_dict
+
+    def get_participant(
+        self,
+        participant_id: str
+    ) -> types.Participant:
+        """Returns the Participant object for the given participant name
+        Args:
+            participant_id: str
+                The participant id for the requested object.
+                ``projects/<Project ID>/locations/<Location ID>/
+                conversations/<Conv. ID>/participants/<Participant ID>``.
+        """
+        client = services.participants.ParticipantsClient(
+            credentials = self.creds
+            )
+
+        request = types.GetParticipantRequest(
+                    name=participant_id,
+                )
+
+        response = client.get_participant(request=request)
+
+        return response
+
+    def update_participant(
+        self,
+        participant_id: str = None,
+        obj: types.Participant = None,
+        **kwargs
+    ) -> types.Participant:
+        """
+        Updates the participant object based on the provided args.
+        Args:
+            participant_id: str
+                Full id of the participant to be updated.
+            obj:
+                The participant object to be updated.
+        """
+        if obj:
+            participant = obj
+            participant.name = participant_id
+
+        else:
+            if not participant_id:
+                raise ValueError(
+                    "Atleast one of participant object or participant "
+                    "name is required to make the update."
+                )
+
+            participant = self.get_participant(
+                participant_id = participant_id
+                )
+
+        # set participant attributes from kwargs
+        for key, value in kwargs.items():
+            setattr(participant, key, value)
+
+        paths = kwargs.keys()
+        mask = field_mask_pb2.FieldMask(paths=paths)
+
+        client = services.participants.ParticipantsClient(
+            credentials = self.creds
+            )
+
+        request = types.UpdateParticipantRequest()
+
+        request.participant = participant
+        request.update_mask = mask
+
+        response = client.update_participant(request = request)
+
+        return response
+
+    def analyze_content_text(
+        self,
+        text,
+        participant_id,
+        language_code = "en-US"
+        ) -> types.AnalyzeContentResponse:
+        """Analyze text message content from a participant.
+
+        Args:
+            text (str):
+                The input text string that needs to be analyzed.
+            participant (str):
+                The participant id of the participant for the
+                corresponding text utterance being analyzed.
+            language_code (str):
+                Language of the inputted text.
+
+        Returns:
+            Response message from the analyze content.
+            type: google.cloud.dialogflow.v2beta1.Participants.AnalyzeContent
+        """
+
+        client = services.participants.ParticipantsClient(
+            credentials= self.creds
+            )
+        text_input = {"text": text, "language_code": language_code}
+
+        logging.info("Running analyze content...")
+        response = client.analyze_content(
+            participant = participant_id,
+            text_input = text_input
+            )
+
+        return response
diff --git a/src/dfcx_scrapi/builders/agents.py b/src/dfcx_scrapi/builders/agents.py
new file mode 100644
index 00000000..fc5d8094
--- /dev/null
+++ b/src/dfcx_scrapi/builders/agents.py
@@ -0,0 +1,254 @@
+"""A set of builder methods to create CX proto resource objects"""
+
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import List
+
+from google.cloud.dialogflowcx_v3beta1.types import Agent
+from google.cloud.dialogflowcx_v3beta1.types import SpeechToTextSettings
+
+
+class AgentBuilder:
+    """Base Class for CX Agent builder."""
+
+    def __init__(self, obj: Agent = None):
+        self.proto_obj = None
+        if obj:
+            self.load_agent(obj)
+
+
+    def __str__(self) -> str:
+        """String representation of the proto_obj."""
+        self._check_agent_exist()
+
+        logs = self.proto_obj.advanced_settings.logging_settings
+        speech_2_txt = self.proto_obj.speech_to_text_settings
+        return (
+            f"display_name: {self.proto_obj.display_name}"
+            f"\ntime_zone: {self.proto_obj.time_zone}"
+            f"\ndefault_language_code: {self.proto_obj.default_language_code}"
+            f"\ndescription: {self.proto_obj.description}"
+            f"\navatar_uri: {self.proto_obj.avatar_uri}"
+            "\nenable_speech_adaptation:"
+            f" {speech_2_txt.enable_speech_adaptation}"
+            "\nenable_spell_correction:"
+            f" {self.proto_obj.enable_spell_correction}"
+            "\nsupported_language_codes:"
+            f" {self.proto_obj.supported_language_codes}"
+            "\nenable_stackdriver_logging:"
+            f" {logs.enable_stackdriver_logging}"
+            "\nenable_interaction_logging:"
+            f" {logs.enable_interaction_logging}"
+            f"\nsecurity_settings: {self.proto_obj.security_settings}")
+
+
+    def _check_agent_exist(self):
+        """Check if the proto_obj exists otherwise raise an error."""
+        if not self.proto_obj:
+            raise ValueError(
+                "There is no proto_obj!"
+                "\nUse create_new_agent or load_agent to continue."
+            )
+        elif not isinstance(self.proto_obj, Agent):
+            raise ValueError(
+                "proto_obj is not an Agent type."
+                "\nPlease create or load the correct type to continue."
+            )
+
+
+    def load_agent(
+        self, obj: Agent, overwrite: bool = False
+    ) -> Agent:
+        """Load an existing agent to proto_obj for further uses.
+
+        Args:
+          obj (Agent):
+            An existing Agent obj.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already
+            contains an Agent.
+
+        Returns:
+          An Agent object stored in proto_obj
+        """
+        if not isinstance(obj, Agent):
+            raise ValueError(
+                "The object you're trying to load is not an Agent!"
+            )
+        if self.proto_obj and not overwrite:
+            raise Exception(
+                "proto_obj already contains an Agent."
+                " If you wish to overwrite it, pass overwrite as True."
+            )
+
+        if overwrite or not self.proto_obj:
+            self.proto_obj = obj
+
+        return self.proto_obj
+
+
+    def create_new_agent(
+        self,
+        display_name: str,
+        time_zone: str,
+        default_language_code: str = "en",
+        description: str = None,
+        avatar_uri: str = None,
+        overwrite: bool = False
+    ) -> Agent:
+        """Create a new Agent.
+
+        Args:
+          display_name (str):
+            Required. The human-readable name of the
+            agent, unique within the location.
+          time_zone (str):
+            Required. The time zone of the agent from the
+            `time zone database <https://www.iana.org/time-zones>`.
+            e.g., America/New_York, Europe/Paris.
+          default_language_code (str):
+            Required. Immutable. The default language of the agent as a
+            language tag. See `Language Support
+              <https://cloud.google.com/dialogflow/cx/docs/reference/language>`
+            for a list of the currently supported language codes. This
+            field cannot be updated.
+          description (str):
+            The description of the agent. The maximum
+            length is 500 characters. If exceeded, the
+            request is rejected.
+          avatar_uri (str):
+            The URI of the agent's avatar. Avatars are used throughout
+            the Dialogflow console and in the self-hosted `Web Demo
+              <https://cloud.google.com/dialogflow/docs/integrations/web-demo>`
+            integration.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already
+            contains an Agent.
+
+        Returns:
+          An Agent object stored in proto_obj
+        """
+        if ((display_name and not isinstance(display_name, str)) or
+            (time_zone and not isinstance(time_zone, str))):
+            raise ValueError(
+                "display_name and time_zone should be string."
+            )
+        if self.proto_obj and not overwrite:
+            raise Exception(
+                "proto_obj already contains an Agent."
+                " If you wish to overwrite it, pass overwrite as True."
+            )
+        if overwrite or not self.proto_obj:
+            self.proto_obj = Agent(
+                display_name=display_name,
+                time_zone=time_zone,
+                default_language_code=default_language_code,
+                description=description,
+                avatar_uri=avatar_uri,
+                locked=False,
+            )
+
+        return self.proto_obj
+
+
+    def language_and_speech_settings(
+        self,
+        enable_speech_adaptation: bool = False,
+        enable_spell_correction: bool = False,
+        supported_language_codes: List[str] = None,
+    ) -> Agent:
+        """Change the language and speech settings.
+
+        Args:
+          enable_speech_adaptation (bool):
+            Whether to use speech adaptation for speech
+            recognition.
+          enable_spell_correction (bool):
+            Indicates if automatic spell correction is
+            enabled in detect intent requests.
+          supported_language_codes (List[str]):
+            The list of all languages supported by the agent
+            (except for the ``default_language_code``).
+
+        Returns:
+          An Agent object stored in proto_obj
+        """
+        self._check_agent_exist()
+
+        if isinstance(enable_speech_adaptation, bool):
+            self.proto_obj.speech_to_text_settings=SpeechToTextSettings(
+                enable_speech_adaptation=enable_speech_adaptation
+            )
+        if isinstance(enable_spell_correction, bool):
+            self.proto_obj.enable_spell_correction = enable_spell_correction
+        if supported_language_codes:
+            if (isinstance(supported_language_codes, list) and
+                all(
+                    (isinstance(lang, str) for lang in supported_language_codes)
+                )):
+                the_obj = self.proto_obj
+                the_obj.supported_language_codes = supported_language_codes
+            else:
+                raise ValueError(
+                    "supported_language_codes should be a list of strings."
+                )
+
+        return self.proto_obj
+
+
+    def security_and_logging_settings(
+        self,
+        enable_stackdriver_logging: bool = False,
+        enable_interaction_logging: bool = False,
+        security_settings: str = None,
+    ) -> Agent:
+        """ Change the security and logging settings.
+
+        Args:
+          enable_stackdriver_logging (bool):
+            If true, StackDriver logging is currently
+            enabled.
+          enable_interaction_logging (bool):
+            If true, DF Interaction logging is currently
+            enabled.
+          security_settings (str):
+            Name of the
+            ``[SecuritySettings]
+              [google.cloud.dialogflow.cx.v3beta1.SecuritySettings]``
+            reference for the agent.Format:
+            ``projects/<Project ID>/locations/<Location ID>/
+              securitySettings/<Security Settings ID>``.
+
+        Returns:
+          An Agent object stored in proto_obj
+        """
+        self._check_agent_exist()
+
+        logs = self.proto_obj.advanced_settings.logging_settings
+        if isinstance(enable_stackdriver_logging, bool):
+            logs.enable_stackdriver_logging = enable_stackdriver_logging
+        if isinstance(enable_interaction_logging, bool):
+            logs.enable_interaction_logging = enable_interaction_logging
+        if security_settings and isinstance(security_settings, str):
+            self.proto_obj.security_settings = security_settings
+
+        return self.proto_obj
+
+
+    def show_agent_info(self):
+        """Show the proto_obj information."""
+        self._check_agent_exist()
+
+        print(self)
diff --git a/src/dfcx_scrapi/builders/entity_types.py b/src/dfcx_scrapi/builders/entity_types.py
new file mode 100644
index 00000000..013bb374
--- /dev/null
+++ b/src/dfcx_scrapi/builders/entity_types.py
@@ -0,0 +1,402 @@
+"""A set of builder methods to create CX proto resource objects"""
+
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import List, Union
+
+from google.cloud.dialogflowcx_v3beta1.types import EntityType
+
+
+class EntityTypeBuilder:
+    """Base Class for CX EntityType builder."""
+
+    def __init__(self, obj: EntityType = None):
+        self.proto_obj = None
+        if obj:
+            self.load_entity_type(obj)
+
+
+    def __str__(self) -> str:
+        """String representation of the proto_obj."""
+        self._check_entity_type_exist()
+
+        return (
+            f"{self._show_entity_type_basic_info()}"
+            f"\n{self._show_excluded_phrases()}"
+            f"\n{self._show_entities()}")
+
+
+    def _check_entity_type_exist(self):
+        """Check if the proto_obj exists otherwise raise an error."""
+        if not self.proto_obj:
+            raise ValueError(
+                "There is no proto_obj!\nUse create_new_entity_type"
+                " or load_entity_type to continue."
+            )
+        elif not isinstance(self.proto_obj, EntityType):
+            raise ValueError(
+                "proto_obj is not an EntityType."
+                "\nPlease create or load the correct type to continue."
+            )
+
+
+    def _show_entity_type_basic_info(self) -> str:
+        """Shows the information of proto_obj."""
+        self._check_entity_type_exist()
+
+        return (
+            f"display_name: {self.proto_obj.display_name}"
+            f"\nkind: {self.proto_obj.kind.name}"
+            f"\nauto_expansion_mode: {self.proto_obj.auto_expansion_mode}"
+            "\nenable_fuzzy_extraction:"
+            f" {self.proto_obj.enable_fuzzy_extraction}"
+            f"\nredact: {self.proto_obj.redact}")
+
+
+    def _show_excluded_phrases(self) -> str:
+        """Shows the excluded phrases of proto_obj."""
+        self._check_entity_type_exist()
+
+        excluded_phrases = "\n\t".join([
+            phrase.value
+            for phrase in self.proto_obj.excluded_phrases
+        ])
+
+        return f"excluded phrases:\n\t{excluded_phrases}"
+
+
+    def _show_entities(self) -> str:
+        """Shows the entities of proto_obj."""
+        self._check_entity_type_exist()
+
+        entities =  "\n".join([
+            f"  {entity.value}:\n\t{', '.join(entity.synonyms)}"
+            for entity in self.proto_obj.entities
+        ])
+
+        return f"entities:\n{entities}"
+
+
+    def show_entity_type(self, mode: str = "whole"):
+        """Show the proto_obj information.
+
+        Args:
+          mode (str):
+            Specifies what part of the entity type to show.
+            Options:
+              ['basic', 'entities', 'excluded' or 'excluded phrases', 'whole']
+        """
+        self._check_entity_type_exist()
+
+        if mode == "basic":
+            print(self._show_entity_type_basic_info())
+        elif mode == "entities":
+            print(self._show_entities())
+        elif mode in ["excluded", "excluded phrases"]:
+            print(self._show_excluded_phrases())
+        elif mode == "whole":
+            print(self)
+        else:
+            raise ValueError(
+                "mode should be in"
+                "['basic', 'entities',"
+                " 'excluded' or 'excluded phrases', 'whole']"
+            )
+
+
+    def load_entity_type(
+        self, obj: EntityType, overwrite: bool = False
+    ) -> EntityType:
+        """Load an existing EntityType to proto_obj for further uses.
+
+        Args:
+          obj (EntityType):
+            An existing EntityType obj.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already
+            contains an EntityType.
+
+        Returns:
+          An EntityType object stored in proto_obj
+        """
+        if not isinstance(obj, EntityType):
+            raise ValueError(
+                "The object you're trying to load is not an EntityType!"
+            )
+        if self.proto_obj and not overwrite:
+            raise Exception(
+                "proto_obj already contains an EntityType."
+                " If you wish to overwrite it, pass overwrite as True."
+            )
+
+        if overwrite or not self.proto_obj:
+            self.proto_obj = obj
+
+        return self.proto_obj
+
+
+    def create_new_entity_type(
+        self,
+        display_name: str,
+        kind: int,
+        auto_expansion_mode: int = 0,
+        enable_fuzzy_extraction: bool = False,
+        redact: bool = False,
+        overwrite: bool = False
+    ) -> EntityType:
+        """Create a new EntityType.
+
+        Args:
+          display_name (str):
+            The human-readable name of the
+            entity type, unique within the agent.
+          kind (int):
+            Represents kinds of entities.
+              1 = KIND_MAP
+              2 = KIND_LIST
+              3 = KIND_REGEXP
+          auto_expansion_mode (int):
+            Indicates whether the entity type can be
+            automatically expanded.
+              AUTO_EXPANSION_MODE_UNSPECIFIED = 0
+              AUTO_EXPANSION_MODE_DEFAULT = 1
+          enable_fuzzy_extraction (bool):
+            Enables fuzzy entity extraction during
+            classification.
+          redact (bool):
+            Indicates whether parameters of the entity
+            type should be redacted in log. If redaction is
+            enabled, page parameters and intent parameters
+            referring to the entity type will be replaced by
+            parameter name during logging.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already
+            contains an EntityType.
+
+        Returns:
+          An EntityType object stored in proto_obj
+        """
+        if not isinstance(display_name, str):
+            raise ValueError(
+                "display_name should be a string."
+            )
+        if not (isinstance(kind, int) and 1 <= kind <= 3):
+            raise ValueError(
+                "kind should be an int between 0 and 3."
+            )
+        if self.proto_obj and not overwrite:
+            raise Exception(
+                "proto_obj already contains an EntityType."
+                " If you wish to overwrite it, pass overwrite as True."
+            )
+        if overwrite or not self.proto_obj:
+            self.proto_obj = EntityType(
+                display_name=display_name,
+                kind=kind,
+                auto_expansion_mode=auto_expansion_mode,
+                enable_fuzzy_extraction=enable_fuzzy_extraction,
+                redact=redact
+            )
+
+        return self.proto_obj
+
+
+    def add_excluded_phrase(
+        self, phrase: Union[str, List[str]]
+    ) -> EntityType:
+        """Add one or more phrases to be excluded in the EntityType.
+
+        Args:
+          phrase (str | List[str]):
+            A single phrase as a string
+            or multiple phrases as a list of strings
+
+        Returns:
+          An EntityType object stored in proto_obj
+        """
+        self._check_entity_type_exist()
+
+        if isinstance(phrase, str):
+            self.proto_obj.excluded_phrases.append(
+                EntityType.ExcludedPhrase(value=phrase)
+            )
+        elif isinstance(phrase, list):
+            if not all((isinstance(p, str) for p in phrase)):
+                raise ValueError(
+                    "Only strings are allowed in the phrase list."
+                )
+
+            self.proto_obj.excluded_phrases.extend([
+                EntityType.ExcludedPhrase(value=value)
+                for value in phrase])
+
+        else:
+            raise ValueError(
+                "phrase should be either a string or a list of strings."
+            )
+
+        return self.proto_obj
+
+
+    def remove_excluded_phrase(
+        self, phrase: Union[str, List[str]]
+    ) -> EntityType:
+        """Remove one or more phrases from excluded phrases in the EntityType.
+
+        Args:
+          phrase (str | List[str]):
+            A single phrase as a string
+            or multiple phrases as a list of strings
+
+        Returns:
+          An EntityType object stored in proto_obj
+        """
+        self._check_entity_type_exist()
+
+        excl_phrases = {
+            excl_phrase.value
+            for excl_phrase in self.proto_obj.excluded_phrases
+        }
+        if isinstance(phrase, str):
+            excl_phrases -= {phrase}
+        elif isinstance(phrase, list):
+            if not all((isinstance(p, str) for p in phrase)):
+                raise ValueError(
+                    "Only strings are allowed in the phrase list."
+                )
+            excl_phrases -= set(phrase)
+        else:
+            raise ValueError(
+                "phrase should be either a string or a list of strings."
+            )
+        # Remove all excluded phrases and add new ones from the excl_phrases set
+        self.proto_obj.excluded_phrases.clear()
+        self.proto_obj.excluded_phrases.extend([
+            EntityType.ExcludedPhrase(value=excl_phrase)
+            for excl_phrase in excl_phrases
+        ])
+
+        return self.proto_obj
+
+
+    def add_entity(
+        self, value: str, synonyms: List[str] = None
+    ) -> EntityType:
+        """Add an entity to the EntityType stored in proto_obj
+
+        Args:
+          value (str):
+            Required. The primary value associated with this entity
+            entry. For example, if the entity type is *vegetable*, the
+            value could be *scallions*.
+            For ``KIND_MAP`` entity types:
+            -  A canonical value to be used in place of synonyms.
+          synonyms (List[str]):
+            Required only for ``KIND_MAP``.
+            A collection of value synonyms. For example, if
+            the entity type is *vegetable*, and ``value`` is
+            *scallions*, a synonym could be *green onions*.
+
+        Returns:
+          An EntityType object stored in proto_obj
+        """
+        self._check_entity_type_exist()
+
+        if not isinstance(value, str):
+            raise ValueError(
+                "value should be string."
+            )
+
+        if self.proto_obj.kind.name in ["KIND_LIST", "KIND_REGEXP"]:
+            self.proto_obj.entities.append(
+                EntityType.Entity(
+                    value=value, synonyms=[value]
+                )
+            )
+        elif self.proto_obj.kind.name == "KIND_MAP":
+            if not (
+                isinstance(synonyms, list) and
+                all((isinstance(s, str) for s in synonyms))
+            ):
+                raise ValueError(
+                    "synonyms should be a list of strings."
+                )
+
+            self.proto_obj.entities.append(
+                EntityType.Entity(
+                    value=value, synonyms=synonyms
+                )
+            )
+        else:
+            raise Exception(
+                "Entity type's kind is not correct and should be specified."
+            )
+
+        return self.proto_obj
+
+
+    def remove_entity(
+        self, value: str, synonyms: List[str] = None
+    ) -> EntityType:
+        """Remove an entity or synonym from the EntityType stored in proto_obj.
+        For `KIND_MAP` if synonym was not provided, the whole entity
+        will be removed, otherwise the synonyms associated with
+        the value will be removed.
+
+        Args:
+          value (str):
+            Required. The primary value associated with this entity
+            entry to remove. For example, if the entity type is *vegetable*,
+            the value could be *scallions*.
+            For `KIND_MAP` entity types:
+            -  A canonical value to be used in place of synonyms.
+          synonyms (List[str]):
+            Required only for `KIND_MAP`.
+            A collection of value synonyms to be removed. For example, if
+            the entity type is *vegetable*, and `value` is
+            *scallions*, a synonym could be *green onions*.
+
+        Returns:
+          An EntityType object stored in proto_obj
+        """
+        self._check_entity_type_exist()
+
+        if not isinstance(value, str):
+            raise ValueError(
+                "value should be string."
+            )
+
+        if not synonyms:
+            for idx, entity in enumerate(self.proto_obj.entities):
+                if entity.value == value:
+                    del self.proto_obj.entities[idx]
+                    break
+        elif self.proto_obj.kind.name == "KIND_MAP":
+            if not (
+                isinstance(synonyms, list) and
+                all((isinstance(s, str) for s in synonyms))
+            ):
+                raise ValueError(
+                    "synonyms should be a list of strings."
+                )
+
+            for entity in self.proto_obj.entities:
+                if entity.value == value:
+                    new_synonyms = list(set(entity.synonyms) - set(synonyms))
+                    entity.synonyms.clear()
+                    entity.synonyms.extend(new_synonyms)
+                    break
+
+        return self.proto_obj
diff --git a/src/dfcx_scrapi/builders/intent.py b/src/dfcx_scrapi/builders/intent.py
deleted file mode 100644
index 6452817a..00000000
--- a/src/dfcx_scrapi/builders/intent.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""A set of builder methods to create CX proto resource objects"""
-
-# Copyright 2022 Google LLC
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#     https://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-import logging
-
-from typing import List
-
-
-def build_intent(display_name, phrases: List[str]):
-    '''build an intent from list of phrases plus meta'''
-    tps = {
-        'text': row['text'] for row in phrases
-    }
-    intent = {
-        'display_name': display_name,
-        'training_phrases': tps
-    }
-    logging.info('intent %s', intent)
-    return intent
diff --git a/src/dfcx_scrapi/builders/intents.py b/src/dfcx_scrapi/builders/intents.py
new file mode 100644
index 00000000..b38d864e
--- /dev/null
+++ b/src/dfcx_scrapi/builders/intents.py
@@ -0,0 +1,682 @@
+"""A set of builder methods to create CX proto resource objects"""
+
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+from string import ascii_lowercase
+from string import digits
+from collections import defaultdict
+from typing import List, Dict, Union
+
+from google.cloud.dialogflowcx_v3beta1.types import Intent
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+
+class IntentBuilder:
+    """Base Class for CX Intent builder."""
+
+    def __init__(self, obj: Intent = None):
+        self.proto_obj = None
+        if obj:
+            self.load_intent(obj)
+
+
+    def __str__(self) -> str:
+        """String representation of the proto_obj."""
+        self._check_intent_exist()
+
+        return (f"{self._show_basic_info()}"
+            f"\n\n{self._show_parameters()}"
+            f"\n\n{self._show_training_phrases()}")
+
+
+    def _check_intent_exist(self):
+        """Check if the proto_obj exists otherwise raise an error."""
+        if not self.proto_obj:
+            raise ValueError(
+                "There is no proto_obj!"
+                "\nUse create_new_intent or load_intent to continue."
+            )
+        elif not isinstance(self.proto_obj, Intent):
+            raise ValueError(
+                "proto_obj is not an Intent type."
+                "\nPlease create or load the correct type to continue."
+            )
+
+
+    def _include_spaces_to_phrase(self, phrase: List[str], annots: List[str]):
+        """Internal method to add spaces to the training phrase list and
+        make related changes to the annotations list.
+
+        Args:
+          phrase (List[str]):
+            A list of strings that represents the training phrase.
+          annots (List[str]):
+            A list of strings that represents
+              parameter_id of each part in phrase.
+        """
+        i = 0
+        while True:
+            p_curr, a_curr = phrase[i], annots[i]
+            try:
+                p_next, a_next = phrase[i+1], annots[i+1]
+            except IndexError:
+                break
+
+            if a_curr and a_next:
+                phrase.insert(i+1, " ")
+                annots.insert(i+1, "")
+                i += 2
+            elif a_curr and not a_next:
+                phrase[i+1] = " " + p_next
+                i += 1
+            elif not a_curr and a_next:
+                phrase[i] = p_curr + " "
+                i += 1
+            elif not a_curr and not a_next:
+                phrase[i] = p_curr + " " + p_next
+                del phrase[i+1]
+                del annots[i+1]
+
+
+    def _label_constraints_check(self, key: str, value: str):
+        """Check constraints for the label's key and value
+        and raise an error if needed.
+
+        Args:
+          key (str):
+            Label's key.
+          value (str):
+            Label's value.
+        """
+        allowed_chars = ascii_lowercase + digits + "-_"
+        # TODO Add International characteres to allowed_chars
+        allowed_char_error_msg = (
+            "Key and Value can only contain lowercase letters,"
+            " numeric characters, underscores and dashes."
+        )
+        if not(isinstance(key, str) and isinstance(value, str)):
+            raise ValueError(
+                "Key and value should be string."
+            )
+        if len(key) > 63 or len(value) > 63:
+            raise ValueError(
+                "Key and value can be no longer than 63 characters"
+            )
+        if key.startswith("sys-") and key not in ["sys-head", "sys-contextual"]:
+            raise ValueError(
+                "Prefix `sys-` is reserved for Dialogflow defined labels."
+            )
+        if key[0] not in ascii_lowercase:
+            raise ValueError("Key must start with a lowercase letter.")
+        for s in key:
+            if s not in allowed_chars:
+                raise ValueError(allowed_char_error_msg)
+        for s in value:
+            if s not in allowed_chars:
+                raise ValueError(allowed_char_error_msg)
+
+
+    def _show_basic_info(self) -> str:
+        """String representation for the basic information of proto_obj."""
+        self._check_intent_exist()
+
+        labels = [
+            str(key) if key == val else f"{key}: {val}"
+            for key, val in self.proto_obj.labels.items()
+        ]
+        return (f"display_name: {self.proto_obj.display_name}"
+            f"\ndescription: {self.proto_obj.description}"
+            f"\npriority: {self.proto_obj.priority}"
+            f"\nis_fallback: {self.proto_obj.is_fallback}"
+            f"\nlabels: {labels}")
+
+
+    def _show_parameters(self) -> str:
+        """String representation for the parameters of proto_obj."""
+        self._check_intent_exist()
+
+        return "\n".join([
+            (f"parameter_id: {str(param.id)}"
+            f"\nentity_type: {str(param.entity_type)}"
+            f"\n\tis_list: {bool(param.is_list)}"
+            f"\n\tredact: {bool(param.redact)}")
+            for param in self.proto_obj.parameters
+        ])
+
+
+    def _show_training_phrases(self, repeat_count: int = None) -> str:
+        """String representation for the training phrases of proto_obj."""
+        self._check_intent_exist()
+
+        phrases = []
+        for tp in self.proto_obj.training_phrases:
+            if repeat_count and tp.repeat_count != repeat_count:
+                continue
+            phrase = "".join([part.text for part in tp.parts])
+            annotations = {
+                part.text: part.parameter_id
+                for part in tp.parts
+                if part.parameter_id
+            }
+            phrases.append(
+                f"phrase: {phrase}"
+                f"\nannotations: {str(annotations)}"
+                f"\n\trepeat_count: {tp.repeat_count}"
+            )
+
+        return "\n".join(phrases)
+
+
+    def show_intent(
+        self, mode: str = "whole", repeat_count: int = None
+    ) -> None:
+        """Show the proto_obj information.
+
+        Args:
+          mode (str):
+            Specifies what part of the intent to show.
+              Options:
+              ['basic', 'parameters', 'phrases' or 'training phrases', 'whole']
+          repeat_count (int):
+            Indicates how many times the training phrases
+            was added to the intent.
+        """
+        self._check_intent_exist()
+
+        self.parameter_checking()
+
+        if mode == "basic":
+            print(self._show_basic_info())
+        elif mode == "parameters":
+            print(self._show_parameters())
+        elif mode in ["phrases", "training phrases"]:
+            print(self._show_training_phrases(repeat_count=repeat_count))
+        elif mode == "whole":
+            print(self)
+        else:
+            raise ValueError(
+                "mode should be in"
+                " ['basic', 'parameters',"
+                " 'phrases', 'training phrases', 'whole']"
+            )
+
+
+    def show_stats(self):
+        """Provide some stats about the intent."""
+        repeat_count_dict = defaultdict(lambda: 0)
+        annotated_count = 0
+        for tp in self.proto_obj.training_phrases:
+            repeat_count_dict[tp.repeat_count] += 1
+
+            for part in tp.parts:
+                if part.parameter_id:
+                    annotated_count += 1
+                    break
+
+        phrases_count = len(self.proto_obj.training_phrases)
+        params_count = len(self.proto_obj.parameters)
+        annotated_pct = round(100*annotated_count/phrases_count, 1)
+        uniques_count = repeat_count_dict[1]
+        uniques_pct = round(100*uniques_count/phrases_count, 1)
+        non_uniques_count = phrases_count - uniques_count
+        non_uniques_pct = round(100*non_uniques_count/phrases_count, 1)
+
+        phrases_str = f"# of training phrases: {phrases_count}"
+        params_str = f"# of parameters: {params_count}"
+        annotated_str = (
+            "Annotated training phrases:"
+            f" {annotated_pct}% ({annotated_count})"
+        )
+
+        uniques_str = (
+            "Unique training phrases:"
+            f" {uniques_pct}% ({uniques_count})"
+        )
+        non_uniques_str = (
+            "Non-unique training phrases:"
+            f" {non_uniques_pct}% ({non_uniques_count})"
+        )
+
+        repeat_count_srt = "\n\t".join([
+            f"with repeat count {i}: {repeat_count_dict[i]}"
+            for i in sorted(repeat_count_dict.keys())
+        ])
+
+        out = (
+            f"{phrases_str}\n{annotated_str}\n{params_str}\n"
+            f"\n{uniques_str}\n{non_uniques_str}\n\t{repeat_count_srt}"
+        )
+        print(out)
+
+
+    def parameter_checking(self, raise_error: bool = False) -> bool:
+        """Check if the annotated parameters exist
+        in the Parameter attribute of proto_obj.
+
+        Args:
+          raise_error (bool):
+            A flag to whether raise an error. If False, it will log a warning.
+
+        Returns:
+          True if annotated parameters are the same as parameters in proto_obj
+        """
+        tp_params_set = set()
+        for tp in self.proto_obj.training_phrases:
+            for part in tp.parts:
+                tp_params_set.add(part.parameter_id)
+        # Remove the empty string for unannotated parts
+        try:
+            tp_params_set.remove("")
+        except KeyError:
+            pass
+
+        # Get the parameters from proto_obj
+        parameters_set = {param.id for param in self.proto_obj.parameters}
+
+        # Check for not existing annotated parameters
+        return_flag = True
+        for tp_param in tp_params_set:
+            if tp_param not in parameters_set:
+                return_flag = False
+                msg = (
+                    f"parameter_id `{tp_param}` does not exist in parameters."
+                    "\nPlease add it using add_parameter method to continue."
+                )
+                if raise_error:
+                    raise Exception(msg)
+                else:
+                    logging.warning(msg)
+
+        return bool(return_flag)
+
+
+    def load_intent(
+        self, obj: Intent, overwrite: bool = False
+    ) -> Intent:
+        """Load an existing intent to proto_obj for further uses.
+
+        Args:
+          obj (Intent):
+            An existing Intent obj.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already
+            contains an Intent.
+
+        Returns:
+          An Intent object stored in proto_obj
+        """
+        if not isinstance(obj, Intent):
+            raise ValueError(
+                "The object you're trying to load is not an Intent!"
+            )
+        if self.proto_obj and not overwrite:
+            raise Exception(
+                "proto_obj already contains an Intent."
+                " If you wish to overwrite it, pass overwrite as True."
+            )
+
+        if overwrite or not self.proto_obj:
+            self.proto_obj = obj
+
+        return self.proto_obj
+
+
+    def create_new_intent(
+        self,
+        display_name: str,
+        priority: int = 500000,
+        is_fallback: bool = False,
+        description: str = None,
+        overwrite: bool = False,
+    ) -> Intent:
+        """Create a new Intent.
+
+        Args:
+          display_name (str):
+            Required. The human-readable name of the
+            intent, unique within the agent.
+          priority (int):
+            The priority of this intent. Higher numbers represent higher
+            priorities.
+            -  If the supplied value is unspecified or 0, the service
+            translates the value to 500,000, which corresponds to the
+            ``Normal`` priority in the console.
+            -  If the supplied value is negative, the intent is ignored
+            in runtime detect intent requests.
+          is_fallback (bool):
+            Indicates whether this is a fallback intent.
+            Currently only default fallback intent is
+            allowed in the agent, which is added upon agent
+            creation.
+            Adding training phrases to fallback intent is
+            useful in the case of requests that are
+            mistakenly matched, since training phrases
+            assigned to fallback intents act as negative
+            examples that triggers no-match event.
+          description (str):
+            Human readable description for better
+            understanding an intent like its scope, content,
+            result etc. Maximum character limit: 140
+            characters.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already
+            contains an Intent.
+
+        Returns:
+          An Intent object stored in proto_obj
+        """
+        if self.proto_obj and not overwrite:
+            raise Exception(
+                "proto_obj already contains an Intent."
+                " If you wish to overwrite it, pass overwrite as True."
+            )
+
+        if overwrite or not self.proto_obj:
+            self.proto_obj = Intent(
+                display_name=display_name,
+                priority=priority,
+                is_fallback=is_fallback,
+                description=description
+            )
+
+        return self.proto_obj
+
+
+    def add_training_phrase(
+        self,
+        phrase: Union[str, List[str]],
+        annotations: List[str] = None,
+        repeat_count: int = 1,
+        include_spaces: bool = True,
+    ) -> Intent:
+        """Add a training phrase to proto_obj.
+
+        Args:
+          phrase (str | List[str]):
+            The training phrase as a string without annotations or
+            a list of strings that represents a single training phrase.
+          annotations (List[str]):
+            A list of strings that represents
+              parameter_id of each part in phrase.
+            Length of annotations list should be less than or equal to
+              length of phrase list.
+            If the length is less than length of phrase list it propagate
+              the rest of the annotations automatically with no annotation.
+          repeat_count (int):
+            Indicates how many times this example was added to the intent.
+          include_spaces (bool):
+            For the phrases with annotations, indicates whether the function
+              should include spaces between each part of training phrase.
+
+        Example 1: phrase = "I want to check my balance"
+        Example 2:
+          phrase = ['I want to order a', 'pizza']
+          annotations = ['', 'food_type']
+          include_spaces = True
+        Example 3:
+          phrase = [
+              'one way', ' ticket leaving ', 'January 1',
+              ' to ', 'LAX', ' from ', 'CDG'
+          ]
+          annotations = [
+              'flight_type', '', 'departure_date',
+              '', 'arrival_city', '', 'departure_city'
+          ]
+          include_spaces = False
+        Example 4:
+          phrase = ["I'd like to buy a", 'one way', 'ticket']
+          annotations = ['', 'flight_type']
+          include_spaces = True
+
+        Returns:
+          An Intent object stored in proto_obj
+        """
+
+        self._check_intent_exist()
+
+        # Add simple training phrase
+        if isinstance(phrase, str):
+            # Create the training phrase obj and add it to the others
+            tp = Intent.TrainingPhrase(
+                parts=[Intent.TrainingPhrase.Part(text=phrase)],
+                repeat_count=repeat_count
+            )
+            self.proto_obj.training_phrases.append(tp)
+
+            return self.proto_obj
+
+        # Add annotated training phrase
+        # Type / Error checking
+        if not (isinstance(phrase, list) and isinstance(annotations, list)):
+            raise ValueError(
+                "Both phrase and annotations should be lists."
+            )
+        if not (
+            all((isinstance(p, str) for p in phrase)) and
+            all((isinstance(a, str) for a in annotations))
+        ):
+            raise ValueError(
+                "Only strings allowed in phrase or annotations list."
+            )
+        if len(annotations) > len(phrase):
+            raise IndexError(
+                "Length of annotations list is more than phrase list!"
+            )
+
+        # Propagate the annotations list if needed
+        if len(annotations) < len(phrase):
+            annotations.extend([""] * (len(phrase) - len(annotations)))
+        # Change the phrase and annotations lists to include spaces if needed
+        if include_spaces:
+            self._include_spaces_to_phrase(phrase, annotations)
+        # Creating parts for the training phrase
+        parts_list = []
+        for text, parameter_id in zip(phrase, annotations):
+            part = Intent.TrainingPhrase.Part(
+                text=text, parameter_id=parameter_id
+            )
+            parts_list.append(part)
+
+        # Create the training phrase obj and add it to the others
+        tp = Intent.TrainingPhrase(
+            parts=parts_list, repeat_count=repeat_count
+        )
+        self.proto_obj.training_phrases.append(tp)
+
+        self.parameter_checking()
+
+        return self.proto_obj
+
+
+    def add_parameter(
+        self,
+        parameter_id: str,
+        entity_type: str,
+        is_list: bool = False,
+        redact: bool = False,
+    ) -> Intent:
+        """Add a parameter to Parameter attribute of proto_obj.
+
+        Args:
+          parameter_id (str):
+            Required. The unique identifier of the parameter.
+          entity_type (str):
+            Required. The entity type of the parameter. Format:
+            ``projects/-/locations/-/agents/-/
+              entityTypes/<System Entity Type ID>``
+            for system entity types (for example,
+            ``projects/-/locations/-/agents/-/entityTypes/sys.date``),
+            or
+            ``projects/<Project ID>/locations/<Location ID>/
+              agents/<Agent ID>/entityTypes/<Entity Type ID>``
+            for developer entity types.
+          is_list (bool):
+            Indicates whether the parameter represents a
+            list of values.
+          redact (bool):
+            Indicates whether the parameter content should be redacted
+            in log. If redaction is enabled, the parameter content will
+            be replaced by parameter name during logging.
+
+        Returns:
+          An Intent object stored in proto_obj
+        """
+        self._check_intent_exist()
+
+        if not (isinstance(parameter_id, str) or isinstance(entity_type, str)):
+            raise ValueError(
+                "parameter_id and entity_type should be string."
+            )
+
+        # Create the new parameter and add it to the proto_obj
+        parameters = Intent.Parameter(
+            id=parameter_id,
+            entity_type=entity_type,
+            is_list=is_list,
+            redact=redact
+        )
+        self.proto_obj.parameters.append(parameters)
+
+        return self.proto_obj
+
+
+    def add_label(
+        self, label: Union[Dict[str, str], str]
+    ) -> Intent:
+        """Add a label to proto_obj.
+
+        Args:
+          labels (Dict[str, str] | str):
+            labels can be assigned as key:value like driver:account
+            or strings like 'head intent'.
+            Labels can contain lowercase letters, digits and
+              the symbols '-' and'_'.
+            International characters are allowed, including letters
+              from unicase alphabets.
+            Keys must start with a letter.
+            Keys and values can be no longer than 63 characters and no more
+              than 128 bytes.
+            Prefix "sys-" is reserved for Dialogflow defined labels.
+              Currently allowed Dialogflow defined labels include:
+                - "sys-head" means the intent is a head intent.
+                - "sys-contextual" means the intent is a contextual intent.
+
+        Returns:
+          An Intent object stored in proto_obj
+        """
+        self._check_intent_exist()
+
+        if isinstance(label, str):
+            self._label_constraints_check(key=label, value=label)
+            self.proto_obj.labels.update({label: label})
+        elif isinstance(label, dict):
+            for key, val in label.items():
+                self._label_constraints_check(key=key, value=val)
+            self.proto_obj.labels.update(label)
+        else:
+            raise ValueError(
+                "label should be either a string or a dictionary."
+            )
+
+        return self.proto_obj
+
+
+    def remove_training_phrase(self, phrase: str) -> Intent:
+        """Remove a training phrase from proto_obj.
+
+        Args:
+          phrase (str):
+            The training phrase to remove from proto_obj.
+
+        Returns:
+          An Intent object stored in proto_obj
+        """
+        self._check_intent_exist()
+
+        if not isinstance(phrase, str):
+            raise ValueError("phrase should be a string.")
+
+        for idx, tp in enumerate(self.proto_obj.training_phrases):
+            # Construct the training phrase using parts
+            the_phrase = "".join([part.text for part in tp.parts])
+            if phrase == the_phrase:
+                del self.proto_obj.training_phrases[idx]
+                break
+
+        return self.proto_obj
+
+
+    def remove_parameter(self, parameter_id: str) -> Intent:
+        """Remove a parameter from proto_obj.
+
+        Args:
+          parameter_id (str):
+            The id of the parameter to remove from proto_obj.
+
+        Returns:
+          An Intent object stored in proto_obj
+        """
+        self._check_intent_exist()
+
+        if not isinstance(parameter_id, str):
+            raise ValueError("parameter_id should be a string.")
+
+        for idx, param in enumerate(self.proto_obj.parameters):
+            if parameter_id == param.id:
+                del self.proto_obj.parameters[idx]
+                break
+
+        return self.proto_obj
+
+
+    def remove_label(self, label: Union[Dict[str, str], str]) -> Intent:
+        """Remove a single or multiple labels from proto_obj.
+
+        Args:
+          label (Dict[str, str] | str):
+            A string or a dictionary of lables to remove.
+
+        Returns:
+          An Intent object stored in proto_obj
+        """
+        self._check_intent_exist()
+
+        if isinstance(label, str):
+            if self.proto_obj.labels.get(label) == label:
+                self.proto_obj.labels.pop(label)
+        elif isinstance(label, dict):
+            for key, val in label.items():
+                if not(isinstance(key, str) and isinstance(val, str)):
+                    raise ValueError(
+                        "Keys and values in label's dictionary"
+                        " should be string."
+                    )
+                # Check if the keys and values in the `label`
+                # are the same as labels in proto_obj
+                if self.proto_obj.labels.get(key) == val:
+                    self.proto_obj.labels.pop(key)
+        else:
+            raise ValueError(
+                "labels should be either a string or a dictionary."
+            )
+
+        return self.proto_obj
diff --git a/src/dfcx_scrapi/core/agents.py b/src/dfcx_scrapi/core/agents.py
index 521d30d8..5f6c15d7 100644
--- a/src/dfcx_scrapi/core/agents.py
+++ b/src/dfcx_scrapi/core/agents.py
@@ -83,6 +83,7 @@ def list_agents(
               `projects/<GCP PROJECT ID>/locations/<LOCATION ID>`
               `projects/my-gcp-project/locations/us-central1`
           project_id: The GCP Project ID as a string
+
         Returns:
           List of Agent objects
         """
@@ -125,13 +126,13 @@ def list_agents(
         return agents
 
     def get_agent(self, agent_id: str) -> types.Agent:
-        """Retrieves a single CX agent resource object.
+        """Retrieves a single CX Agent resource object.
 
         Args:
           agent_id: The formatted CX Agent ID
 
         Returns:
-        A single types.Agent object
+          A single CX Agent resource object
         """
 
         request = types.agent.GetAgentRequest()
@@ -171,6 +172,7 @@ def get_agent_by_display_name(
               display names.
               Syntax for region ID can be found here:
               https://cloud.google.com/dialogflow/cx/docs/concept/region#avail
+
         Returns:
           CX agent resource object. If no agent is found, returns None.
         """
@@ -217,7 +219,7 @@ def get_agent_by_display_name(
     def create_agent(
         self,
         project_id: str,
-        display_name: str,
+        display_name: str = None,
         gcp_region: str = "global",
         obj: types.Agent = None,
         **kwargs,
@@ -225,41 +227,48 @@ def create_agent(
         """Create a Dialogflow CX Agent with given display name.
 
         By default the CX Agent will be created in the project that the user
-        is currently authenticated to
-        If the user provides an existing Agent object, create a new CX agent
-        based on this object.
+        is currently authenticated to.
+        If the user provides an existing Agent object, a new CX Agent will be
+        created based on this object and any other input/kwargs will be
+        discarded.
 
         Args:
           project_id: GCP project id where the CX agent will be created
           display_name: Human readable display name for the CX agent
           gcp_region: GCP region to create CX agent. Defaults to 'global'
           obj: (Optional) Agent object to create new agent from
+            Refer to `builders.agents.AgentBuilder` to build one.
 
         Returns:
-          A single types.Agent resource.
+          The newly created CX Agent resource object.
         """
 
+        parent = f"projects/{project_id}/locations/{gcp_region}"
+
         if obj:
-            agent = obj
-            parent = f"projects/{project_id}/locations/{gcp_region}"
-            agent.display_name = display_name
+            agent_obj = obj
+            agent_obj.name = ""
+
         else:
-            agent = types.agent.Agent()
-            parent = f"projects/{project_id}/locations/{gcp_region}"
-            agent.display_name = display_name
+            if not display_name:
+                raise ValueError(
+                    "At least display_name or obj should be specified."
+                )
+            agent_obj = types.Agent(
+                display_name=display_name,
+                default_language_code="en",
+                time_zone="America/Chicago"
+            )
 
-        agent.default_language_code = "en"
-        agent.time_zone = "America/Chicago"
-
-        # set optional args as agent attributes
-        for key, value in kwargs.items():
-            setattr(agent, key, value)
+            # set optional args as agent attributes
+            for key, value in kwargs.items():
+                setattr(agent_obj, key, value)
 
         client_options = self._set_region(parent)
         client = services.agents.AgentsClient(
             credentials=self.creds, client_options=client_options
         )
-        response = client.create_agent(parent=parent, agent=agent)
+        response = client.create_agent(parent=parent, agent=agent_obj)
 
         return response
 
@@ -317,12 +326,12 @@ def get_validation_result(
         that Flow ID.
 
         Args:
-        agent_id: CX Agent ID string in the following format
+          agent_id: CX Agent ID string in the following format
             projects/<PROJECT ID>/locations/<LOCATION ID>/agents/<AGENT ID>
-        timeout: (Optional) The timeout for this request
+          timeout: (Optional) The timeout for this request
 
         Returns:
-        Dictionary of Validation results for the entire Agent.
+          Dictionary of Validation results for the entire Agent.
         """
 
         if not agent_id:
@@ -395,6 +404,7 @@ def export_agent(
 
     def restore_agent(self, agent_id: str, gcs_bucket_uri: str) -> str:
         """Restores a CX agent from a gcs_bucket location.
+
         Currently there is no way to restore back to default
         settings via the api. The feature request for this is logged.
 
@@ -436,6 +446,8 @@ def update_agent(
           kwargs: You may find a list of agent attributes here:
               https://cloud.google.com/python/docs/reference/dialogflow-cx/
                   latest/google.cloud.dialogflowcx_v3beta1.types.Agent
+        Returns:
+          The updated CX Agent resource object.
         """
 
         if obj:
@@ -464,6 +476,9 @@ def delete_agent(self, agent_id: str) -> str:
         Args:
           agent_id: CX Agent ID string in the following format
             projects/<PROJECT ID>/locations/<LOCATION ID>/agents/<AGENT ID>
+
+        Returns:
+          String "Agent '(agent_id)' successfully deleted."
         """
         client_options = self._set_region(agent_id)
         client = services.agents.AgentsClient(
diff --git a/src/dfcx_scrapi/core/changelogs.py b/src/dfcx_scrapi/core/changelogs.py
index bd417755..24cc6020 100644
--- a/src/dfcx_scrapi/core/changelogs.py
+++ b/src/dfcx_scrapi/core/changelogs.py
@@ -14,11 +14,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import datetime
 import logging
+
 from typing import Dict
 
 import pandas as pd
-import datetime
 
 from google.cloud.dialogflowcx_v3beta1 import services
 from google.cloud.dialogflowcx_v3beta1 import types
@@ -55,6 +56,7 @@ def __init__(
 
     @staticmethod
     def _validate_create_time(create_time: str):
+        """Validates that create_time is in the ISO 8601 datetime format."""
         try:
             datetime.datetime.strptime(create_time, "YYYY-MM-DDThh:mm:ss.sZ")
         except ValueError as err:
@@ -65,6 +67,7 @@ def _validate_create_time(create_time: str):
 
     @staticmethod
     def _validate_epoch_time(create_time_epoch_seconds: str):
+        """Validates that create_time_epoch_seconds is a Unix Timestamp."""
         try:
             datetime.datetime.fromtimestamp(int(create_time_epoch_seconds))
         except ValueError as err:
@@ -87,7 +90,7 @@ def list_changelogs(self, agent_id: str = None, **kwargs):
           agent_id: the formatted CX Agent ID
 
         Returns:
-          changelogs: List of Change History logs
+          List of Change History logs
         """
         request = types.changelog.ListChangelogsRequest()
         request.parent = agent_id
@@ -153,7 +156,7 @@ def get_changelog(self, changelog_id: str):
             <Changelog ID>`
 
         Returns:
-          changelog: A single changelog object
+          A single changelog object
         """
         request = types.changelog.GetChangelogRequest()
         request.name = changelog_id
@@ -178,7 +181,7 @@ def changelogs_to_dataframe(
             of the 'user_type' column. Defaults to '@google.com'.
 
         Returns:
-          df: the final dataframe output of the formatted logs
+          The final dataframe output of the formatted logs
         """
         changelogs = self.list_changelogs(agent_id)
 
diff --git a/src/dfcx_scrapi/core/conversation.py b/src/dfcx_scrapi/core/conversation.py
index 5f5bee41..27910fc5 100644
--- a/src/dfcx_scrapi/core/conversation.py
+++ b/src/dfcx_scrapi/core/conversation.py
@@ -16,12 +16,14 @@
 
 import logging
 import time
+import traceback
 import uuid
 
-import pandas as pd
 from typing import Dict
+from operator import attrgetter
 from threading import Thread
-import traceback
+
+import pandas as pd
 
 from google.cloud.dialogflowcx_v3beta1 import services
 from google.cloud.dialogflowcx_v3beta1 import types
@@ -63,9 +65,8 @@ def __init__(
         )
 
         logging.info(
-            f"create conversation with creds_path: \
-                {creds_path} | agent_id: {agent_id}",
-        )
+            "create conversation with creds_path: %s | agent_id: %s",
+            creds_path, agent_id)
 
         if agent_id or config["agent_path"]:
             self.agent_id = agent_id or config["agent_path"]
@@ -83,6 +84,9 @@ def __init__(
 
     @staticmethod
     def _get_match_type_from_map(match_type: int):
+        """Translates the match_type enum int value into a more descriptive
+        string.
+        """
         match_type_map = {
             0: "MATCH_TYPE_UNSPECIFIED",
             1: "INTENT",
@@ -91,12 +95,16 @@ def _get_match_type_from_map(match_type: int):
             4: "NO_MATCH",
             5: "NO_INPUT",
             6: "EVENT",
+            8: "KNOWLEDGE_CONNECTOR"
         }
 
         return match_type_map[match_type]
 
     @staticmethod
     def _validate_test_set_input(test_set: pd.DataFrame):
+        """Validates that all pages referenced in the test set exist in the
+        agent.
+        """
         mask = test_set.page_id.isna().to_list()
         invalid_pages = set(test_set.page_display_name[mask].to_list())
 
@@ -234,6 +242,11 @@ def _gather_query_result_parameters(self, input_parameters):
         return output_parameters
 
     def _page_id_mapper(self):
+        """Initializes the agent_pages_map dataframe.
+
+        This dataframe contains the flow_display_name, page_display_name,
+        and page_id for each page in the agent.
+        """
         agent_pages_map = pd.DataFrame()
         flow_map = self.flows.get_flows_map(agent_id=self.agent_id)
         for flow_id in flow_map.keys():
@@ -275,13 +288,10 @@ def _get_reply_results(self, utterance, page_id, results, i):
             send_obj={"text": utterance}, current_page=page_id, restart=True
         )
 
-        intent = response["intent_name"]
-        confidence = response["confidence"]
         target_page = response["page_name"]
 
-        results["detected_intent"][i] = intent or "NO_MATCH"
-        results["confidence"][i] = confidence
         results["target_page"][i] = target_page
+        results["match"][i] = response["match"]
 
     def _get_intent_detection(self, test_set: pd.DataFrame):
         """Gets the results of a subset of Intent Detection tests.
@@ -305,9 +315,8 @@ def _get_intent_detection(self, test_set: pd.DataFrame):
 
         threads = [None] * len(utterances)
         results = {
-            "detected_intent": [None] * len(utterances),
-            "confidence": [None] * len(utterances),
             "target_page": [None] * len(utterances),
+            "match":[None] * len(utterances),
         }
         for i, (utterance, page_id) in enumerate(zip(utterances, page_ids)):
             threads[i] = Thread(
@@ -316,30 +325,29 @@ def _get_intent_detection(self, test_set: pd.DataFrame):
             )
             threads[i].start()
 
-        for idx, _ in enumerate(threads):
-            threads[idx].join()
+        for _, thread in enumerate(threads):
+            thread.join()
 
-        test_set_mapped["detected_intent"] = results["detected_intent"]
-        test_set_mapped["confidence"] = results["confidence"]
         test_set_mapped["target_page"] = results["target_page"]
+        test_set_mapped["match"] = results["match"]
         test_set_mapped = test_set_mapped.drop(columns=["page_id"])
-
         intent_detection = test_set_mapped.copy()
 
         return intent_detection
 
+
     def restart(self):
-        """starts a new session/conversation for this agent"""
+        """Starts a new session/conversation for this agent"""
         self.session_id = uuid.uuid4()
         self.turn_count = 0
 
     def set_agent_env(self, param, value):
-        """setting changes related to the environment"""
+        """Setting changes related to the environment"""
         logging.info("setting agent_env param:[%s] = value:[%s]", param, value)
         self.agent_env[param] = value
 
     def checkpoint(self, msg=None, start=False):
-        """print a checkpoint to time progress and debug bottleneck"""
+        """Print a checkpoint to time progress and debug bottleneck"""
         if start:
             start_time = time.perf_counter()
             self.start_time = start_time
@@ -358,20 +366,26 @@ def reply(
         current_page: str = None,
         checkpoints: bool = False,
     ):
-        """
-        args:
-            send_obj: Dictionary with the following structure:
-              {'text': str,
-               'params': Dict[str,str],
-               'dtmf': str}
-            restart: Boolean flag that determines whether to use the existing
-              session ID or start a new conversation with a new session ID.
-              Passing True will create a new session ID on subsequent calls.
-              Defaults to False.
-            retries: used for recurse calling this func if API fails
-            current_page: Specify the page id to start the conversation from
-            checkpoints: Boolean flag to enable/disable Checkpoint timer
-              debugging. Defaults to False.
+        """Runs intent detection on one utterance and gets the agent reply.
+
+        Args:
+          send_obj: Dictionary with the following structure:
+            {'text': str,
+            'params': Dict[str,str],
+            'dtmf': str}
+          restart: Boolean flag that determines whether to use the existing
+            session ID or start a new conversation with a new session ID.
+            Passing True will create a new session ID on subsequent calls.
+            Defaults to False.
+          retries: used for recurse calling this func if API fails
+          current_page: Specify the page id to start the conversation from
+          checkpoints: Boolean flag to enable/disable Checkpoint timer
+            debugging. Defaults to False.
+
+        Returns:
+          A dictionary for the agent reply to to the submitted text.
+            Includes keys response_messages, confidence, page_name,
+            intent_name, match_type, match, other_intents, and params.
         """
         text = send_obj.get("text")
         send_params = send_obj.get("params")
@@ -490,6 +504,7 @@ def reply(
         reply["match_type"] = self._get_match_type_from_map(
             query_result.match.match_type
         )
+        reply["match"] = query_result.match
         reply["other_intents"] = self.format_other_intents(query_result)
         reply["params"] = params
 
@@ -498,7 +513,7 @@ def reply(
         return reply
 
     def format_other_intents(self, query_result):
-        """unwind protobufs into more friendly dict"""
+        """Unwind protobufs into more friendly dict"""
         other_intents = query_result.diagnostic_info.get(
             "Alternative Matched Intents"
         )
@@ -520,7 +535,7 @@ def format_other_intents(self, query_result):
         return None
 
     def getpath(self, obj, xpath, default=None):
-        """get data at a pathed location out of object internals"""
+        """Get data at a pathed location out of object internals"""
         elem = obj
         try:
             for xpitem in xpath.strip("/").split("/"):
@@ -565,7 +580,7 @@ def run_intent_detection(
           rate_limit: Number of seconds to wait between running test set chunks
 
         Returns:
-          intent_detection: A Pandas DataFrame consisting of the original
+          A Pandas DataFrame consisting of the original
             DataFrame plus an additional column for the detected intent with
             the following schema.
               flow_display_name: str
@@ -583,7 +598,39 @@ def run_intent_detection(
             result = pd.concat([result, result_chunk])
             self.progress_bar(start, test_set.shape[0])
             time.sleep(rate_limit)
-
         self.progress_bar(test_set.shape[0], test_set.shape[0])
 
+        result = self._unpack_match(result)
         return result
+
+    def _unpack_match(self, df: pd.DataFrame):
+        """ Unpacks a 'match' column into four component columns.
+
+        Args:
+          df: dataframe containing a column named match of types.Match
+
+        Returns:
+          A copy of df with columns match_type, confidence, parameters_set,
+            and detected_intent instead of match.
+        """
+        df = (
+            df
+            .copy()
+            .assign(
+                match_type = lambda df: df.match.apply(
+                    attrgetter("match_type._name_")),
+                confidence = lambda df: df.match.apply(
+                    attrgetter("confidence")),
+                parameters_set = lambda df: df.match.apply(
+                    attrgetter("parameters")),
+                detected_intent = lambda df: df.match.apply(
+                    attrgetter("intent.display_name"))
+            )
+            .assign(
+                parameters_set = lambda df: df.parameters_set.apply(
+                    lambda p: self.recurse_proto_marshal_to_dict(
+                        p) if p else "")
+            )
+            .drop(columns="match")
+        )
+        return df
diff --git a/src/dfcx_scrapi/core/entity_types.py b/src/dfcx_scrapi/core/entity_types.py
index becfc0c2..02292870 100644
--- a/src/dfcx_scrapi/core/entity_types.py
+++ b/src/dfcx_scrapi/core/entity_types.py
@@ -59,14 +59,28 @@ def __init__(
     def entity_type_proto_to_dataframe(
         obj: types.EntityType, mode: str = "basic"
     ):
-        """Entity Types to dataframe
+        """Converts an EntityType protobuf object to a Pandas Dataframe.
 
         Args:
-          obj, EntityType protobuf object
-          mode: (Optional) basic returns display_name, value of entity type and
-            it's synonyms.
-          advanced returns entity types and excluded phrases in a comprehensive
-            format.
+          obj: EntityType protobuf object
+          mode: (Optional) "basic" returns display_name, value of entity type
+            and its synonyms.
+            "advanced" returns entity types and excluded phrases in a
+            comprehensive format.
+
+        Returns:
+          In basic mode, a Pandas Dataframe for the entity type object with
+          schema:
+            display_name: str
+            entity_value: str
+            synonyms: str
+          In advanced mode, a dictionary with keys entity_types and
+          excluded_phrases.
+            The value of entity_types is a Pandas Dataframe with columns:
+              entity_type_id, display_name, kind, auto_expansion_mode,
+              fuzzy_extraction, redact, entity_value, synonyms
+            The value of excluded_phrases is a Dataframe with columns:
+              entity_type_id, display_name, excluded_phrase
         """
         if mode == "basic":
             main_df = pd.DataFrame()
@@ -77,18 +91,18 @@ def entity_type_proto_to_dataframe(
                 entity_type_dict["entity_value"] = entity.value
                 for synonym in entity.synonyms:
                     entity_type_dict["synonyms"] = synonym
-                    main_df = main_df.append(
-                        entity_type_dict, ignore_index=True
-                    )
+                    entity_type_df = pd.DataFrame(entity_type_dict, index=[0])
+                    main_df = pd.concat([main_df, entity_type_df],
+                                        ignore_index=True)
 
             return main_df
 
         elif mode == "advanced":
 
             main_df = pd.DataFrame()
-            excluded_phrases_df = pd.DataFrame()
+            excl_phrases_df = pd.DataFrame()
 
-            excluded_phrases_dict = {
+            excl_phrases_dict = {
                 "entity_type_id": obj.name,
                 "display_name": obj.display_name,
             }
@@ -104,18 +118,19 @@ def entity_type_proto_to_dataframe(
                 entity_type_dict["entity_value"] = entity.value
                 for synonym in entity.synonyms:
                     entity_type_dict["synonyms"] = synonym
-                    main_df = main_df.append(
-                        entity_type_dict, ignore_index=True
-                    )
+                    entity_type_df = pd.DataFrame(entity_type_dict, index=[0])
+                    main_df = pd.concat([main_df, entity_type_df],
+                                        ignore_index=True)
 
             for excluded_phrase in obj.excluded_phrases:
-                excluded_phrases_dict["excluded_phrase"] = excluded_phrase.value
-                excluded_phrases_df = excluded_phrases_df.append(
-                    excluded_phrases_dict, ignore_index=True
-                )
+                excl_phrases_dict["excluded_phrase"] = excluded_phrase.value
+                excl_phrases_dict2df = pd.DataFrame(
+                                          excl_phrases_dict, index=[0])
+                excl_phrases_df = pd.concat([excl_phrases_df,
+                                     excl_phrases_dict2df], ignore_index=True)
 
             return {
-                "entity_types": main_df, "excluded_phrases": excluded_phrases_df
+                "entity_types": main_df, "excluded_phrases": excl_phrases_df
             }
 
         else:
@@ -126,16 +141,30 @@ def entity_types_to_df(
         agent_id: str = None,
         mode: str = "basic",
         entity_type_subset: List[str] = None) -> pd.DataFrame:
-        """Extracts all Intents and Training Phrases into a Pandas DataFrame.
+        """Extracts all Entity Types into a Pandas DataFrame.
 
         Args:
-          agent_id, agent to pull list of intents
-          mode: (Optional) basic returns display_name, value of entity type and
-            it's synonyms.
-          advanced returns entity types and excluded phrases in a comprehensive
-            format.
+          agent_id, agent to pull list of entity types
+          mode: (Optional) "basic" returns display_name, value of entity type
+            and its synonyms.
+            "advanced" returns entity types and excluded phrases in a
+            comprehensive format.
           entity_type_subset: (Optional) A list of entities to pull
             If it's None, grab all the entity_types
+
+        Returns:
+          In basic mode, a Pandas Dataframe for all entity types in the agent
+          with schema:
+            display_name: str
+            entity_value: str
+            synonyms: str
+          In advanced mode, a dictionary with keys entity_types and
+          excluded_phrases.
+            The value of entity_types is a Pandas Dataframe with columns:
+              entity_type_id, display_name, kind, auto_expansion_mode,
+              fuzzy_extraction, redact, entity_value, synonyms
+            The value of excluded_phrases is a Dataframe with columns:
+              entity_type_id, display_name, excluded_phrase
         """
 
         if not agent_id:
@@ -152,14 +181,14 @@ def entity_types_to_df(
                 single_entity_df = self.entity_type_proto_to_dataframe(
                     obj, mode=mode
                 )
-                main_df = main_df.append(single_entity_df)
+                main_df = pd.concat([main_df, single_entity_df])
             main_df = main_df.sort_values(
                 ["display_name", "entity_value"])
             return main_df
 
         elif mode == "advanced":
             main_df = pd.DataFrame()
-            excluded_phrases_df = pd.DataFrame()
+            excl_phrases_df = pd.DataFrame()
             for obj in entity_types:
                 if (entity_type_subset and
                         obj.display_name not in entity_type_subset):
@@ -167,10 +196,10 @@ def entity_types_to_df(
                 single_entity_dict = self.entity_type_proto_to_dataframe(
                     obj, mode=mode
                 )
-                main_df = main_df.append(single_entity_dict["entity_types"])
-                excluded_phrases_df = excluded_phrases_df.append(
-                    single_entity_dict["excluded_phrases"]
-                )
+                main_df = pd.concat(
+                    [main_df, single_entity_dict["entity_types"]])
+                excl_phrases_df = pd.concat(
+                    [excl_phrases_df, single_entity_dict["excluded_phrases"]])
             type_map = {
                 "auto_expansion_mode": bool,
                 "fuzzy_extraction": bool,
@@ -179,7 +208,7 @@ def entity_types_to_df(
             main_df = main_df.astype(type_map)
 
             return {
-                "entity_types": main_df, "excluded_phrases": excluded_phrases_df
+                "entity_types": main_df, "excluded_phrases": excl_phrases_df
             }
 
         else:
@@ -187,15 +216,15 @@ def entity_types_to_df(
 
 
     def get_entities_map(self, agent_id: str = None, reverse=False):
-        """Exports Agent Entityt Names and UUIDs into a user friendly dict.
+        """Exports Agent Entity Type Names and UUIDs into a user friendly dict.
 
         Args:
-          - agent_id, the formatted CX Agent ID to use
-          - reverse, (Optional) Boolean flag to swap key:value -> value:key
+          agent_id: the formatted CX Agent ID to use
+          reverse: (Optional) Boolean flag to swap key:value -> value:key
 
         Returns:
-          - intents_map, Dictionary containing Entity UUIDs as keys and
-              intent.display_name as values
+          Dictionary containing Entity Type UUIDs as keys and
+          Entity Type display names as values
         """
         if not agent_id:
             agent_id = self.agent_id
@@ -218,10 +247,10 @@ def list_entity_types(self, agent_id: str = None):
         """Returns a list of Entity Type objects.
 
         Args:
-          - agent_id, the formatted CX Agent ID to use
+          agent_id: the formatted CX Agent ID to use
 
         Returns:
-          - entities, List of Entity Type objects
+          List of Entity Type objects
         """
         if not agent_id:
             agent_id = self.agent_id
@@ -247,10 +276,10 @@ def get_entity_type(self, entity_id: str = None):
         """Returns a single Entity Type object.
 
         Args:
-          - entity_id, the formatted CX Entity ID to get
+          entity_id: the formatted CX Entity ID to get
 
         Returns:
-          - response, the single Entity Type object
+          The single Entity Type object
         """
         if not entity_id:
             entity_id = self.entity_id
@@ -264,33 +293,46 @@ def get_entity_type(self, entity_id: str = None):
         return response
 
     def create_entity_type(
-        self, agent_id: str = None, obj: types.EntityType = None,
-        language_code: str = None, **kwargs
+        self,
+        agent_id: str = None,
+        display_name: str = None,
+        language_code: str = None,
+        obj: types.EntityType = None,
+        **kwargs
     ) -> types.EntityType:
         """Creates a single Entity Type object resource.
 
         Args:
-          - agent_id, the formatted CX Agent ID to create the object on
-          - obj, The CX EntityType object in proper format.
-          - language_code: Language code of the intents being uploaded. Ref:
+          agent_id: the formatted CX Agent ID to create the object on
+          display_name: Human readable display name for the EntityType
+          language_code: Language code of the intents being uploaded. Ref:
             https://cloud.google.com/dialogflow/cx/docs/reference/language
+          obj: The CX EntityType object in proper format.
+            Refer to `builders.entity_types.EntityTypeBuilder` to build one.
 
         Returns:
-          - response, copy of the Entity Type object created
+          A copy of the Entity Type object created
         """
         if not agent_id:
             agent_id = self.agent_id
 
         # If entity_type_obj is given set entity_type to it
         if obj:
-            entity_type = obj
-            entity_type.name = ""
+            entity_type_obj = obj
+            entity_type_obj.name = ""
         else:
-            entity_type = types.entity_type.EntityType()
+            if not display_name:
+                raise ValueError(
+                    "At least display_name or obj should be specified."
+                )
+            entity_type_obj = types.EntityType(
+                display_name=display_name,
+                kind=1
+            )
 
-        # set optional arguments to entity type attributes
-        for key, value in kwargs.items():
-            setattr(entity_type, key, value)
+            # set optional arguments to entity type attributes
+            for key, value in kwargs.items():
+                setattr(entity_type_obj, key, value)
 
         client_options = self._set_region(agent_id)
         client = services.entity_types.EntityTypesClient(
@@ -300,7 +342,7 @@ def create_entity_type(
         request = types.entity_type.CreateEntityTypeRequest()
 
         request.parent = agent_id
-        request.entity_type = entity_type
+        request.entity_type = entity_type_obj
 
         if language_code:
             request.language_code = language_code
@@ -318,6 +360,7 @@ def update_entity_type(
         language_code: str = None,
         **kwargs):
         """Update a single CX Entity Type object.
+
         Pass in a the Entity Type ID and the specified kwargs for the
         parameters in Entity Types object that you want updated. If you do not
         provide an Entity Type object, the object will be fetched based on the
@@ -326,13 +369,13 @@ def update_entity_type(
         Entity Type object as defined by the kwargs provided.
 
         Args:
-          entity_type_id, CX Entity Type ID in proper format
-          obj, (Optional) a CX Entity Type object of types.EntityType
+          entity_type_id: CX Entity Type ID in proper format
+          obj: (Optional) a CX Entity Type object of types.EntityType
           language_code: Language code of the intents being uploaded. Ref:
-          https://cloud.google.com/dialogflow/cx/docs/reference/language
+            https://cloud.google.com/dialogflow/cx/docs/reference/language
 
         Returns:
-          response, a copy of the updated Entity Type object
+          A copy of the updated Entity Type object
         """
 
         if obj:
@@ -368,10 +411,10 @@ def delete_entity_type(self, entity_id: str = None, obj=None) -> None:
         """Deletes a single Entity Type resouce object.
 
         Args:
-          entity_id, the formatted CX Entity ID to delete
+          entity_id: the formatted CX Entity ID to delete
 
         Returns:
-          - None
+          None
         """
         if not entity_id:
             entity_id = self.entity_id
diff --git a/src/dfcx_scrapi/core/environments.py b/src/dfcx_scrapi/core/environments.py
index a1f74521..047ad52f 100644
--- a/src/dfcx_scrapi/core/environments.py
+++ b/src/dfcx_scrapi/core/environments.py
@@ -106,8 +106,8 @@ def get_environments_map(
         into a user friendly dict.
 
         Args:
-          agent_id, the formatted CX Agent ID to use
-          reverse, (Optional) Boolean flag to swap key:value -> value:key
+          agent_id: the formatted CX Agent ID to use
+          reverse: (Optional) Boolean flag to swap key:value -> value:key
 
         Returns:
           Dictionary containing Environment UUIDs as keys and environment
@@ -163,7 +163,7 @@ def get_environment(
             <Agent ID>/environments/<Environment ID>
 
         Returns:
-            Environment object.
+          Environment object.
         """
         request = types.environment.GetEnvironmentRequest(
             name=environment_id)
@@ -188,7 +188,7 @@ def get_environment_by_display_name(
             projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>
 
         Returns:
-            Environment object.
+          Environment object.
         """
         if not agent_id:
             agent_id = self.agent_id
@@ -211,6 +211,7 @@ def create_environment(
             type google.cloud.dialogflowcx_v3beta1.types.Environment
           agent_id: The targeted agent for the operation. format:
             projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>
+
         Returns:
           An object representing a long-running operation. (LRO)
         """
@@ -354,6 +355,7 @@ def deploy_flow_to_environment(
         environment_id:str,
         flow_version:str):
         """Deploys a flow to the specified environment.
+
          Args:
           environment_id: unique ID associated with target environment. Format:
             projects/<Project ID>/locations/<Location ID>/agents/
@@ -361,6 +363,7 @@ def deploy_flow_to_environment(
           flow_version: Required. The flow version to deploy. Format:
             projects/<Project ID>/locations/<Location ID>/agents/
             <Agent ID>/ flows/<Flow ID>/versions/<Version ID>
+
         Returns:
           An object representing a long-running operation. (LRO)
         """
diff --git a/src/dfcx_scrapi/core/experiments.py b/src/dfcx_scrapi/core/experiments.py
index cdf513dc..8e2ba592 100644
--- a/src/dfcx_scrapi/core/experiments.py
+++ b/src/dfcx_scrapi/core/experiments.py
@@ -56,13 +56,21 @@ def __init__(
         logging.info("created %s", self.agent_id)
 
     def list_experiments(self, environment_id=None):
-        """list out experiments"""
+        """List out experiments.
+
+        Args:
+          environment_id: (Optional) The ID for the environment from which
+            the list of experiments will be retrieved.
+
+        Returns:
+          A list of experiment JSON objects.
+        """
         environment_path = f"{self.agent_id}/environments/{environment_id}"
         logging.info("environment_path %s", environment_path)
 
         request = types.experiment.ListExperimentsRequest()
         request.parent = environment_path
-        client_options = self._set_region(environment_id)
+        client_options = self._set_region(environment_path)
         client = services.experiments.ExperimentsClient(
             client_options=client_options, credentials=self.creds
         )
diff --git a/src/dfcx_scrapi/core/flows.py b/src/dfcx_scrapi/core/flows.py
index 32a41694..206fd60c 100644
--- a/src/dfcx_scrapi/core/flows.py
+++ b/src/dfcx_scrapi/core/flows.py
@@ -28,6 +28,8 @@
     datefmt="%Y-%m-%d %H:%M:%S",
 )
 
+logger = logging.getLogger(__name__)
+
 
 class Flows(scrapi_base.ScrapiBase):
     """Core Class for CX Flow Resource functions."""
@@ -53,20 +55,64 @@ def __init__(
 
         self.agent_id = agent_id
 
-    def get_flows_map(
-        self,
-        agent_id: str,
-        reverse=False
+    # TODO: Migrate to Flow Builder class when ready
+    @staticmethod
+    def _build_nlu_settings(
+        model_type: str = "STANDARD",
+        classification_threshold: float = 0.3,
+        model_training_mode: str = "MANUAL",
     ):
+        """Builds the NLU Settings object to be used with Flow objects.
+
+        Args:
+          model_type: ONEOF `STANDARD`, `ADVANCED`, `CUSTOM`. Defaults to
+            `STANDARD`.
+          classification_threshold: To filter out false positive results and
+            still get variety in matched natural language inputs for your
+            agent, you can tune the machine learning classification threshold.
+            If the returned score value is less than the threshold value, then
+            a no-match event will be triggered. The score values range from 0.0
+            (completely uncertain) to 1.0 (completely certain). If set to 0.0,
+            the default of 0.3 is used.
+          model_training_mode: ONEOF `AUTOMATIC`, `MANUAL`. Defaults to
+            `MANUAL`
+        """
+        model_type_map = {"STANDARD": 1, "CUSTOM": 2, "ADVANCED": 3}
+
+        model_training_map = {"AUTOMATIC": 1, "MANUAL": 2}
+
+        nlu_settings = types.NluSettings()
+        nlu_settings.classification_threshold = classification_threshold
+
+        if model_type in model_type_map:
+            nlu_settings.model_type = model_type_map[model_type]
+        else:
+            raise KeyError(
+                f"`{model_type}` is invalid. `model_type` must be "
+                "one of `STANDARD`, `ADVANCED`, `CUSTOM`."
+            )
+
+        if model_training_mode in model_training_map:
+            nlu_settings.model_training_mode = model_training_map[
+                model_training_mode
+            ]
+        else:
+            raise KeyError(
+                f"`{model_training_mode}` is invalid. "
+                "`model_training_mode` must be one of `AUTOMATIC`, `MANUAL`."
+            )
+
+        return nlu_settings
+
+    def get_flows_map(self, agent_id: str, reverse=False):
         """Exports Agent Flow Names and UUIDs into a user friendly dict.
 
         Args:
-            - agent_id, the formatted CX Agent ID to use
-            - reverse, (Optional) Boolean flag to swap key:value -> value:key
+          agent_id: the formatted CX Agent ID to use
+          reverse: (Optional) Boolean flag to swap key:value -> value:key
 
         Returns:
-            - flows_dict, Dictionary containing flow UUIDs as keys and
-                flow.display_name as values
+          Dictionary containing flow UUIDs as keys and display names as values
         """
 
         if reverse:
@@ -84,7 +130,7 @@ def get_flows_map(
         return flows_dict
 
     def train_flow(self, flow_id: str) -> str:
-        """trains the specified flow.
+        """Trains the specified flow.
 
         Args:
           flow_id: CX flow ID string in the following format
@@ -92,7 +138,7 @@ def train_flow(self, flow_id: str) -> str:
               flows/<FLOW ID>
 
         Returns:
-          response: A Long Running Operation (LRO) ID that can be used to
+          A Long Running Operation (LRO) ID that can be used to
             check the status of the export using
               dfcx_scrapi.core.operations->get_lro()
         """
@@ -102,20 +148,18 @@ def train_flow(self, flow_id: str) -> str:
 
         client_options = self._set_region(flow_id)
         client = services.flows.FlowsClient(
-            credentials=self.creds, client_options=client_options)
+            credentials=self.creds, client_options=client_options
+        )
 
         response = client.train_flow(request)
 
         return response
 
-    def list_flows(
-        self,
-        agent_id: str
-    ) -> List[types.Flow]:
+    def list_flows(self, agent_id: str) -> List[types.Flow]:
         """Get a List of all Flows in the current Agent.
 
         Args:
-          agent_id, CX Agent ID string in the proper format
+          agent_id: CX Agent ID string in the proper format
             projects/<PROJECT ID>/locations/<LOCATION ID>/agents/<AGENT ID>
 
         Returns:
@@ -138,9 +182,7 @@ def list_flows(
         return flows
 
     def get_flow_by_display_name(
-        self,
-        display_name: str,
-        agent_id: str
+        self, display_name: str, agent_id: str
     ) -> types.Flow:
         """Get a single CX Flow object based on its display name.
 
@@ -149,18 +191,16 @@ def get_flow_by_display_name(
           agent_id: CX Agent ID in which the flow exists.
 
         Returns:
-          a single CX Flow object"""
+          A single CX Flow object
+        """
 
-        flows_map = self.get_flows_map(
-            agent_id = agent_id,
-            reverse = True
-        )
+        flows_map = self.get_flows_map(agent_id=agent_id, reverse=True)
 
         if display_name in flows_map:
             flow_id = flows_map[display_name]
         else:
             raise ValueError(
-                f"Flow \"{display_name}\" " \
+                f'Flow "{display_name}" '
                 f"does not exist in the specified agent."
             )
 
@@ -175,7 +215,8 @@ def get_flow(self, flow_id: str) -> types.Flow:
           flow_id: CX Flow ID in the proper format
 
         Returns:
-          response: a single CX Flow object"""
+          A single CX Flow object
+        """
 
         client_options = self._set_region(flow_id)
         client = services.flows.FlowsClient(
@@ -185,20 +226,65 @@ def get_flow(self, flow_id: str) -> types.Flow:
 
         return response
 
-    def update_flow(
+    def create_flow(
         self,
-        flow_id: str,
+        agent_id: str,
+        display_name: str = None,
+        language_code: str = "en",
         obj: types.Flow = None,
-        **kwargs
+        **kwargs,
+    ):
+        """Create a Dialogflow CX Flow with given display name.
+
+        If the user provides an existing Flow object, a new CX Flow will be
+        created based on this object and any other input/kwargs will be
+        discarded.
+
+        Args:
+          agent_id: DFCX Agent id where the Flow will be created
+          display_name: Human readable display name for the CX Flow
+          obj: (Optional) Flow object to create in proto format
+
+        Returns:
+          The newly created CX Flow resource object.
+        """
+        request = types.flow.CreateFlowRequest()
+        request.parent = agent_id
+        request.language_code = language_code
+
+        if obj:
+            flow_obj = obj
+            request.flow = flow_obj
+
+        else:
+            flow_obj = types.Flow()
+            flow_obj.display_name = display_name
+
+            # set optional args as agent attributes
+            for key, value in kwargs.items():
+                setattr(flow_obj, key, value)
+
+            request.flow = flow_obj
+
+        client_options = self._set_region(agent_id)
+        client = services.flows.FlowsClient(
+            credentials=self.creds, client_options=client_options
+        )
+        response = client.create_flow(request)
+
+        return response
+
+    def update_flow(
+        self, flow_id: str, obj: types.Flow = None, **kwargs
     ) -> types.Flow:
         """Update a single specific CX Flow object.
 
         Args:
-          flow_id, CX Flow ID in the proper format
-          obj, (Optional) a single CX Flow object of types.Flow
+          flow_id: CX Flow ID in the proper format
+          obj: (Optional) a single CX Flow object of types.Flow
 
         Returns:
-          response, a copy of the updated Flow object
+          A copy of the updated Flow object
         """
 
         if obj:
@@ -221,10 +307,11 @@ def update_flow(
 
         return response
 
-    def update_nlu_settings(self, flow_id:str, **kwargs):
-        """updates flow to new NLU setting.
+    def update_nlu_settings(self, flow_id: str, **kwargs):
+        """Updates flow to new NLU setting.
+
         Args:
-            flow_id: flow id to update nlu settings for.
+          flow_id: flow id to update nlu settings for.
             model_type: (Optional) [0:unspecified, 1:MODEL_TYPE_STANDARD,
               2:Custom, 3:Advanced]
             classification_threshold: (Optional) threshold for the flow
@@ -239,24 +326,21 @@ def update_nlu_settings(self, flow_id:str, **kwargs):
         self.update_flow(flow_id=flow_id, nlu_settings=current_settings)
 
     def export_flow(
-        self,
-        flow_id: str,
-        gcs_path: str,
-        ref_flows: bool = True
+        self, flow_id: str, gcs_path: str, ref_flows: bool = True
     ) -> Dict[str, str]:
         """Exports DFCX Flow(s) into GCS bucket.
 
         Args:
-          flow_id, the formatted CX Flow ID to export
-          gcs_path, The `Google Cloud Storage URI to export the flow to. The
+          flow_id: the formatted CX Flow ID to export
+          gcs_path: The `Google Cloud Storage URI to export the flow to. The
             format of this URI must be ``gs://<bucket-name>/<object-name>``. If
             left unspecified, the serialized flow is returned inline.
-          ref_flows, Whether to export flows referenced by the specified flow.
+          ref_flows: Whether to export flows referenced by the specified flow.
 
         Returns:
-          lro.result, If successful the LRO result will return the Google Cloud
-            Storage URI from the Export Flow request. Otherwise, it will return
-            the corresponding error.
+          A Long Running Operation result. If successful the LRO result will
+            return the Google Cloud Storage URI from the Export Flow request.
+            Otherwise, it will return the corresponding error.
         """
         request = types.flow.ExportFlowRequest()
         request.name = flow_id
@@ -271,25 +355,20 @@ def export_flow(
 
         return response.result()
 
-    def export_flow_inline(
-        self,
-        flow_id: str,
-        ref_flows: bool = True
-    ) -> bytes:
+    def export_flow_inline(self, flow_id: str, ref_flows: bool = True) -> bytes:
         """Export a Flow, returning uncompressed raw byte content for flow.
 
         Args:
-          flow_id, the formatted CX Flow ID to export
-          ref_flows, Whether to export flows referenced by the specified flow.
+          flow_id: the formatted CX Flow ID to export
+          ref_flows: Whether to export flows referenced by the specified flow.
 
         Returns:
-          bytes representing the content of the flow.
+          Bytes representing the content of the flow.
         """
         request = types.flow.ExportFlowRequest()
         request.name = flow_id
         request.include_referenced_flows = ref_flows
 
-
         client_options = self._set_region(flow_id)
         client = services.flows.FlowsClient(
             credentials=self.creds, client_options=client_options
@@ -303,22 +382,22 @@ def import_flow(
         agent_id: str,
         gcs_path: str = None,
         flow_content: bytes = None,
-        import_option: str = "KEEP"
+        import_option: str = "KEEP",
     ) -> Dict[str, str]:
         """Imports a DFCX Flow to CX Agent. Flow can be imported from a
         GCS bucket or from raw bytes.
 
         Args:
-          agent_id, the CX Agent ID to import the flow into.
-          gcs_path, the `Google Cloud Storage URI to import flow from.
+          agent_id: the CX Agent ID to import the flow into.
+          gcs_path: the `Google Cloud Storage URI to import flow from.
             the format of this URI must be ``gs://<bucket-name>/<object-name>``.
-          flow_content, uncompressed raw byte content for flow.
-          import_option, one of 'FALLBACK' or 'KEEP'. Defaults to 'KEEP'
+          flow_content: uncompressed raw byte content for flow.
+          import_option: one of 'FALLBACK' or 'KEEP'. Defaults to 'KEEP'
 
         Returns:
-          lro.result, If successful the LRO result will return the Flow ID of
-            the newly imported Flow. Otherwise, it will return the
-            corresponding error.
+          A Long Running Operation result. If successful the LRO result will
+            return the Flow ID of the newly imported Flow.
+            Otherwise, it will return the corresponding error.
         """
 
         if gcs_path and flow_content:
@@ -346,17 +425,14 @@ def import_flow(
 
         return response
 
-    def delete_flow(
-        self,
-        flow_id: str,
-        force: bool = False
-    ):
-        """Deletes a single CX Flow Object resources.
+    def delete_flow(self, flow_id: str, force: bool = False):
+        """Deletes a single CX Flow Object resource.
 
         Args:
           flow_id: flow to delete
           force: False means a flow will not be deleted if a route to the flow
-            exists, True means the flow will be deleted and all
+            exists, True means the flow will be deleted as well as all the
+            transition routes leading to the flow.
         """
 
         request = types.DeleteFlowRequest()
diff --git a/src/dfcx_scrapi/core/intents.py b/src/dfcx_scrapi/core/intents.py
index 090804ae..6ec4f974 100644
--- a/src/dfcx_scrapi/core/intents.py
+++ b/src/dfcx_scrapi/core/intents.py
@@ -15,10 +15,8 @@
 # limitations under the License.
 
 from collections import defaultdict
-import json
 import logging
 from typing import Dict, List, Tuple
-import numpy as np
 import pandas as pd
 
 from google.cloud.dialogflowcx_v3beta1 import services
@@ -62,162 +60,117 @@ def __init__(
             self.agent_id = agent_id
 
     @staticmethod
-    def intent_proto_to_dataframe(obj: types.Intent, mode="basic"):
-        """intents to dataframe
+    def concat_dict_and_df(
+        intent_df: pd.DataFrame,
+        intent_dict: Dict[str,str]) -> pd.DataFrame:
+        """Transform Dict to DF then Concat with existing DF."""
+        row = pd.DataFrame.from_dict(intent_dict, orient="index").transpose()
+        intent_df = pd.concat([intent_df, row], ignore_index=True)
 
-        Args:
-          obj, intent protobuf object
-          mode: (Optional) basic returns display name and training phrase as
-            plain text.
-          Advanced returns training phrase and parameters df broken out by
-            parts.
-        """
-        if mode == "basic":
-            intent_dict = defaultdict(list)
-            if "training_phrases" in obj:
-                for train_phrase in obj.training_phrases:
-                    item_list = []
-                    if len(train_phrase.parts) > 1:
-                        for item in train_phrase.parts:
-                            item_list.append(item.text)
-                        intent_dict[obj.display_name].append("".join(item_list))
-                    else:
-                        intent_dict[obj.display_name].append(
-                            train_phrase.parts[0].text
-                        )
-            else:
-                intent_dict[obj.display_name].append("")
+        return intent_df
 
-            data_frame = pd.DataFrame.from_dict(
-                intent_dict, orient="index"
-            ).transpose()
-            data_frame = data_frame.stack().to_frame().reset_index(level=1)
-            data_frame = data_frame.rename(
-                columns={"level_1": "display_name", 0: "training_phrase"}
-            ).reset_index(drop=True)
-            data_frame = data_frame.sort_values(
-                ["display_name", "training_phrase"])
+    @staticmethod
+    def parse_phrase_for_parameter_info(
+        intent_dict: Dict[str,str],
+        params_dict: Dict[str,str],
+        part: types.Intent.TrainingPhrase.Part,
+        part_count: int):
+        """Parse through Phrase part and update Dict with Parameter info."""
+        intent_dict.update({
+            "text": part.text,
+            "text_idx": part_count
+            })
 
-            return data_frame
+        if part.parameter_id:
+            intent_dict.update(params_dict[part.parameter_id])
+        elif intent_dict.get("entity_type"):
 
-        elif mode == "advanced":
+            # Remove existing parameter_id if exist
+            key_to_remove = [
+                "parameter_id", "entity_type",
+                "is_list", "redact",
+                ]
 
-            train_phrases = obj.training_phrases
-            params = obj.parameters
-            if len(train_phrases) > 0:
-                tp_df = pd.DataFrame()
-                tp_id = 0
-                for train_phrase in train_phrases:
-                    part_id = 0
-                    for part in train_phrase.parts:
-                        tp_df = tp_df.append(
-                            pd.DataFrame(
-                                columns=[
-                                    "display_name",
-                                    "name",
-                                    "training_phrase",
-                                    "part",
-                                    "text",
-                                    "parameter_id",
-                                    "repeat_count",
-                                    "id",
-                                ],
-                                data=[
-                                    [
-                                        obj.display_name,
-                                        obj.name,
-                                        tp_id,
-                                        part_id,
-                                        part.text,
-                                        part.parameter_id,
-                                        train_phrase.repeat_count,
-                                        train_phrase.id,
-                                    ]
-                                ],
-                            )
-                        )
-                        part_id += 1
-                    tp_id += 1
-
-                phrases = tp_df.copy()
-                phrase_lst = (
-                    phrases.groupby(["training_phrase"])["text"]
-                    .apply(lambda x: "".join(x))  # pylint: disable=W0108
-                    .reset_index()
-                    .rename(columns={"text": "phrase"})
-                )
+            for key in key_to_remove:
+                intent_dict.pop(key)
 
-                phrases = pd.merge(
-                    phrases, phrase_lst, on=["training_phrase"], how="outer"
-                )
+        return intent_dict
 
-                if len(params) > 0:
-                    param_df = pd.DataFrame()
-                    for param in params:
-                        param_df = param_df.append(
-                            pd.DataFrame(
-                                columns=["display_name", "id", "entity_type"],
-                                data=[
-                                    [
-                                        obj.display_name,
-                                        param.id,
-                                        param.entity_type,
-                                    ]
-                                ],
-                            )
-                        )
-                    return {"phrases": phrases, "parameters": param_df}
+    def process_basic_mode_proto(self, obj: types.Intent):
+        """Process Intent Proto in basic mode."""
+        intent_df = pd.DataFrame(columns=["display_name", "training_phrase"])
 
-                else:
-                    return {
-                        "phrases": phrases,
-                        "parameters": pd.DataFrame(
-                            columns=["display_name", "id", "entity_type"]
-                        ),
-                    }
+        intent_dict = {"display_name": str(obj.display_name)}
 
-            else:
-                return {
-                    "phrases": pd.DataFrame(
-                        columns=[
-                            "display_name",
-                            "name",
-                            "training_phrase",
-                            "part",
-                            "text",
-                            "parameter_id",
-                            "repeat_count",
-                            "id",
-                            "phrase",
-                        ],
-                        data=[
-                            [
-                                obj.display_name,
-                                obj.name,
-                                np.nan,
-                                np.nan,
-                                np.nan,
-                                np.nan,
-                                np.nan,
-                                np.nan,
-                                np.nan,
-                            ]
-                        ],
-                    ),
-                    "parameters": pd.DataFrame(
-                        columns=["display_name", "id", "entity_type"]
-                    ),
-                }
+        if not obj.training_phrases:
+            intent_df = self.concat_dict_and_df(intent_df, intent_dict)
 
         else:
-            raise ValueError("Mode types: [basic, advanced]")
+            for phrase in obj.training_phrases:
+                parts_list = [part.text for part in phrase.parts]
+                intent_dict.update({"training_phrase": "".join(parts_list)})
+
+                intent_df = self.concat_dict_and_df(intent_df, intent_dict)
+
+        return intent_df
+
+    def process_advanced_mode_proto(self, obj: types.Intent):
+        """Process Intent Proto in advanced mode."""
+
+        intent_df = pd.DataFrame(columns=[
+            "name", "display_name", "description", "priority",
+            "is_fallback", "labels", "id", "repeat_count",
+            "training_phrase_idx", "text", "text_idx",
+            "parameter_id", "entity_type", "is_list", "redact",
+            ])
+
+        intent_dict = {
+            "name": str(obj.name),
+            "display_name": str(obj.display_name),
+            "description": str(obj.description),
+            "priority": int(obj.priority),
+            "is_fallback": bool(obj.is_fallback),
+        }
+
+        # labels
+        intent_dict["labels"] = ",".join([
+            key if key == val else f"{key}:{val}"
+            for key, val in obj.labels.items()
+        ])
+        # parameters
+        params_dict = {
+            str(param.id): {
+                "parameter_id": str(param.id),
+                "entity_type": str(param.entity_type),
+                "is_list": bool(param.is_list),
+                "redact": bool(param.redact),
+            }
+            for param in obj.parameters
+        }
+        # training phrases
+        if not obj.training_phrases:
+            intent_df = self.concat_dict_and_df(intent_df, intent_dict)
+
+        else:
+            for tp_count, phrase in enumerate(obj.training_phrases):
+                intent_dict.update({
+                    "id": str(phrase.id),
+                    "repeat_count": int(phrase.repeat_count),
+                    "training_phrase_idx": tp_count,
+                })
+                for part_count, part in enumerate(phrase.parts):
+                    intent_dict = self.parse_phrase_for_parameter_info(
+                        intent_dict, params_dict, part, part_count)
+
+                    intent_df = self.concat_dict_and_df(intent_df, intent_dict)
+
+        return intent_df
 
     @staticmethod
     def modify_training_phrase_df(
         actions: pd.DataFrame, training_phrase_df: pd.DataFrame
     ):
-        """
-        Update the advanced mode training phrases dataframe to reflect the
+        """Update the advanced mode training phrases dataframe to reflect the
         actions provided in the actions dataframe. Pass the returned new
         training phrase dataframe and the original parameters dataframe to the
         bulk_update_intents_from_dataframe functions with update_flag = True in
@@ -226,19 +179,21 @@ def modify_training_phrase_df(
         added or moved.
 
         Args:
-          actions:
-            display_name, display_name of the intent to take action on
-            phrase, exact string training phrase to take action on
-            action, add or delete. To do a move it is an add + a delete action
-              of the same phrase
-            training_phrase_df, advanced mode training phrase dataframe pulled
+          actions: A Pandas DataFrame with columns:
+            display_name: the display name of the intent to take action on
+            phrase: exact string training phrase to take action on
+            action: "add" or "delete". To do a move, it will be an add and
+              a delete action of the same phrase
+          training_phrase_df: advanced mode training phrase dataframe pulled
 
         Returns:
-          updated_training_phrases_df, training phrase df from advanced mode
-            with the edits made to it shown in the actions_taken dataframe
-          actions_taken: actions taken based on the actions provided. For
-            example we cannot delete a training phrase which does not exit;
-            this will be shown.
+          A dictionary with keys updated_training_phrases_df and actions_taken.
+            The value for updated_training_phrases_df is the training phrase
+              DataFrame from advanced mode with the edits made to it shown
+              in the actions_taken DataFrame.
+            The value for actions_taken is a DataFrame of actions taken based
+              on the actions provided. For example, we cannot delete a training
+              phrase which does not exist; this will be shown.
         """
         mutations = pd.merge(
             training_phrase_df,
@@ -295,12 +250,14 @@ def modify_training_phrase_df(
         ]
 
         updated_training_phrases_df = untouched.copy()
-        updated_training_phrases_df = updated_training_phrases_df.append(
+        updated_training_phrases_df = pd.concat([
+            updated_training_phrases_df,
             false_additions
-        )
-        updated_training_phrases_df = updated_training_phrases_df.append(
+        ])
+        updated_training_phrases_df = pd.concat([
+            updated_training_phrases_df,
             true_additions
-        )
+        ])
         updated_training_phrases_df = updated_training_phrases_df.drop(
             columns=["action"]
         )
@@ -321,7 +278,7 @@ def modify_training_phrase_df(
                     axis=1,
                 ),
             )
-            actions_taken = actions_taken.append(true_additions)
+            actions_taken = pd.concat([actions_taken, true_additions])
 
         if false_additions.empty is False:
             false_additions.insert(len(false_additions.columns), "completed", 0)
@@ -338,7 +295,7 @@ def modify_training_phrase_df(
                     axis=1,
                 ),
             )
-            actions_taken = actions_taken.append(false_additions)
+            actions_taken = pd.concat([actions_taken, false_additions])
 
         if true_deletions.empty is False:
             true_deletions.insert(len(true_deletions.columns), "completed", 1)
@@ -355,7 +312,7 @@ def modify_training_phrase_df(
                     axis=1,
                 ),
             )
-            actions_taken = actions_taken.append(true_deletions)
+            actions_taken = pd.concat([actions_taken, true_deletions])
 
         if false_deletions.empty is False:
             false_deletions.insert(len(false_deletions.columns), "completed", 0)
@@ -372,7 +329,7 @@ def modify_training_phrase_df(
                     axis=1,
                 ),
             )
-            actions_taken = actions_taken.append(false_deletions)
+            actions_taken = pd.concat([actions_taken, false_deletions])
 
         actionable_intents = list(
             set(actions_taken[actions_taken["completed"] == 1]["display_name"])
@@ -388,16 +345,52 @@ def modify_training_phrase_df(
 
         return return_data
 
+
+    def intent_proto_to_dataframe(
+        self,
+        obj: types.Intent,
+        mode: str = "basic") -> pd.DataFrame:
+        """Converts an intent protobuf object to a Pandas DataFrame.
+
+        Args:
+          obj (types.Intent): the intent protobuf object
+          mode (str):
+            "basic" returns display name and training phrase as plain text.
+            "advanced" returns training phrases broken out by parts
+            with their parameters included.
+
+        Returns:
+          In basic mode, a Pandas DataFrame with columns:
+            display_name, training phrase
+          In advanced mode, a Pandas DataFrame with columns:
+            name, display_name, description, priority,
+            is_fallback, labels, id, repeat_count,
+            training_phrase_idx, text, text_idx,
+            parameter_id, entity_type, is_list, redact
+        """
+        if not isinstance(obj, types.Intent):
+            raise ValueError("obj should be Intent.")
+
+        if mode == "basic":
+            intent_df = self.process_basic_mode_proto(obj)
+
+        elif mode == "advanced":
+            intent_df = self.process_advanced_mode_proto(obj)
+
+        else:
+            raise ValueError("Mode types: [basic, advanced]")
+
+        return intent_df
+
     def get_intents_map(self, agent_id: str = None, reverse: bool = False):
         """Exports Agent Intent Names and UUIDs into a user friendly dict.
 
         Args:
-          agent_id, the formatted CX Agent ID to use
-          reverse, (Optional) Boolean flag to swap key:value -> value:key
+          agent_id: the formatted CX Agent ID to use
+          reverse: (Optional) Boolean flag to swap key:value -> value:key
 
         Returns:
-          intents_map, Dictionary containing Intent UUIDs as keys and
-              intent.display_name as values
+          Dictionary containing Intent UUIDs as keys and display names as values
         """
         if not agent_id:
             agent_id = self.agent_id
@@ -423,12 +416,12 @@ def list_intents(
         """Exports List of all intents in specific CX Agent.
 
         Args:
-          agent_id, the formatted CX Agent ID to use
+          agent_id: the formatted CX Agent ID to use
           language_code: Language code of the intents being uploaded. Ref:
             https://cloud.google.com/dialogflow/cx/docs/reference/language
 
         Returns:
-          intents, List of Intent objects
+          List of Intent objects
         """
         if not agent_id:
             agent_id = self.agent_id
@@ -459,12 +452,12 @@ def get_intent(
         """Get a single Intent object based on specific CX Intent ID.
 
         Args:
-          intent_id, the properly formatted CX Intent ID
+          intent_id: the properly formatted CX Intent ID
           language_code: Language code of the intents being uploaded. Ref:
             https://cloud.google.com/dialogflow/cx/docs/reference/language
 
         Returns:
-          response, a single Intent object
+          A single Intent object
         """
         if not intent_id:
             intent_id = self.intent_id
@@ -488,63 +481,38 @@ def create_intent(
         self,
         agent_id: str,
         obj: types.Intent = None,
-        intent_dictionary: dict = None,
-        language_code: str = None) -> types.Intent:
+        display_name: str = None,
+        language_code: str = None,
+        **kwargs) -> types.Intent:
         """Creates an Intent from a protobuf or dictionary.
 
         Args:
-          agent_id, the formatted CX Agent ID to use
-          obj, (Optional) Intent protobuf of types.Intent
-          intent_dictionary, (optional) dictionary of the intent to pass in
-            with structure
-
-        example intent dictionary:
-           test_intent = {
-            "description": "",
-            "display_name": "my_intent",
-            "is_fallback": False,
-            "labels": {},
-            "priority": 500000,
-            "training_phrases": [
-                {
-                    "id": "",
-                    "parts": [
-                        {
-                            "text": "hello"
-                        },
-                        {
-                            "text": "all"
-                        }
-                    ],
-                    "repeat_count": 1
-                },
-                {
-                    "id": "",
-                    "parts": [
-                        {
-                            "text": "hi"
-                        }
-                    ],
-                    "repeat_count": 1
-                }
-            ]
-        }
+          agent_id: the formatted CX Agent ID to use
+          obj: Intent object to create new intent from.
+            Refer to `builders.intents.IntentBuilder` to build one.
+          display_name: Human readable display name for the Intent
+          language_code (Optional): The language of the training phrases in
+            the intent. If not specified, the agent's default language is used
 
         Returns:
           Intent protobuf object
         """
 
-        if obj and intent_dictionary:
-            raise ValueError("cannot provide both obj and intent_dictionary")
-        elif obj:
-            intent = obj
-            intent.name = ""
-        elif intent_dictionary:
-            intent = types.intent.Intent.from_json(
-                json.dumps(intent_dictionary)
-            )
+        if obj:
+            intent_obj = obj
+            intent_obj.name = ""
         else:
-            raise ValueError("must provide either obj or intent_dictionary")
+            if not display_name:
+                raise ValueError(
+                    "At least display_name or obj should be specified."
+                )
+            intent_obj = types.Intent(
+                display_name=display_name
+            )
+
+            # set optional arguments to entity type attributes
+            for key, value in kwargs.items():
+                setattr(intent_obj, key, value)
 
         request = types.intent.CreateIntentRequest()
 
@@ -552,7 +520,7 @@ def create_intent(
             request.language_code = language_code
 
         request.parent = agent_id
-        request.intent = intent
+        request.intent = intent_obj
 
         client_options = self._set_region(agent_id)
         client = services.intents.IntentsClient(
@@ -569,14 +537,18 @@ def update_intent(
         obj: types.Intent = None,
         language_code: str = None,
         **kwargs) -> types.Intent:
-        """Updates a single Intent object based on provided args.
+        """Updates a single Intent object based on provided arguments.
+
         Args:
-          intent_id, the destination Intent ID. Must be formatted properly
+          intent_id: the destination Intent ID. Must be formatted properly
             for Intent IDs in CX.
-          obj, The CX Intent object in proper format. This can also be
+          obj: The CX Intent object in proper format. This can also be
             extracted by using the get_intent() method.
           language_code: Language code of the intents being uploaded. Ref:
             https://cloud.google.com/dialogflow/cx/docs/reference/language
+
+        Returns:
+          The updated intent object.
         """
         if obj:
             intent = obj
@@ -614,7 +586,7 @@ def delete_intent(self, intent_id: str, obj: types.Intent = None) -> None:
         """Deletes an intent by Intent ID.
 
         Args:
-          intent_id, intent to delete
+          intent_id: intent to delete
         """
         if obj:
             intent_id = obj.name
@@ -629,52 +601,64 @@ def bulk_intent_to_df(
         self,
         agent_id: str = None,
         mode: str = "basic",
-        intent_subset:list = None,
-        language_code:str = None) -> pd.DataFrame:
+        intent_subset: List[str] = None,
+        transpose: bool = False,
+        language_code: str = None) -> pd.DataFrame:
         """Extracts all Intents and Training Phrases into a Pandas DataFrame.
 
         Args:
-          agent_id, agent to pull list of intents
-          mode: (Optional) basic returns display name and training phrase as
-            plain text.
-          Advanced returns training phrase and parameters df broken out by
-            parts.
-          language_code: Language code of the intents being uploaded. Ref:
+          agent_id (str):
+            agent to pull list of intents
+          mode (str):
+            "basic" returns display name and training phrase as plain text.
+            "advanced" returns training phrases broken out by parts
+            with their parameters included.
+          intent_subset (List[str]):
+            A subset of intents to extract the intents from.
+          transpose (bool):
+            Return the transposed DataFrame. If this flag passed as True,
+            mode won't affect the result and the result would be like basic.
+          language_code (str):
+            Language code of the intents being uploaded. Ref:
             https://cloud.google.com/dialogflow/cx/docs/reference/language
+
+        Returns:
+          In basic mode, a Pandas DataFrame with columns:
+            display_name, training phrase
+          In advanced mode, a Pandas DataFrame with columns:
+            name, display_name, description, priority,
+            is_fallback, labels, id, repeat_count,
+            training_phrase_idx, text, text_idx,
+            parameter_id, entity_type, is_list, redact
         """
 
         if not agent_id:
             agent_id = self.agent_id
 
-        intents = self.list_intents(agent_id, language_code=language_code)
-        if mode == "basic":
-            main_frame = pd.DataFrame()
-            for obj in intents:
-                if (intent_subset) and (obj.display_name not in intent_subset):
-                    continue
-
-                data_frame = self.intent_proto_to_dataframe(obj, mode=mode)
-                main_frame = main_frame.append(data_frame)
-            main_frame = main_frame.sort_values(
-                ["display_name", "training_phrase"])
-            return main_frame
+        if transpose:
+            _, intents_dict = self.intents_to_df_cosine_prep(agent_id)
+            transposed_df = pd.DataFrame.from_dict(
+                intents_dict, "index"
+            ).transpose()
+            if intent_subset:
+                transposed_df = transposed_df[intent_subset]
 
-        elif mode == "advanced":
-            master_phrases = pd.DataFrame()
-            master_parameters = pd.DataFrame()
-            for obj in intents:
-                if (intent_subset) and (obj.display_name not in intent_subset):
-                    continue
-                output = self.intent_proto_to_dataframe(obj, mode="advanced")
-                master_phrases = master_phrases.append(output["phrases"])
-                master_parameters = master_parameters.append(
-                    output["parameters"]
-                )
-            return {"phrases": master_phrases, "parameters": master_parameters}
+            return transposed_df
 
-        else:
+        if mode not in ["basic", "advanced"]:
             raise ValueError("Mode types: [basic, advanced]")
 
+        main_df = pd.DataFrame()
+        intents = self.list_intents(agent_id, language_code=language_code)
+
+        for obj in intents:
+            if (intent_subset) and (obj.display_name not in intent_subset):
+                continue
+            intent_df = self.intent_proto_to_dataframe(obj, mode=mode)
+            main_df = pd.concat([main_df, intent_df], ignore_index=True)
+
+        return main_df
+
     def intents_to_df_cosine_prep(
         self,
         agent_id: str = None) -> Tuple[pd.DataFrame, Dict[str,str]]:
@@ -682,12 +666,13 @@ def intents_to_df_cosine_prep(
         Similarity tools.
 
         Args:
-          agent_id, agent to pull list of intents from
+          agent_id: agent to pull list of intents from
 
         Returns:
-          df, a Pandas Dataframe of Intents and TPs
-          intent_dict, a Defaultdict(List) that is prepped to feed to the
-            Cosine similarity tool (offline)
+          A tuple containing two values:
+            A Pandas Dataframe of Intents and TPs
+            A Defaultdict(List) that is prepped to feed to the
+              Cosine similarity tool (offline)
         """
         if not agent_id:
             agent_id = self.agent_id
diff --git a/src/dfcx_scrapi/core/operations.py b/src/dfcx_scrapi/core/operations.py
index 641b048b..8b76ee5c 100644
--- a/src/dfcx_scrapi/core/operations.py
+++ b/src/dfcx_scrapi/core/operations.py
@@ -54,7 +54,7 @@ def get_lro(self, lro: str):
                 <operation-uuid>'
 
         Returns:
-        response: Response status and payload from LRO
+          Response status and payload from LRO
         """
         location = lro.split("/")[3]
         if location != "global":
diff --git a/src/dfcx_scrapi/core/pages.py b/src/dfcx_scrapi/core/pages.py
index b94d7da3..30ace676 100644
--- a/src/dfcx_scrapi/core/pages.py
+++ b/src/dfcx_scrapi/core/pages.py
@@ -16,11 +16,11 @@
 
 import logging
 from typing import Dict, List
-from google.cloud.dialogflowcx_v3beta1 import services
-from google.cloud.dialogflowcx_v3beta1 import types
+from google.cloud.dialogflowcx_v3beta1.services import pages
+from google.cloud.dialogflowcx_v3beta1.types import page as gcdc_page
 from google.protobuf import field_mask_pb2
 
-from dfcx_scrapi.core.scrapi_base import ScrapiBase
+from dfcx_scrapi.core import scrapi_base
 
 # logging config
 logging.basicConfig(
@@ -29,14 +29,15 @@
     datefmt="%Y-%m-%d %H:%M:%S",
 )
 
-class Pages(ScrapiBase):
+
+class Pages(scrapi_base.ScrapiBase):
     """Core Class for CX Page Resource functions."""
 
     def __init__(
         self,
         creds_path: str = None,
         creds_dict: Dict = None,
-        scope=False,
+        scope=None,
         creds=None,
         page_id: str = None,
         flow_id: str = None,
@@ -48,27 +49,47 @@ def __init__(
             scope=scope,
         )
 
-        if page_id:
-            self.page_id = page_id
-            self.client_options = self._set_region(page_id)
+        self.page_id = page_id
+        self.flow_id = flow_id
+
+    @staticmethod
+    def _add_generic_pages_to_map(flow_id, pages_map, reverse):
+        """Add the generic page names to each Page map.
+
+        Dialogflow CX contains a few `special` pages names that are reserved
+        and do not have UUID4 format IDs. This will take the existing page
+        map and insert them in for downstream lookups.
+
+        Args:
+          flow_id: The Flow ID that contains the Pages in the pages_map
+          pages_map: The existing pages_map Dict from `get_pages_map` that
+            we will add the new special pages to.
+          reverse: Boolean flag to swap key:value -> value:key
+        """
+        page_names = ["START_PAGE", "END_FLOW", "END_SESSION"]
+
+        if reverse:
+            for page in page_names:
+                pages_map[page] = f"{flow_id}/pages/{page}"
+        else:
+            for page in page_names:
+                pages_map[f"{flow_id}/pages/{page}"] = page
 
-        if flow_id:
-            self.flow_id = flow_id
+        return pages_map
 
     def get_pages_map(
-        self,
-        flow_id: str = None,
-        reverse=False) -> Dict[str, str]:
+        self, flow_id: str = None, reverse=False
+    ) -> Dict[str, str]:
         """Exports Agent Page UUIDs and Names into a user friendly dict.
 
         Args:
-          - flow_id, the formatted CX Agent Flow ID to use
-          - reverse, (Optional) Boolean flag to swap key:value -> value:key
+          flow_id: the formatted CX Agent Flow ID to use
+          reverse: (Optional) Boolean flag to swap key:value -> value:key
 
         Returns:
-          - webhooks_map, Dictionary containing Webhook UUIDs as keys and
-              webhook.display_name as values. If Optional reverse=True, the
-              output will return page_name:ID mapping instead of ID:page_name
+          Dictionary containing Page UUIDs as keys and display names as values.
+          If Optional reverse=True, the output will return page_name:ID mapping
+          instead of ID:page_name
         """
         if not flow_id:
             flow_id = self.flow_id
@@ -85,22 +106,26 @@ def get_pages_map(
                 for page in self.list_pages(flow_id)
             }
 
+        pages_dict = self._add_generic_pages_to_map(
+            flow_id, pages_dict, reverse
+        )
+
         return pages_dict
 
-    def list_pages(self, flow_id: str = None) -> List[types.Page]:
+    def list_pages(self, flow_id: str = None) -> List[gcdc_page.Page]:
         """Get a List of all pages for the specified Flow ID.
 
         Args:
-          flow_id, the properly formatted Flow ID string
+          flow_id: the properly formatted Flow ID string
 
         Returns:
-          cx_pages, A List of CX Page objects for the specific Flow ID
+          A List of CX Page objects for the specific Flow ID
         """
-        request = types.page.ListPagesRequest()
+        request = gcdc_page.ListPagesRequest()
         request.parent = flow_id
 
         client_options = self._set_region(flow_id)
-        client = services.pages.PagesClient(
+        client = pages.PagesClient(
             credentials=self.creds, client_options=client_options
         )
         response = client.list_pages(request)
@@ -112,20 +137,20 @@ def list_pages(self, flow_id: str = None) -> List[types.Page]:
 
         return cx_pages
 
-    def get_page(self, page_id: str = None) -> types.Page:
+    def get_page(self, page_id: str = None) -> gcdc_page.Page:
         """Get a single CX Page object based on the provided Page ID.
 
         Args:
-          page_id, a properly formatted CX Page ID
+          page_id: a properly formatted CX Page ID
 
         Returns:
-          response, a single CX Page Object of types.Page
+          A single CX Page Object
         """
         if not page_id:
             page_id = self.page_id
 
         client_options = self._set_region(page_id)
-        client = services.pages.PagesClient(
+        client = pages.PagesClient(
             credentials=self.creds, client_options=client_options
         )
 
@@ -134,18 +159,16 @@ def get_page(self, page_id: str = None) -> types.Page:
         return response
 
     def create_page(
-        self,
-        flow_id: str = None,
-        obj: types.Page = None,
-        **kwargs) -> types.Page:
+        self, flow_id: str = None, obj: gcdc_page.Page = None, **kwargs
+    ) -> gcdc_page.Page:
         """Create a single CX Page object in the specified Flow ID.
 
         Args:
-          flow_id, the CX Flow ID where the Page object will be created
-          obj, (Optional) a CX Page object of types.Page
+          flow_id: the CX Flow ID where the Page object will be created
+          obj: (Optional) a CX Page object of gcdc_page.Page
 
         Returns:
-          response, a copy of the successful Page object that was created
+          A copy of the successful Page object that was created
         """
         if not flow_id:
             flow_id = self.flow_id
@@ -154,13 +177,13 @@ def create_page(
             page = obj
             page.name = ""
         else:
-            page = types.page.Page()
+            page = gcdc_page.Page()
 
         for key, value in kwargs.items():
             setattr(page, key, value)
 
         client_options = self._set_region(flow_id)
-        client = services.pages.PagesClient(
+        client = pages.PagesClient(
             credentials=self.creds, client_options=client_options
         )
 
@@ -169,18 +192,16 @@ def create_page(
         return response
 
     def update_page(
-        self,
-        page_id: str = None,
-        obj: types.Page = None,
-        **kwargs) -> types.Page:
+        self, page_id: str = None, obj: gcdc_page.Page = None, **kwargs
+    ) -> gcdc_page.Page:
         """Update a single CX Page object.
 
         Args:
-          page_id, the CX Page ID to update
-          obj, (Optional) a CX Page object of types.Page
+          page_id: the CX Page ID to update
+          obj: (Optional) a CX Page object of gcdc_page.Page
 
         Returns:
-          response, a copy of the successful Page object that was created
+          A copy of the successful Page object that was created
         """
         if obj:
             page = obj
@@ -196,10 +217,29 @@ def update_page(
         mask = field_mask_pb2.FieldMask(paths=paths)
 
         client_options = self._set_region(page_id)
-        client = services.pages.PagesClient(
+        client = pages.PagesClient(
             credentials=self.creds, client_options=client_options
         )
 
         response = client.update_page(page=page, update_mask=mask)
 
         return response
+
+    def delete_page(self, page_id: str = None) -> str:
+        """Deletes the specified Page.
+
+        Args:
+          page_id: CX Page ID string in the following Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              flows/<Flow ID>/pages/<Page ID>``
+
+        Returns:
+          String "Page `{page_id}` successfully deleted."
+        """
+        client_options = self._set_region(page_id)
+        client = pages.PagesClient(
+            credentials=self.creds, client_options=client_options
+        )
+        client.delete_page(name=page_id)
+
+        return f"Page `{page_id}` successfully deleted."
diff --git a/src/dfcx_scrapi/core/project.py b/src/dfcx_scrapi/core/project.py
index 4c719cda..46a760a6 100644
--- a/src/dfcx_scrapi/core/project.py
+++ b/src/dfcx_scrapi/core/project.py
@@ -64,7 +64,7 @@ def list_agents(self, project_id=None):
             self.project_id or error.
 
         Returns:
-          all_agents: A List of Dicts that has all agent metadata info.
+          A List of Dicts that has all agent metadata info.
 
         """
         region_list = [
@@ -96,7 +96,7 @@ def backup_all_agents(self, gcs_bucket: str, project_id: str = None):
           gcs_bucket: The GCS bucket to backup all files to
 
         Returns:
-          lro_list: List of all LROs to reference backend job status
+          List of all LROs to reference backend job status
         """
         if not project_id:
             project_id = self.project_id
diff --git a/src/dfcx_scrapi/core/scrapi_base.py b/src/dfcx_scrapi/core/scrapi_base.py
index 027a4375..0dac2409 100644
--- a/src/dfcx_scrapi/core/scrapi_base.py
+++ b/src/dfcx_scrapi/core/scrapi_base.py
@@ -16,6 +16,7 @@
 
 import logging
 import json
+import re
 
 from typing import Dict
 from google.oauth2 import service_account
@@ -24,6 +25,8 @@
 
 from proto.marshal.collections import repeated
 from proto.marshal.collections import maps
+
+
 class ScrapiBase:
     """Core Class for managing Auth and other shared functions."""
 
@@ -35,8 +38,8 @@ class ScrapiBase:
     def __init__(
         self,
         creds_path: str = None,
-        creds_dict: Dict[str,str] = None,
-        creds: service_account.Credentials =None,
+        creds_dict: Dict[str, str] = None,
+        creds: service_account.Credentials = None,
         scope=False,
         agent_id=None,
     ):
@@ -70,14 +73,16 @@ def __init__(
 
     @staticmethod
     def _set_region(item_id):
-        """different regions have different API endpoints
+        """Different regions have different API endpoints
 
         Args:
-            item_id: agent/flow/page - any type of long path id like
-                `projects/<GCP PROJECT ID>/locations/<LOCATION ID>
+          item_id: agent/flow/page - any type of long path id like
+            `projects/<GCP PROJECT ID>/locations/<LOCATION ID>
 
         Returns:
-            client_options: use when instantiating other library client objects
+          A dictionary containing the api_endpoint to use when
+          instantiating other library client objects, or None
+          if the location is "global"
         """
         try:
             location = item_id.split("/")[3]
@@ -95,7 +100,7 @@ def _set_region(item_id):
 
     @staticmethod
     def pbuf_to_dict(pbuf):
-        """extractor of json from a protobuf"""
+        """Extractor of json from a protobuf"""
         blobstr = json_format.MessageToJson(
             pbuf
         )  # i think this returns JSON as a string
@@ -104,20 +109,139 @@ def pbuf_to_dict(pbuf):
 
     @staticmethod
     def cx_object_to_json(cx_object):
-        """response objects have a magical _pb field attached"""
+        """Response objects have a magical _pb field attached"""
         return ScrapiBase.pbuf_to_dict(cx_object._pb)  # pylint: disable=W0212
 
     @staticmethod
     def cx_object_to_dict(cx_object):
-        """response objects have a magical _pb field attached"""
+        """Response objects have a magical _pb field attached"""
         return ScrapiBase.pbuf_to_dict(cx_object._pb)  # pylint: disable=W0212
 
     @staticmethod
     def extract_payload(msg):
-        """convert to json so we can get at the object"""
+        """Convert to json so we can get at the object"""
         blob = ScrapiBase.cx_object_to_dict(msg)
         return blob.get("payload")  # deref for nesting
 
+    @staticmethod
+    def _parse_resource_path(
+        resource_type,
+        resource_id,
+        validate=True) -> Dict[str, str]:
+        # pylint: disable=line-too-long
+        """Validates the provided Resource ID against known patterns.
+
+        Args:
+          resource_type, Must be one of the following resource types:
+            `agent`, `entity_type`, `environmnet`, `flow`, `intent`, `page`,
+            `project`, `security_setting`, `session`, `session_entity_type`,
+            `test_case`, `transition_route_group`, `version`, `webhook`
+          resource_id, The CX resource ID to check against the provided
+            resource_type
+          validate, allows the user to have their Resource ID validated along
+            with returning the parts dictionary of the Resource ID. If set to
+            True, this method will prompt the user with the correct format
+            to utilize for the specified ID. If set to False, no validation
+            will occur. If the input Resource ID is invalid when set to False,
+            an empty Dictionary will be returned, allowing the caller to
+            define their own ValueError message in a higher level class.
+            Defaults to True.
+        """
+
+        standard_id_match = r"[-0-9a-f]{1,36}"
+        page_id_match = r"[-0-9a-f]{1,36}|START_PAGE|END_SESSION|END_FLOW"
+        entity_id_match = r"[-@.0-9a-z]{1,36}"
+        location_id_match = r"[-0-9a-z]{1,36}"
+        session_id_match = r"[-0-9a-zA-Z!@#$%^&*()_+={}[\]:;\"'<>,.?]{1,36}"
+        version_id_match = r"[0-9]{1,4}"
+
+        matcher_root = f"^projects/(?P<project>.+?)/locations/(?P<location>{location_id_match})"
+
+        pattern_map = {
+            "agent": {
+                "matcher": fr"{matcher_root}/agents/(?P<agent>{standard_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`",
+            },
+            "entity_type": {
+                "matcher": fr"{matcher_root}/agents/(?P<agent>{standard_id_match})/entityTypes/(?P<entity>{entity_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/entityTypes/<Entity Types ID>`",
+            },
+            "environment": {
+                "matcher": fr"{matcher_root}/agents/(?P<agent>{standard_id_match})/environments/(?P<environment>{standard_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/environments/<Environment ID>`",
+            },
+            "flow": {
+                "matcher": fr"{matcher_root}/agents/(?P<agent>{standard_id_match})/flows/(?P<flow>{standard_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/flows/<Flow ID>`",
+            },
+            "intent": {
+                "matcher": fr"{matcher_root}/agents/(?P<agent>{standard_id_match})/intents/(?P<intent>{standard_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/intents/<Intent ID>`",
+            },
+            "page": {
+                "matcher": fr"{matcher_root}/agents/(?P<agent>{standard_id_match})/flows/(?P<flow>{standard_id_match})/pages/(?P<page>{page_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/flows/<Flow ID>/pages/<Page ID>`",
+            },
+            "project": {
+                "matcher": fr"{matcher_root}$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/`",
+            },
+            "security_setting": {
+                "matcher": fr"{matcher_root}/securitySettings/(?P<security_setting>{standard_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/securitySettings/<Security Setting ID>`",
+            },
+            "session": {
+                "matcher": fr"{matcher_root}/agents/(?P<agent>{standard_id_match})/sessions/(?P<session>{session_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/sessions/<Session ID>`",
+            },
+            "session_entity_type": {
+                "matcher": fr"{matcher_root}/agents/(?P<agent>{standard_id_match})/sessions/(?P<session>{session_id_match})/entityTypes/(?P<entity>{entity_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/sessions/<Session ID>/entityTypes/<Entity Type ID>`",
+            },
+            "test_case": {
+                "matcher": fr"{matcher_root}/agents/(?P<agent>{standard_id_match})/testCases/(?P<test_case>{standard_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/testCases/<Test Case ID>`",
+            },
+            "transition_route_group": {
+                "matcher": fr"{matcher_root}/agents/(?P<agent>{standard_id_match})/flows/(?P<flow>{standard_id_match})/transitionRouteGroups/(?P<transition_route_group>{standard_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/flows/<Flow ID>/transitionRouteGroups/<Transition Route Group ID>`",
+            },
+            "version": {
+                "matcher": fr"{matcher_root}/agents/(?P<agent>{standard_id_match})/flows/(?P<flow>{standard_id_match})/versions/(?P<version>{version_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/flows/<Flow ID>`",
+            },
+            "webhook": {
+                "matcher": fr"{matcher_root}/agents/(?P<agent>{standard_id_match})/webhooks/(?P<webhook>{standard_id_match})$",
+                "format": "`projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`",
+            }
+        }
+
+        if resource_type not in pattern_map:
+            raise KeyError(
+                "`resource_type` must be one of the following resource types:"
+                " `agent`, `entity_type`, `environmnet`, `flow`, `intent`,"
+                "`page`,`project`, `security_setting`, `session`, "
+                "`session_entity_type`,`test_case`, `transition_route_group`, "
+                "`version`, `webhook`"
+            )
+
+        match_res = re.match(pattern_map[resource_type]["matcher"], resource_id)
+        dict_res = match_res.groupdict() if match_res else {}
+        valid_parse = False
+
+        if dict_res:
+            valid_parse = True
+
+        if validate and not valid_parse:
+            raise ValueError(
+                f"{resource_type.capitalize()} ID must be provided in the "
+                f"following format: "
+                f"{pattern_map[resource_type]['format']}"
+            )
+
+        # pylint: enable=line-too-long
+        return dict_res
+
     def recurse_proto_repeated_composite(self, repeated_object):
         repeated_list = []
         for item in repeated_object:
@@ -134,7 +258,7 @@ def recurse_proto_repeated_composite(self, repeated_object):
 
     def recurse_proto_marshal_to_dict(self, marshal_object):
         new_dict = {}
-        for k,v in marshal_object.items():
+        for k, v in marshal_object.items():
             if isinstance(v, maps.MapComposite):
                 v = self.recurse_proto_marshal_to_dict(v)
             elif isinstance(v, repeated.RepeatedComposite):
diff --git a/src/dfcx_scrapi/core/security_settings.py b/src/dfcx_scrapi/core/security_settings.py
index 317b95be..9ea2f6cc 100644
--- a/src/dfcx_scrapi/core/security_settings.py
+++ b/src/dfcx_scrapi/core/security_settings.py
@@ -58,11 +58,11 @@ def list_security_settings(self, location_id: str):
         """List Security Settings for a given Project and Region.
 
         Args:
-            location_id: CX Agent ID string in the following format
+          location_id: CX Agent ID string in the following format
             projects/<PROJECT ID>/locations/<LOCATION ID>/
 
         Returns:
-            Dict of results
+          List of security settings
         """
 
         request = self.ss_types.ListSecuritySettingsRequest()
@@ -87,12 +87,12 @@ def get_security_settings(self, security_setting_id: str):
         """Get specified CCAI Security Setting.
 
         Args:
-            security_setting_id: Security Setting ID string in the following
-                format: projects/<PROJECT ID>/locations/<LOCATION ID>/
-                securitySettings/<SECURITY SETTINGS ID>
+          security_setting_id: Security Setting ID string in the following
+            format: projects/<PROJECT ID>/locations/<LOCATION ID>/
+            securitySettings/<SECURITY SETTINGS ID>
 
         Returns:
-            A single Security Settings object of types.SecuritySettings
+          A single Security Settings object of types.SecuritySettings
         """
 
         request = self.ss_types.GetSecuritySettingsRequest()
@@ -118,16 +118,16 @@ def create_security_settings(
         new security settings profile.
 
         Args:
-            location_id: CX Agent ID string in the following format:
-              projects/<PROJECT ID>/locations/<LOCATION ID>/
-            obj: An object of types.SecuritySettings representing the Security
+          location_id: CX Agent ID string in the following format:
+            projects/<PROJECT ID>/locations/<LOCATION ID>/
+          obj: An object of types.SecuritySettings representing the Security
             Settings object to be created
-            security_settings_dict: An optional dictionary of key/value pairs
+          security_settings_dict: An optional dictionary of key/value pairs
             that correspond to the fields and values necessary for creating a
             new Security Settings profile
 
         Returns:
-            A single Security Settings object of types.SecuritySettings
+          A single Security Settings object of types.SecuritySettings
         """
 
         if obj and security_settings_dict:
@@ -162,12 +162,12 @@ def update_security_settings(self, security_setting_id: str, **kwargs):
         """Update specified CCAI Security Setting.
 
         Args:
-            security_setting_id: Security Setting ID string in the following
-                format: projects/<PROJECT ID>/locations/<LOCATION ID>/
-                securitySettings/<SECURITY SETTINGS ID>
+          security_setting_id: Security Setting ID string in the following
+            format: projects/<PROJECT ID>/locations/<LOCATION ID>/
+            securitySettings/<SECURITY SETTINGS ID>
 
         Returns:
-            A single Security Settings object of types.SecuritySettings
+          A single Security Settings object of types.SecuritySettings
         """
 
         security_settings = self.get_security_settings(security_setting_id)
@@ -197,8 +197,8 @@ def delete_security_settings(self, security_setting_id: str):
 
         Args:
           security_setting_id: Security Setting ID string in the following
-                format: projects/<PROJECT ID>/locations/<LOCATION ID>/
-                securitySettings/<SECURITY SETTINGS ID>
+            format: projects/<PROJECT ID>/locations/<LOCATION ID>/
+            securitySettings/<SECURITY SETTINGS ID>
         """
         request = self.ss_types.DeleteSecuritySettingsRequest()
         request.name = security_setting_id
diff --git a/src/dfcx_scrapi/core/session_entity_types.py b/src/dfcx_scrapi/core/session_entity_types.py
new file mode 100644
index 00000000..49c61f53
--- /dev/null
+++ b/src/dfcx_scrapi/core/session_entity_types.py
@@ -0,0 +1,400 @@
+"""Session Entity Types Resource functions."""
+
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+from typing import Dict, List
+
+from google.oauth2 import service_account
+from google.cloud.dialogflowcx_v3beta1 import services
+from google.cloud.dialogflowcx_v3beta1 import types
+from google.protobuf import field_mask_pb2
+
+from dfcx_scrapi.core import scrapi_base
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+
+class SessionEntityTypes(scrapi_base.ScrapiBase):
+    """Core Class for CX Session Entity Type functions.
+    https://cloud.google.com/dialogflow/cx/docs/concept/entity-session
+    """
+
+    def init(
+        self,
+        creds_path: str = None,
+        creds_dict: Dict = None,
+        creds: service_account.Credentials = None,
+        scope: bool = False,
+        agent_id: str = None,
+    ):
+
+        super().__init__(
+            creds_path=creds_path,
+            creds_dict=creds_dict,
+            creds=creds,
+            scope=scope,
+        )
+
+        self.agent_id = agent_id
+
+    def _merge_session_id_and_env_id(
+        self, session_id: str, environment_id: str
+    ) -> str:
+        """Merges the Session ID and Environment ID into a valid Session ID.
+
+        When using DFCX Environments, the Session ID needs to be modified to
+        include the Environment ID as part of its fully qualified path. Use
+        this method to merge the 2 IDs into a valid Session ID when a non-DRAFT
+        environment is in use.
+
+        session_id, The session to list all session entity types from.
+            Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              sessions/<Session ID>``
+          environment_id, The Environment associated with the Session ID to
+            list all session entity types from. Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              environments/<Environment ID>`
+        """
+        env_parts = self._parse_resource_path("environment", environment_id)
+        session_parts = self._parse_resource_path("session", session_id)
+
+        parent_id = (
+            f"projects/{session_parts['project']}/locations/"
+            f"{session_parts['location']}/agents/{session_parts['agent']}"
+            f"/environments/{env_parts['environment']}/sessions/"
+            f"{session_parts['session']}"
+        )
+
+        return parent_id
+
+    def _merge_session_entity_id_and_env_id(
+        self, session_entity_type_id: str, environment_id: str
+    ) -> str:
+        """Merges the Session Entity Type ID and Environment ID.
+
+        When using DFCX Environments, the Session Entity Type ID needs to be
+        modified to include the Environment ID as part of its fully qualified
+        path. Use this method to merge the 2 IDs into a valid Session Entity
+        Type ID when a non-DRAFT environment is in use.
+
+        Args:
+          session_entity_type_id, The Session Entity Type ID to merge.
+            Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              sessions/<Session ID>/entityTypes/<Entity Type ID>``
+          environment_id, The Environment associated with the Session Entity
+            Type ID to merge. Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              environments/<Environment ID>`
+        """
+        entity_parts = self._parse_resource_path(
+            "session_entity_type", session_entity_type_id
+        )
+        environment_parts = self._parse_resource_path(
+            "environment", environment_id
+        )
+
+        parent_id = (
+            f"projects/{environment_parts['project']}/"
+            f"locations/{environment_parts['location']}/"
+            f"agents/{environment_parts['agent']}/"
+            f"environments/{environment_parts['environment']}/"
+            f"sessions/{environment_parts['session']}/"
+            f"entityTypes/{entity_parts['entity']}"
+        )
+
+        return parent_id
+
+    def build_session_entity_type(
+        self,
+        session_id: str,
+        entity_id: str,
+        entity_dict: Dict[str, List[str]],
+        environment_id: str = None,
+        entity_override_mode: str = "Override",
+    ) -> types.SessionEntityType:
+        """Builds a Session Entity Type object based on simple inputs.
+
+        Args:
+          session_id, The session to list all session entity types from.
+            Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              sessions/<Session ID>``
+          entity_id, The Entity ID to replace or extend that currently exists
+            in the DFCX Agent.
+          entity_dict, The Values and associated Synonyms for the Session
+          Entities to be created.
+            Ex:
+              [1] {'scallions': ['green onions']}
+              [2] {'fruit': ['apple','orange'],
+                'vegetables': ['cabbage','celery']}
+          environment_id, The Environment associated with the Session ID to
+            list all session entity types from. Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              environments/<Environment ID>`
+            If ``Environment ID`` is not specified, we assume default 'DRAFT'
+              environment.
+          entity_override_mode, ONEOF: `Override`, `Supplement`
+            `Override` will override the current collection of Session Entity
+              Types for the corresponding entity type.
+            `Supplement` will extend the current collection of Session Entity
+              Types for the corresponding entity type.
+        """
+        if environment_id:
+            parent_id = self._merge_session_id_and_env_id(
+                session_id, environment_id
+            )
+
+        else:
+            parent_id = session_id
+
+        # Append Entity ID to Parent ID
+        entity_parts = self._parse_resource_path("entity_type", entity_id)
+        parent_id += f"/entityTypes/{entity_parts['entity']}"
+
+        st = types.SessionEntityType()
+        st.name = parent_id
+
+        entity_override_map = {
+            "Override": "ENTITY_OVERRIDE_MODE_OVERRIDE",
+            "Supplement": "ENTITY_OVERRIDE_MODE_SUPPLEMENT",
+        }
+
+        st.entity_override_mode = entity_override_map[entity_override_mode]
+
+        entity_list = []
+
+        for k, v in entity_dict.items():
+            ent = types.EntityType.Entity()
+            ent.value = k
+            ent.synonyms = v
+            entity_list.append(ent)
+
+        st.entities = entity_list
+
+        return st
+
+    def list_session_entity_types(
+        self, session_id: str, environment_id: str = None
+    ) -> List[types.SessionEntityType]:
+        """Lists all Session Entities currently active in the Session.
+
+        Args:
+          session_id, The session to list all session entity types from.
+            Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              sessions/<Session ID>``
+          environment_id, The Environment associated with the Session ID to
+            list all session entity types from. Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              environments/<Environment ID>`
+            If ``Environment ID`` is not specified, we assume default 'DRAFT'
+              environment.
+        """
+        if environment_id:
+            parent_id = self._merge_session_id_and_env_id(
+                session_id, environment_id
+            )
+
+        else:
+            parent_id = session_id
+
+        request = types.session_entity_type.ListSessionEntityTypesRequest()
+        request.parent = parent_id
+
+        client_options = self._set_region(session_id)
+        client = services.session_entity_types.SessionEntityTypesClient(
+            credentials=self.creds, client_options=client_options
+        )
+
+        response = client.list_session_entity_types(request)
+
+        session_entities = []
+        for page in response.pages:
+            print(page)
+            # for entity in page.entity_types:
+            #     session_entities.append(entity)
+
+        return session_entities
+
+    def get_session_entity_type(
+        self, session_entity_type_id: str, environment_id: str = None
+    ) -> types.SessionEntityType:
+        """Retrieves the specified Session Entity Type.
+
+        Args:
+          session_entity_type_id, The Session Entity Type ID to retrieve.
+            Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+            sessions/<Session ID>/entityTypes/<Entity Type ID>``
+          environment_id, The Environment associated with the Session ID to
+            list all session entity types from. Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              environments/<Environment ID>`
+            If ``Environment ID`` is not specified, we assume default 'DRAFT'
+              environment.
+        """
+        if environment_id:
+            parent_id = self._merge_session_entity_id_and_env_id(
+                session_entity_type_id, environment_id
+            )
+        else:
+            parent_id = session_entity_type_id
+
+        request = types.GetSessionEntityTypeRequest()
+        request.name = parent_id
+
+        client_options = self._set_region(session_entity_type_id)
+        client = services.session_entity_types.SessionEntityTypesClient(
+            credentials=self.creds, client_options=client_options
+        )
+
+        response = client.get_session_entity_type(request)
+
+        return response
+
+    def create_session_entity_type(
+        self, session_id: str, session_entity_type: types.SessionEntityType
+    ) -> types.SessionEntityType:
+        """Creates a Session Entity Type object from provided inputs.
+        Args:
+          session_id, The session to list all session entity types from.
+            Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              sessions/<Session ID>``
+          session_entity_type, The Session Entity Type object to create for the
+            provided session. Use `build_session_entity_type` to create a new
+            object.
+
+        """
+        request = types.CreateSessionEntityTypeRequest()
+        request.parent = session_id
+        request.session_entity_type = session_entity_type
+
+        client_options = self._set_region(session_id)
+        client = services.session_entity_types.SessionEntityTypesClient(
+            credentials=self.creds, client_options=client_options
+        )
+
+        response = client.create_session_entity_type(request)
+
+        return response
+
+    def update_session_entity_type(
+        self,
+        session_entity_type_id: str,
+        environment_id: str = None,
+        obj: types.SessionEntityType = None,
+        **kwargs,
+    ) -> types.SessionEntityType:
+        """Updates the specified Session Entity Type object.
+
+        This method will update the specific Sesssion Entity Type object based
+        on the provided user inputs. If the user provides the entier Session
+        Entity Type object, the entire existing object will be updated.
+        Alternatively, kwargs can be provided to only update specific portions
+        of the Session Entity Type object.
+
+        Ref: https://github.com/googleapis/python-dialogflow-cx/blob/main/
+          google/cloud/dialogflowcx_v3beta1/types/session_entity_type.py#L36
+
+        Args:
+          session_entity_type_id, the ID of the Session Entity Type to update
+          Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              sessions/<Session ID>/entityTypes/<Entity Type ID>`
+          environment_id, The Environment associated with the Session Entity
+            Type ID to update. Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              environments/<Environment ID>`
+            If ``Environment ID`` is not specified, we assume default 'DRAFT'
+              environment.
+          obj, the Session Entity Type object to send as update
+        """
+        if environment_id:
+            parent_id = self._merge_session_entity_id_and_env_id(
+                session_entity_type_id, environment_id
+            )
+        else:
+            parent_id = session_entity_type_id
+
+        if obj:
+            session_entity_type = obj
+            session_entity_type.name = parent_id
+        else:
+            session_entity_type = self.get_session_entity_type(parent_id)
+
+        # set agent attributes to args
+        for key, value in kwargs.items():
+            setattr(session_entity_type, key, value)
+        paths = kwargs.keys()
+        mask = field_mask_pb2.FieldMask(paths=paths)
+
+        client_options = self._set_region(parent_id)
+        client = services.session_entity_types.SessionEntityTypesClient(
+            credentials=self.creds, client_options=client_options
+        )
+        request = types.UpdateSessionEntityTypeRequest()
+        request.session_entity_type = parent_id
+        request.update_mask = mask
+
+        response = client.update_session_entity_type(request)
+
+        return response
+
+    def delete_session_entity_type(
+        self, session_entity_type_id: str, environment_id: str = None
+    ) -> str:
+        """Deletes the specified Session Entity Type.
+
+        Args:
+          session_entity_type_id, the ID of the Session Entity Type to update
+            Format:
+              ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+                sessions/<Session ID>/entityTypes/<Entity Type ID>`
+            environment_id, The Environment associated with the Session Entity
+              Type ID to update. Format:
+              ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+                environments/<Environment ID>`
+              If ``Environment ID`` is not specified, we assume default 'DRAFT'
+                environment.
+        """
+        if environment_id:
+            parent_id = self._merge_session_entity_id_and_env_id(
+                session_entity_type_id, environment_id
+            )
+
+        else:
+            parent_id = session_entity_type_id
+
+        client_options = self._set_region(session_entity_type_id)
+        client = services.session_entity_types.SessionEntityTypesClient(
+            credentials=self.creds, client_options=client_options
+        )
+
+        request = types.DeleteSessionEntityTypeRequest()
+        request.name = parent_id
+
+        client.delete_session_entity_type(request)
+
+        return f"Session Entity Type {session_entity_type_id} successfully "\
+            "deleted."
diff --git a/src/dfcx_scrapi/core/sessions.py b/src/dfcx_scrapi/core/sessions.py
index d74efa39..10ee35b7 100644
--- a/src/dfcx_scrapi/core/sessions.py
+++ b/src/dfcx_scrapi/core/sessions.py
@@ -15,6 +15,7 @@
 # limitations under the License.
 
 import logging
+import uuid
 from typing import Dict, List
 from google.cloud.dialogflowcx_v3beta1 import services
 from google.cloud.dialogflowcx_v3beta1 import types
@@ -36,15 +37,60 @@ def __init__(
         creds_path: str = None,
         creds_dict: Dict = None,
         scope=False,
-        session_id: str = None,
+        agent_id: str = None,
+        session_id: str = None
     ):
         super().__init__(
             creds_path=creds_path, creds_dict=creds_dict, scope=scope
         )
 
-        if session_id:
+        self.session_id = session_id
+        self.agent_id = agent_id
+
+    @property
+    def session_id(self):
+        return self._session_id
+
+    @session_id.setter
+    def session_id(self, value):
+        if value:
+            self._parse_resource_path("session", value)
+
+        self._session_id = value
+
+    @staticmethod
+    def _build_query_input(text, language_code):
+        """Build out the query_input object for the Query Request.
+
+        Args:
+          text, the text to use for the Detect Intent request.
+          language_code, the language code to use for Detect Intent request.
+          """
+        text_input = types.session.TextInput(text=text)
+        query_input = types.session.QueryInput(
+            text=text_input, language_code=language_code)
+
+        return query_input
+
+    def build_session_id(
+        self, agent_id:str = None, overwrite:bool = True) -> str:
+        """Creates a valid UUID-4 Session ID to use with other methods.
+
+        Args:
+          overwrite (Optional), if a session_id already exists, this will
+            overwrite the existing Session ID parameter. Defaults to True.
+        """
+
+        agent_parts = self._parse_resource_path("agent", agent_id)
+        session_id = f"projects/{agent_parts['project']}/"\
+            f"locations/{agent_parts['location']}/agents/"\
+            f"{agent_parts['agent']}/sessions/{uuid.uuid4()}"
+
+        if overwrite:
             self.session_id = session_id
 
+        return session_id
+
     def run_conversation(
         self,
         agent_id: str = None,
@@ -56,18 +102,18 @@ def run_conversation(
         """Tests a full conversation with the specified CX Agent.
 
         Args:
-          agent_id, the Agent ID of the CX Agent to have the conversation with.
-          session_id, an RFC 4122 formatted UUID to be used as the unique ID
+          agent_id: the Agent ID of the CX Agent to have the conversation with.
+          session_id: an RFC 4122 formatted UUID to be used as the unique ID
             for the duration of the conversation session. When using Python
             uuid library, uuid.uuid4() is preferred.
-          conversation, a List of Strings that represent the USER utterances
+          conversation: a List of Strings that represent the USER utterances
             for the given conversation, in the order they would happen
             chronologically in the conversation.
             Ex:
               ['I want to check my bill', 'yes', 'no that is all', 'thanks!']
-          parameters, (Optional) Dict of CX Session Parameters to set in the
+          parameters: (Optional) Dict of CX Session Parameters to set in the
             conversation. Typically this is set before a conversation starts.
-          response_text, Will provide the Agent Response text if set to True.
+          response_text: Will provide the Agent Response text if set to True.
             Default value is False.
 
         Returns:
@@ -139,40 +185,66 @@ def run_conversation(
                         )
                 print(f"Response Text: {concat_messages}\n")
 
-    def detect_intent(self, agent_id, session_id, text, parameters=None):
+
+    def detect_intent(
+        self,
+        agent_id,
+        session_id,
+        text,
+        language_code="en",
+        parameters=None):
         """Returns the result of detect intent with texts as inputs.
 
         Using the same `session_id` between requests allows continuation
-        of the conversation."""
+        of the conversation.
+
+        Args:
+          agent_id: the Agent ID of the CX Agent to have the conversation with.
+          session_id: an RFC 4122 formatted UUID to be used as the unique ID
+            for the duration of the conversation session. When using Python
+            uuid library, uuid.uuid4() is preferred.
+          text: the user utterance to run intent detection on
+          parameters: (Optional) Dict of CX Session Parameters to set in the
+            conversation. Typically this is set before a conversation starts.
+
+        Returns:
+          The CX query result from intent detection
+        """
         client_options = self._set_region(agent_id)
         session_client = services.sessions.SessionsClient(
             client_options=client_options, credentials=self.creds
         )
 
-        session_path = f"{agent_id}/sessions/{session_id}"
+        res = self._parse_resource_path("session", str(session_id), False)
+        if not res:
+            raise ValueError(
+                "Session ID must be provided in the following format: "
+                "`projects/<Project ID>/locations/<Location ID>/agents/"\
+                "<Agent ID>/sessions/<Session ID>`.\n\n"\
+                "Utilize `build_session_id` to create a new Session ID.")
+
+        logging.info(f"Starting Session ID {session_id}")
 
         if parameters:
             query_params = types.session.QueryParameters(parameters=parameters)
-            text_input = types.session.TextInput(text=text)
-            query_input = types.session.QueryInput(
-                text=text_input, language_code="en"
-            )
-            request = types.session.DetectIntentRequest(
-                session=session_path,
-                query_params=query_params,
-                query_input=query_input,
-            )
+
+            query_input = self._build_query_input(text, language_code)
+
+            request = types.session.DetectIntentRequest()
+            request.session = session_id
+            request.query_input = query_input
+            request.query_params = query_params
 
             response = session_client.detect_intent(request=request)
 
-        text_input = types.session.TextInput(text=text)
-        query_input = types.session.QueryInput(
-            text=text_input, language_code="en"
-        )
-        request = types.session.DetectIntentRequest(
-            session=session_path, query_input=query_input
-        )
-        response = session_client.detect_intent(request=request)
+        else:
+            query_input = self._build_query_input(text, language_code)
+
+            request = types.session.DetectIntentRequest()
+            request.session = session_id
+            request.query_input = query_input
+
+        response = session_client.detect_intent(request)
         query_result = response.query_result
 
         return query_result
@@ -182,10 +254,16 @@ def preset_parameters(
     ):
         """Used to set session parameters before a conversation starts.
 
-        agent_id, the Agent ID of the CX Agent to have the conversation with.
-        session_id, an RFC 4122 formatted UUID to be used as the unique ID
+        Args:
+          agent_id: the Agent ID of the CX Agent to have the conversation with.
+          session_id: an RFC 4122 formatted UUID to be used as the unique ID
             for the duration of the conversation session. When using Python
             uuid library, uuid.uuid4() is preferred.
+          parameters: Dict of CX Session Parameters to set in the
+            conversation. Typically this is set before a conversation starts.
+
+        Returns:
+          The CX query result from intent detection run with no text input
         """
         client_options = self._set_region(agent_id)
         session_client = services.sessions.SessionsClient(
diff --git a/src/dfcx_scrapi/core/transition_route_groups.py b/src/dfcx_scrapi/core/transition_route_groups.py
index 29acc178..30cdcd37 100644
--- a/src/dfcx_scrapi/core/transition_route_groups.py
+++ b/src/dfcx_scrapi/core/transition_route_groups.py
@@ -109,13 +109,13 @@ def get_route_groups_map(self, flow_id: str = None, reverse=False):
         """Exports Agent Route Group UUIDs and Names into a user friendly dict.
 
         Args:
-          - flow_id, the formatted CX Agent Flow ID to use
-          - reverse, (Optional) Boolean flag to swap key:value -> value:key
+          flow_id: the formatted CX Agent Flow ID to use
+          reverse: (Optional) Boolean flag to swap key:value -> value:key
 
         Returns:
-          - webhooks_map, Dictionary containing Webhook UUIDs as keys and
-              webhook.display_name as values. If Optional reverse=True, the
-              output will return page_name:ID mapping instead of ID:page_name
+          Dictionary containing Route Group UUIDs as keys and display names
+          as values. If Optional reverse=True, the output will return
+          route group name:ID mapping instead of ID:route group name
         """
         if not flow_id:
             flow_id = self.flow_id
@@ -138,10 +138,10 @@ def list_transition_route_groups(self, flow_id: str = None):
         """Exports List of all Route Groups in the specified CX Flow ID.
 
         Args:
-          flow_id, The formatted CX Flow ID to list the route groups from
+          flow_id: The formatted CX Flow ID to list the route groups from
 
         Returns:
-          cx_route_groups, List of Route Group objects
+          List of Route Group objects
         """
         if not flow_id:
             flow_id = self.flow_id
@@ -168,10 +168,10 @@ def get_transition_route_group(self, route_group_id):
         """Get a single Transition Route Group object.
 
         Args:
-          route_group_id, the formatted CX Route Group ID to retrieve.
+          route_group_id: the formatted CX Route Group ID to retrieve.
 
         Returns:
-          response, a single Route Group object
+          A single Route Group object
         """
         request = types.transition_route_group.GetTransitionRouteGroupRequest()
         request.name = route_group_id
@@ -192,13 +192,13 @@ def create_transition_route_group(
         """Create a single Transition Route Group resource.
 
         Args:
-          flow_id, the formatted CX Flow ID to create the route group in
-          obj, (Optional) the Transition Route Group object of type
+          flow_id: the formatted CX Flow ID to create the route group in
+          obj: (Optional) the Transition Route Group object of type
             types.TransitionRouteGroup that you want the new route group
             to be built from.
 
         Returns:
-          response, a copy of the successfully created Route Group object
+          A copy of the successfully created Route Group object
         """
         if not flow_id:
             flow_id = self.flow_id
@@ -232,14 +232,14 @@ def update_transition_route_group(
         """Update a single Route Group resource.
 
         Args:
-          route_group_id, the formatted CX Route Group ID to update.
-          obj, (Optional) the Transition Route Group object of type
+          route_group_id: the formatted CX Route Group ID to update.
+          obj: (Optional) the Transition Route Group object of type
             types.TransitionRouteGroup that you want to update.
-          language_code, (Optional) the language in which the agent should
+          language_code: (Optional) the language in which the agent should
             update the TransitionRouteGroup
 
         Returns:
-          response, a copy of the successfully updated Route Group object
+          A copy of the successfully updated Route Group object
         """
         if obj:
             route_group = obj
@@ -291,7 +291,10 @@ def route_groups_to_dataframe(
             to control hitting Quota limits on your project.
 
         Returns:
-          a Pandas Dataframe
+          a Pandas Dataframe with columns: flow, route_group_name, target_page,
+          intent, condition, webhook, webhook_tag, custom_payload,
+          live_agent_handoff, conversation_success, play_audio,
+          output_audio_text, fulfillment_message
         """
         if not agent_id:
             agent_id = self.agent_id
diff --git a/src/dfcx_scrapi/core/versions.py b/src/dfcx_scrapi/core/versions.py
index 1b3dfc63..8d581e42 100644
--- a/src/dfcx_scrapi/core/versions.py
+++ b/src/dfcx_scrapi/core/versions.py
@@ -57,7 +57,7 @@ def list_versions(self, flow_id:str):
               /flows/<Flow ID>
 
         Returns:
-            List of Version objects.
+          List of Version objects.
         """
         if not flow_id:
             flow_id = self.flow_id
@@ -99,7 +99,7 @@ def get_version(
             flows/<Flow ID>
 
         Returns:
-            Version object.
+          Version object.
         """
 
         if not display_name and not version_id:
@@ -126,13 +126,12 @@ def get_version_by_display_name(self, display_name:str, flow_id:str):
 
         Args:
           display_name: Human readable display name of the target to get.
-
           flow_id: The targeted flow for the operation. format:
           projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
             flows/<Flow ID>
 
         Returns:
-            Version object.
+          Version object.
         """
         versions_list = self.list_versions(flow_id)
         for version_obj in versions_list:
@@ -158,7 +157,7 @@ def load_version(
               flows/<Flow ID>
 
         Returns:
-            An object representing a long-running operation (LRO).
+          An object representing a long-running operation (LRO).
         """
 
         if not flow_id:
@@ -192,7 +191,7 @@ def create_version(
           description: Additional description details for the Version created.
 
         Returns:
-            An object representing a long-running operation (LRO).
+          An object representing a long-running operation (LRO).
         """
 
         request = types.version.CreateVersionRequest()
@@ -242,18 +241,16 @@ def compare_versions(
             to compare with the target version. format:
             projects/<Project ID>/locations/<Location ID>/agents/
             <Agent ID>/flows/<Flow ID>/versions/<Version ID>.
-
           target_version_id: ID of the target flow version
             to compare with the base version. format:
             projects/<Project ID>/locations/<Location ID>/agents/
             <Agent ID>/flows/<Flow ID>/versions/<Version ID>.
-
           flow_id: Required. The targeted flow for the operation. Format:
             projects/<Project ID>/locations/<Location ID>/
             agents/<Agent ID>/flows/<Flow ID>
 
         Returns:
-            types.CompareVersionsRequest Object.
+          types.CompareVersionsRequest Object.
         """
         if not flow_id:
             flow_id = self.flow_id
diff --git a/src/dfcx_scrapi/core/webhooks.py b/src/dfcx_scrapi/core/webhooks.py
index 4ed830b6..9460a434 100644
--- a/src/dfcx_scrapi/core/webhooks.py
+++ b/src/dfcx_scrapi/core/webhooks.py
@@ -68,7 +68,7 @@ def get_webhooks_map(
 
         Returns:
           Dictionary containing Webhook UUIDs as keys and
-              webhook.display_name as values
+          webhook display names as values
         """
         if not agent_id:
             agent_id = self.agent_id
@@ -158,7 +158,7 @@ def get_webhook(self, webhook_id:str):
             <Agent ID>/webhooks/<Webhook
 
         Returns:
-          types.Webhook object.
+          The Webhook object.
         """
         request = types.webhook.GetWebhookRequest()
         request.name = webhook_id
@@ -182,7 +182,7 @@ def get_webhook_by_display_name(
          agent_id: Optional. The formatted CX Agent ID.
 
         Returns:
-          types.Webhook object of the specified webhook.
+          The Webhook object of the specified webhook.
         """
 
         if not agent_id:
@@ -218,7 +218,7 @@ def update_webhook(
             object vs. just partial updates.
 
         Returns:
-          types.Webhook object with specified changes.
+          The Webhook object with specified changes.
         """
         if not webhook_obj:
             webhook_obj = types.Webhook()
diff --git a/src/dfcx_scrapi/core_ml/utterance_generator.py b/src/dfcx_scrapi/core_ml/utterance_generator.py
index 5bd4f227..e6555079 100644
--- a/src/dfcx_scrapi/core_ml/utterance_generator.py
+++ b/src/dfcx_scrapi/core_ml/utterance_generator.py
@@ -85,7 +85,7 @@ def generate_utterances(
           base phrase
 
         Returns:
-          synthetic_phrases_df: dataframe with new synthetic phrases.
+          DataFrame with new synthetic phrases.
         """
 
         synthetic_phrases_df = pd.DataFrame()
@@ -114,7 +114,7 @@ def generate_utterances(
             iter_frame["synthetic_phrases"] = synthetic_phrases
             for col in origin_utterances.columns:
                 iter_frame[col] = row[col]
-            synthetic_phrases_df = synthetic_phrases_df.append(iter_frame)
+            synthetic_phrases_df = pd.concat([synthetic_phrases_df, iter_frame])
             ordered_cols = [
                 "id",
                 "synthetic_instances",
diff --git a/src/dfcx_scrapi/tools/copy_util.py b/src/dfcx_scrapi/tools/copy_util.py
index 96e21bec..a8ddd009 100644
--- a/src/dfcx_scrapi/tools/copy_util.py
+++ b/src/dfcx_scrapi/tools/copy_util.py
@@ -34,19 +34,21 @@
 # logging config
 logging.basicConfig(
     level=logging.INFO,
-    format='%(asctime)s %(levelname)-8s %(message)s',
-    datefmt='%Y-%m-%d %H:%M:%S',
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
 )
 
+
 class CopyUtil(ScrapiBase):
     """Utility class for copying DFCX Resources between Agents."""
+
     def __init__(
         self,
         creds_path: str = None,
         creds_dict: Dict = None,
-        creds = None,
-        scope = False,
-        agent_id: str = None
+        creds=None,
+        scope=False,
+        agent_id: str = None,
     ):
         super().__init__(
             creds_path=creds_path,
@@ -63,27 +65,30 @@ def __init__(
         self.pages = Pages(creds=self.creds)
         self.webhooks = Webhooks(creds=self.creds, agent_id=self.agent_id)
         self.route_groups = TransitionRouteGroups(
-            creds=self.creds, agent_id=self.agent_id)
+            creds=self.creds, agent_id=self.agent_id
+        )
 
     @staticmethod
     def _get_entry_webhooks(page_object, resources):
         """Check the Entry Fulfillment for webhooks and return them."""
-        if 'entry_fulfillment' in page_object:
-            if 'webhook' in page_object.entry_fulfillment:
-                resources['webhooks'].append(
-                    page_object.entry_fulfillment.webhook)
+        if "entry_fulfillment" in page_object:
+            if "webhook" in page_object.entry_fulfillment:
+                resources["webhooks"].append(
+                    page_object.entry_fulfillment.webhook
+                )
 
         return resources
 
-
     @staticmethod
     def _get_condition_route_webhooks(page_object, resources):
         """Extract Webhooks from Condition for a given Page."""
-        if 'transition_routes' in page_object:
+        if "transition_routes" in page_object:
             for transition_route in page_object.transition_routes:
-                if ('condition' in transition_route
-                and 'webhook' in transition_route.trigger_fulfillment):
-                    resources['webhooks'].append(
+                if (
+                    "condition" in transition_route
+                    and "webhook" in transition_route.trigger_fulfillment
+                ):
+                    resources["webhooks"].append(
                         transition_route.trigger_fulfillment.webhook
                     )
 
@@ -92,14 +97,12 @@ def _get_condition_route_webhooks(page_object, resources):
     @staticmethod
     def _get_form_entity_types(page_object, resources):
         """Extract Entity Types from Parameters for a given Page."""
-        if 'form' in page_object:
-            if 'parameters' in page_object.form:
+        if "form" in page_object:
+            if "parameters" in page_object.form:
                 for param in page_object.form.parameters:
-                    if 'sys.' in param.entity_type:
+                    if "sys." in param.entity_type:
                         continue
-                    resources['entities'].append(
-                            param.entity_type
-                        )
+                    resources["entities"].append(param.entity_type)
 
         return resources
 
@@ -107,23 +110,20 @@ def _get_form_entity_types(page_object, resources):
     def _get_intent_route_intents(page_object, resources):
         """Extract any Intents from Transition Routes in a given Page."""
 
-        if 'transition_routes' in page_object:
+        if "transition_routes" in page_object:
             for transition_route in page_object.transition_routes:
-                if 'intent' in transition_route:
-                    resources['intents'].append(
-                        transition_route.intent
-                    )
+                if "intent" in transition_route:
+                    resources["intents"].append(transition_route.intent)
 
         return resources
 
     @staticmethod
     def _convert_entry_webhooks(page_object, webhooks_map):
         """Convert webhooks in the entry fulfillment of the Page Object.
-
         Internal method to convert webhooks in the entry fulfillment of
         a given page from the Resource ID to their Display Name or vice versa.
         """
-        if 'webhook' in page_object.entry_fulfillment:
+        if "webhook" in page_object.entry_fulfillment:
             page_object.entry_fulfillment.webhook = webhooks_map[
                 page_object.entry_fulfillment.webhook
             ]
@@ -132,40 +132,38 @@ def _convert_entry_webhooks(page_object, webhooks_map):
 
     @staticmethod
     def __convert_tr_target_page(
-        trans_route,
-        pages_map,
-        convert_type = None,
-        flows_map = None,
-        flow = None):
-
-        if convert_type == 'source':
-            if trans_route.target_page.split('/')[-1] == 'END_FLOW':
-                trans_route.target_page = 'END_FLOW'
-
-            elif trans_route.target_page.split('/')[-1] == 'END_SESSION':
-                trans_route.target_page = 'END_SESSION'
-
-            elif (
-                trans_route.target_page.split('/')[-1] == 'CURRENT_PAGE'
-            ):
-                trans_route.target_page = 'CURRENT_PAGE'
+        trans_route, pages_map, convert_type=None, flows_map=None, flow=None
+    ):
 
+        if convert_type == "source":
+            if trans_route.target_page.split("/")[-1] == "END_FLOW":
+                trans_route.target_page = "END_FLOW"
+            elif trans_route.target_page.split("/")[-1] == "END_SESSION":
+                trans_route.target_page = "END_SESSION"
+            elif trans_route.target_page.split("/")[-1] == "CURRENT_PAGE":
+                trans_route.target_page = "CURRENT_PAGE"
+            elif trans_route.target_page.split("/")[-1] == "PREVIOUS_PAGE":
+                trans_route.target_page = "PREVIOUS_PAGE"
+            elif trans_route.target_page.split("/")[-1] == "START_PAGE":
+                trans_route.target_page = "START_PAGE"
             else:
                 trans_route.target_page = pages_map[trans_route.target_page]
 
-        elif convert_type == 'destination':
-            if trans_route.target_page == 'END_FLOW':
-                trans_route.target_page = (
-                    flows_map[flow] + '/pages/END_FLOW')
-
-            elif trans_route.target_page == 'END_SESSION':
+        elif convert_type == "destination":
+            if trans_route.target_page == "END_FLOW":
+                trans_route.target_page = flows_map[flow] + "/pages/END_FLOW"
+            elif trans_route.target_page == "END_SESSION":
+                trans_route.target_page = flows_map[flow] + "/pages/END_SESSION"
+            elif trans_route.target_page == "CURRENT_PAGE":
                 trans_route.target_page = (
-                    flows_map[flow] + '/pages/END_SESSION')
-
-            elif trans_route.target_page == 'CURRENT_PAGE':
+                    flows_map[flow] + "/pages/CURRENT_PAGE"
+                )
+            elif trans_route.target_page == "PREVIOUS_PAGE":
                 trans_route.target_page = (
-                    flows_map[flow] + '/pages/CURRENT_PAGE')
-
+                    flows_map[flow] + "/pages/PREVIOUS_PAGE"
+                )
+            elif trans_route.target_page == "START_PAGE":
+                trans_route.target_page = flows_map[flow] + "/pages/START_PAGE"
             else:
                 trans_route.target_page = pages_map[trans_route.target_page]
 
@@ -173,45 +171,53 @@ def __convert_tr_target_page(
 
     @staticmethod
     def __convert_trigger_fulfillment_webhook(handler, webhooks_map):
-        if 'webhook' in handler.trigger_fulfillment:
+        if "webhook" in handler.trigger_fulfillment:
             handler.trigger_fulfillment.webhook = webhooks_map[
-                handler.trigger_fulfillment.webhook]
+                handler.trigger_fulfillment.webhook
+            ]
 
         return handler.trigger_fulfillment.webhook
 
-    def _convert_form_parameters( #pylint: disable=too-many-arguments
+    def _convert_form_parameters(  # pylint: disable=too-many-arguments
         self,
         page_object,
         pages_map,
         webhooks_map,
         entities_map,
-        convert_type = None):
+        flows_map,
+        flow,
+        convert_type=None,
+    ):
 
         for param in page_object.form.parameters:
-            if 'fill_behavior' in param:
-                if 'initial_prompt_fulfillment' in param.fill_behavior:
-                    if 'webhook' in (
-                        param.fill_behavior.initial_prompt_fulfillment):
-                        param.fill_behavior.initial_prompt_fulfillment.webhook = webhooks_map[ #pylint: disable=line-too-long
-                            param.fill_behavior.initial_prompt_fulfillment.webhook #pylint: disable=line-too-long
+            if "fill_behavior" in param:
+                if "initial_prompt_fulfillment" in param.fill_behavior:
+                    if "webhook" in (
+                        param.fill_behavior.initial_prompt_fulfillment
+                    ):
+                        param.fill_behavior.initial_prompt_fulfillment.webhook = webhooks_map[  # pylint: disable=line-too-long
+                            param.fill_behavior.initial_prompt_fulfillment.webhook  # pylint: disable=line-too-long
                         ]
 
-                if 'reprompt_event_handlers' in param.fill_behavior:
+                if "reprompt_event_handlers" in param.fill_behavior:
                     for handler in param.fill_behavior.reprompt_event_handlers:
-                        if 'trigger_fulfillment' in handler:
+                        if "trigger_fulfillment" in handler:
                             handler.trigger_fulfillment.webhook = (
                                 self.__convert_trigger_fulfillment_webhook(
-                                    handler, webhooks_map)
+                                    handler, webhooks_map
+                                )
                             )
 
-                        if 'target_page' in handler:
-                            handler.target_page = (
-                                self.__convert_tr_target_page(
-                                    handler, pages_map,
-                                    convert_type=convert_type)
+                        if "target_page" in handler:
+                            handler.target_page = self.__convert_tr_target_page(
+                                handler,
+                                pages_map,
+                                flows_map=flows_map,
+                                flow=flow,
+                                convert_type=convert_type,
                             )
 
-            if 'sys.' in param.entity_type:
+            if "sys." in param.entity_type:
                 pass
             else:
                 param.entity_type = entities_map[param.entity_type]
@@ -219,14 +225,26 @@ def _convert_form_parameters( #pylint: disable=too-many-arguments
         return page_object
 
     def _convert_event_handlers(
-        self, page_object, pages_map, webhooks_map, convert_type = None):
+        self,
+        page_object,
+        pages_map,
+        webhooks_map,
+        flows_map,
+        flow,
+        convert_type=None,
+    ):
 
         for handler in page_object.event_handlers:
-            if 'target_page' in handler:
+            if "target_page" in handler:
                 handler.target_page = self.__convert_tr_target_page(
-                    handler, pages_map, convert_type=convert_type)
+                    handler,
+                    pages_map,
+                    flows_map=flows_map,
+                    flow=flow,
+                    convert_type=convert_type,
+                )
 
-            if 'trigger_fulfillment' in handler:
+            if "trigger_fulfillment" in handler:
                 handler.trigger_fulfillment.webhook = (
                     self.__convert_trigger_fulfillment_webhook(
                         handler, webhooks_map
@@ -235,28 +253,36 @@ def _convert_event_handlers(
 
         return page_object
 
-    def _convert_trans_routes( #pylint: disable=too-many-arguments
+    def _convert_trans_routes(  # pylint: disable=too-many-arguments
         self,
         page_object,
         pages_map,
         intents_map,
         webhooks_map,
-        convert_type = None):
+        flows_map,
+        flow,
+        convert_type=None,
+    ):
 
         for trans_route in page_object.transition_routes:
-            if 'target_page' in trans_route:
+            if "target_page" in trans_route:
                 trans_route.target_page = self.__convert_tr_target_page(
-                    trans_route, pages_map, convert_type=convert_type)
+                    trans_route,
+                    pages_map,
+                    convert_type=convert_type,
+                    flows_map=flows_map,
+                    flow=flow,
+                )
 
-            if 'intent' in trans_route:
+            if "intent" in trans_route:
                 trans_route.intent = intents_map[trans_route.intent]
-                if 'webhook' in trans_route.trigger_fulfillment:
+                if "webhook" in trans_route.trigger_fulfillment:
                     trans_route.trigger_fulfillment.webhook = webhooks_map[
                         trans_route.trigger_fulfillment.webhook
                     ]
             elif (
-                'condition' in trans_route
-                and 'webhook' in trans_route.trigger_fulfillment
+                "condition" in trans_route
+                and "webhook" in trans_route.trigger_fulfillment
             ):
                 trans_route.trigger_fulfillment.webhook = webhooks_map[
                     trans_route.trigger_fulfillment.webhook
@@ -266,22 +292,21 @@ def _convert_trans_routes( #pylint: disable=too-many-arguments
 
     def _get_intent_entity_dependencies(self, resources):
         """Loop through Intents and find any additional Entity dependencies"""
-        agent = '/'.join(resources['intents'][0].split('/')[0:6])
+        agent = "/".join(resources["intents"][0].split("/")[0:6])
         temp_intents = self.intents.list_intents(agent)
 
         for intent in temp_intents:
-            if intent.name in resources['intents']:
+            if intent.name in resources["intents"]:
                 if len(intent.parameters) > 0:
                     for param in intent.parameters:
-                        if 'sys.' in param.entity_type:
+                        if "sys." in param.entity_type:
                             continue
-                        resources['entities'].append(param.entity_type)
+                        resources["entities"].append(param.entity_type)
 
         return resources
 
     def _get_flow_intent_route_intents(self, flow_id, obj_list, resources):
         """Extract Intents from Transition Routes on the Flow Start Page.
-
         The Start Page of every Flow is a special Page-like object that
         actually exists as part of the Flow object. Because it is different
         enough from a standard Page object, we have to extract the resources
@@ -291,9 +316,9 @@ def _get_flow_intent_route_intents(self, flow_id, obj_list, resources):
         temp_page_name_list = [page.name for page in obj_list]
 
         for transition_route in source_flow.transition_routes:
-            if 'intent' in transition_route:
+            if "intent" in transition_route:
                 if transition_route.target_page in temp_page_name_list:
-                    resources['intents'].append(transition_route.intent)
+                    resources["intents"].append(transition_route.intent)
 
         return resources
 
@@ -301,24 +326,20 @@ def _get_route_groups_and_intents(self, page_object, flow_id, resources):
         """Extract Intent resources from Transition Route Groups on a Page."""
         route_groups = self.route_groups.list_transition_route_groups(flow_id)
 
-        if 'transition_route_groups' in page_object:
+        if "transition_route_groups" in page_object:
             for trg in page_object.transition_route_groups:
-                resources['route_groups'].append(trg)
+                resources["route_groups"].append(trg)
                 for route_group in route_groups:
                     if trg == route_group.name:
                         for transition_route in route_group.transition_routes:
-                            resources['intents'].append(
-                                transition_route.intent)
+                            resources["intents"].append(transition_route.intent)
 
         return resources
 
     def _remap_parameters_in_intent(
-        self,
-        source_agent,
-        destination_agent,
-        intent_object):
+        self, source_agent, destination_agent, intent_object
+    ):
         """Remap the Entity Type Resource ID from the Source to Destination.
-
         Internal function to find the Source Entity Type Resource ID, convert
         it to the Display Name, perform a lookup in the Destination Agent, and
         convert that final Resource ID to the Desitnation Agent Entity Type
@@ -328,10 +349,10 @@ def _remap_parameters_in_intent(
         source_entities_map = self.entities.get_entities_map(source_agent)
         destination_entities_map = self.entities.get_entities_map(
             destination_agent, reverse=True
-            )
+        )
 
         for param in intent_object.parameters:
-            if 'sys.' in param.entity_type:
+            if "sys." in param.entity_type:
                 pass
             else:
                 source_name = source_entities_map[param.entity_type]
@@ -341,17 +362,15 @@ def _remap_parameters_in_intent(
         return intent_object
 
     def _update_intent_via_copy(
-        self,
-        intent_display_name,
-        intent_object,
-        destination_agent):
+        self, intent_display_name, intent_object, destination_agent
+    ):
         """Update the Source Intent Display Name in the Destination Agent.
-
         Internal method to update the Destination Intent based on the defined
         Source Intent Display Name. This is specifically used when the
         Destination Agent already contains the Intent Display Name specified.
         This performs a lookup based on Intent Display Name, retrieves the
-        Destination Intent object, and then updates it appropriately."""
+        Destination Intent object, and then updates it appropriately.
+        """
 
         destination_intents = self.intents.list_intents(destination_agent)
         for intent in destination_intents:
@@ -359,78 +378,73 @@ def _update_intent_via_copy(
                 destination_intent_obj = intent
 
         self.intents.update_intent(destination_intent_obj.name, intent_object)
-        logging.info('Intent %s updated successfully',
-          intent_object.display_name)
+        logging.info(
+            "Intent %s updated successfully", intent_object.display_name
+        )
 
-    def _create_intent_via_copy(
-        self,
-        destination_agent,
-        intent_object):
+    def _create_intent_via_copy(self, destination_agent, intent_object):
         """Create the Source Intent in the Destination Agent."""
         try:
             self.intents.create_intent(destination_agent, intent_object)
             logging.info(
-                'Intent %s created successfully', intent_object.display_name)
+                "Intent %s created successfully", intent_object.display_name
+            )
 
         except core_exceptions.AlreadyExists as error:
             print(error)
             print(
-                'If you are trying to update an existing Intent, use the'
-                + ' \'update\' option instead'
+                "If you are trying to update an existing Intent, use the"
+                + " 'update' option instead"
             )
 
     def _get_resource_objects(
-        self,
-        source_agent,
-        resources,
-        resources_objects,
-        skip_list):
+        self, source_agent, resources, resources_objects, skip_list
+    ):
 
-        if 'entities' not in skip_list:
+        if "entities" not in skip_list:
             source_entities = self.entities.list_entity_types(source_agent)
             for entity in source_entities:
-                if entity.name in resources['entities']:
-                    resources_objects['entities'].append(entity)
+                if entity.name in resources["entities"]:
+                    resources_objects["entities"].append(entity)
 
-        if 'intents' not in skip_list:
+        if "intents" not in skip_list:
             source_intents = self.intents.list_intents(source_agent)
             for intent in source_intents:
-                if intent.name in resources['intents']:
-                    resources_objects['intents'].append(intent)
+                if intent.name in resources["intents"]:
+                    resources_objects["intents"].append(intent)
 
-        if 'webhooks' not in skip_list:
+        if "webhooks" not in skip_list:
             source_webhooks = self.webhooks.list_webhooks(source_agent)
             for webhook in source_webhooks:
-                if webhook.name in resources['webhooks']:
-                    resources_objects['webhooks'].append(webhook)
+                if webhook.name in resources["webhooks"]:
+                    resources_objects["webhooks"].append(webhook)
 
-        if 'route_groups' not in skip_list:
+        if "route_groups" not in skip_list:
             source_flows_map = self.flows.get_flows_map(
                 source_agent, reverse=True
             )
             source_route_groups = (
                 self.route_groups.list_transition_route_groups(
-                    source_flows_map['Default Start Flow']
+                    source_flows_map["Default Start Flow"]
                 )
             )
             for route_group in source_route_groups:
-                if route_group.name in resources['route_groups']:
-                    resources_objects['route_groups'].append(route_group)
+                if route_group.name in resources["route_groups"]:
+                    resources_objects["route_groups"].append(route_group)
 
         return resources_objects
 
     def _create_webhook_resources(
-        self,
-        destination_agent,
-        resources_objects,
-        resources_skip_list):
-        for webhook in resources_objects['webhooks']:
-            logging.info('Creating Webhook %s...', webhook.display_name)
+        self, destination_agent, resources_objects, resources_skip_list
+    ):
+        for webhook in resources_objects["webhooks"]:
+            logging.info("Creating Webhook %s...", webhook.display_name)
             try:
                 self.webhooks.create_webhook(destination_agent, webhook)
-                resources_skip_list['webhooks'].append(webhook.display_name)
+                resources_skip_list["webhooks"].append(webhook.display_name)
                 logging.info(
-                    'Webhook %s created successfully.', webhook.display_name)
+                    "Webhook %s created successfully.", webhook.display_name
+                )
 
             except core_exceptions.AlreadyExists as error:
                 logging.info(error)
@@ -438,18 +452,17 @@ def _create_webhook_resources(
         return resources_skip_list
 
     def _create_entity_resources(
-        self,
-        destination_agent,
-        resources_objects,
-        resources_skip_list):
+        self, destination_agent, resources_objects, resources_skip_list
+    ):
 
-        for entity in resources_objects['entities']:
-            logging.info('Creating Entity %s...', entity.display_name)
+        for entity in resources_objects["entities"]:
+            logging.info("Creating Entity %s...", entity.display_name)
             try:
                 self.entities.create_entity_type(destination_agent, entity)
-                resources_skip_list['entities'].append(entity.display_name)
+                resources_skip_list["entities"].append(entity.display_name)
                 logging.info(
-                    'Entity %s created successfully.', entity.display_name)
+                    "Entity %s created successfully.", entity.display_name
+                )
 
             except core_exceptions.AlreadyExists as error:
                 print(error)
@@ -461,59 +474,66 @@ def _create_intent_resources(
         source_agent,
         destination_agent,
         resources_objects,
-        resources_skip_list):
+        resources_skip_list,
+    ):
 
-        for intent in resources_objects['intents']:
-            logging.info('Creating Intent %s...', intent.display_name)
-            time.sleep(1) # sleep to not overrun API rate limiter
+        for intent in resources_objects["intents"]:
+            logging.info("Creating Intent %s...", intent.display_name)
+            time.sleep(1)  # sleep to not overrun API rate limiter
 
-            if 'parameters' in intent:
+            if "parameters" in intent:
                 intent = self._remap_parameters_in_intent(
-                    source_agent, destination_agent, intent)
+                    source_agent, destination_agent, intent
+                )
             try:
                 self.intents.create_intent(destination_agent, intent)
-                resources_skip_list['intents'].append(intent.display_name)
-                logging.info('Intent %s created successfully',
-                  intent.display_name)
+                resources_skip_list["intents"].append(intent.display_name)
+                logging.info(
+                    "Intent %s created successfully", intent.display_name
+                )
             except core_exceptions.AlreadyExists as error:
                 print(error)
 
         return resources_skip_list
 
-    def _create_route_group_resources( #pylint: disable=too-many-arguments
+    def _create_route_group_resources(  # pylint: disable=too-many-arguments
         self,
         source_agent,
         destination_agent,
         destination_flow,
         resources_objects,
-        resources_skip_list):
+        resources_skip_list,
+    ):
 
         source_flows_map = self.flows.get_flows_map(source_agent)
         source_intents_map = self.intents.get_intents_map(source_agent)
         source_webhooks_map = self.webhooks.get_webhooks_map(source_agent)
         source_pages_map = self.pages.get_pages_map(
-            source_flows_map['Default Start Flow'])
+            source_flows_map["Default Start Flow"]
+        )
 
         destination_flows = self.flows.get_flows_map(
-            destination_agent, reverse=True)
+            destination_agent, reverse=True
+        )
         destination_intents_map = self.intents.get_intents_map(
-                destination_agent, reverse=True)
+            destination_agent, reverse=True
+        )
         destination_webhooks_map = self.webhooks.get_webhooks_map(
-                destination_agent, reverse=True)
+            destination_agent, reverse=True
+        )
         destination_pages_map = self.pages.get_pages_map(
-                destination_flows[destination_flow], reverse=True
-            )
+            destination_flows[destination_flow], reverse=True
+        )
 
-        for route_group in resources_objects['route_groups']:
-            logging.info(
-                'Creating Route Group %s...', route_group.display_name)
+        for route_group in resources_objects["route_groups"]:
+            logging.info("Creating Route Group %s...", route_group.display_name)
             for trans_route in route_group.transition_routes:
                 source_name = source_intents_map[trans_route.intent]
                 destination_name = destination_intents_map[source_name]
                 trans_route.intent = destination_name
 
-                if 'trigger_fulfillment' in trans_route:
-                    if 'webhook' in trans_route.trigger_fulfillment:
+                if "trigger_fulfillment" in trans_route:
+                    if "webhook" in trans_route.trigger_fulfillment:
                         source_webhook = source_webhooks_map[
                             trans_route.trigger_fulfillment.webhook
                         ]
@@ -521,29 +541,31 @@ def _create_route_group_resources( #pylint: disable=too-many-arguments
                             source_webhook
                         ]
                         trans_route.trigger_fulfillment.webhook = (
-                            destination_webhook)
+                            destination_webhook
+                        )
 
-                if 'target_page' in trans_route:
-                    if trans_route.target_page.split('/')[-1] == 'END_FLOW':
+                if "target_page" in trans_route:
+                    if trans_route.target_page.split("/")[-1] == "END_FLOW":
                         trans_route.target_page = (
                             destination_flows[destination_flow]
-                            + '/pages/END_FLOW'
+                            + "/pages/END_FLOW"
                         )
                     else:
                         source_page = source_pages_map[trans_route.target_page]
-                        destination_page = destination_pages_map[
-                            source_page
-                        ]
+                        destination_page = destination_pages_map[source_page]
                         trans_route.target_page = destination_page
 
             try:
                 self.route_groups.create_transition_route_group(
                     destination_flows[destination_flow], route_group
                 )
-                resources_skip_list['route_groups'].append(
-                    route_group.display_name)
-                logging.info('Route Group %s created successfully',
-                  route_group.display_name)
+                resources_skip_list["route_groups"].append(
+                    route_group.display_name
+                )
+                logging.info(
+                    "Route Group %s created successfully",
+                    route_group.display_name,
+                )
             except core_exceptions.AlreadyExists as error:
                 print(error)
 
@@ -554,10 +576,9 @@ def copy_intent_to_agent(
         intent_display_name: str,
         source_agent: str,
         destination_agent: str,
-        copy_option: str = 'create',
+        copy_option: str = "create",
     ):
         """Copy an Intent object from one CX agent to another.
-
         Args:
           intent_display_name: The human readable display name of the intent.
           source_agent: the Agent ID string in the following format:
@@ -572,32 +593,26 @@ def copy_intent_to_agent(
         intent_id = intents_map[intent_display_name]
         intent_object = self.intents.get_intent(intent_id)
 
-        if 'parameters' in intent_object:
+        if "parameters" in intent_object:
             intent_object = self._remap_parameters_in_intent(
                 source_agent, destination_agent, intent_object
             )
 
-        if copy_option == 'update':
+        if copy_option == "update":
             self._update_intent_via_copy(
-                intent_display_name, intent_object,destination_agent)
-
-        elif copy_option == 'create':
-            self._create_intent_via_copy(
-                destination_agent, intent_object
+                intent_display_name, intent_object, destination_agent
             )
 
-        else:
-            logging.info('Invalid option. Please use \'create\' or \'update\'')
+        elif copy_option == "create":
+            self._create_intent_via_copy(destination_agent, intent_object)
 
+        else:
+            logging.info("Invalid option. Please use 'create' or 'update'")
 
     def copy_entity_type_to_agent(
-        self,
-        entity_type_display_name,
-        source_agent,
-        destination_agent
+        self, entity_type_display_name, source_agent, destination_agent
     ):
         """Copy an Entity Type object from one CX agent to another.
-
         Args:
           entity_type_display_name: The human readable display name of the
             entity.
@@ -614,8 +629,10 @@ def copy_entity_type_to_agent(
         # push to destination agent
         try:
             self.entities.create_entity_type(destination_agent, entity_object)
-            logging.info('Entity Type %s created successfully',
-              entity_object.display_name)
+            logging.info(
+                "Entity Type %s created successfully",
+                entity_object.display_name,
+            )
 
         except core_exceptions.AlreadyExists as error:
             print(error)
@@ -624,24 +641,21 @@ def create_page_shells(
         self,
         pages_list: List[types.Page],
         destination_agent: str,
-        destination_flow: str = 'Default Start Flow',
+        destination_flow: str = "Default Start Flow",
     ):
         """Create blank DFCX Page object(s) with given Display Name.
-
         This function aids in the copy/pasting of pages from one DFCX agent to
         another by first creating blank Page "shells" in the destination agent
         using the human-readable display names. These Pages will then be
         retrieved by Page ID to use in the final copy/paste of the Page object
         from source to destination.
-
         Args:
-          pages_list, List of Page(s) object to extract page names from
-          destination_agent, DFCX Agent ID of the Destination Agent
-          destination_flow, DFCX Flow ID of the Destination Flow. If no Flow ID
+          pages_list: List of Page objects to extract page names from
+          destination_agent: DFCX Agent ID of the Destination Agent
+          destination_flow: DFCX Flow ID of the Destination Flow. If no Flow ID
             is provided, Default Start Flow will be used.
-
-        Return:
-          Success!
+        Returns:
+          None
         """
         destination_flows = self.flows.get_flows_map(
             destination_agent, reverse=True
@@ -653,32 +667,27 @@ def create_page_shells(
                     destination_flows[destination_flow],
                     display_name=page.display_name,
                 )
-                logging.info(
-                    'Page %s created successfully', page.display_name
-                )
+                logging.info("Page %s created successfully", page.display_name)
             except core_exceptions.AlreadyExists as error:
                 logging.info(error)
                 continue
 
-    def copy_paste_agent_resources( #pylint: disable=too-many-arguments
+    def copy_paste_agent_resources(  # pylint: disable=too-many-arguments
         self,
         resources: Dict[str, str],
         source_agent: str,
         destination_agent: str,
-        destination_flow: str = 'Default Start Flow',
-        skip_list: List[str] = None
+        destination_flow: str = "Default Start Flow",
+        skip_list: List[str] = None,
     ):
         """Copy/Paste Agent level resources from one DFCX agent to another.
-
         Agent level resources in DFCX are resources like Entities, Intents, and
         Webhooks which are not Flow dependent. This method allows the user to
         provide a dictionary of Agent Resources and Resources IDs to be copied
         from a Source agent to a Destination agent. *NOTE* That this method
         will also copy all Route Groups from Default Start Flow only.
-
         To obtain the resource_dict in the proper format, you can use the
         get_page_dependencies() method included in the CopyUtil Class.
-
         Args:
           resource_dict: Dictionary of Lists of DFCX Resource IDs with keys
             corresponding to the Resource type (i.e. intents, entities, etc.)
@@ -688,15 +697,17 @@ def copy_paste_agent_resources( #pylint: disable=too-many-arguments
           destination_flow: (Optional) Defaults to 'Default Start Flow'
           skip_list: (Optional) List of resources to exclude. Use the following
               strings: 'intents', 'entities', 'webhooks', 'route_groups'
-
         Returns:
-          Success!
+          A dictionary with possible keys being webhooks, entities, intents,
+          and route_groups, with keys missing if they were in the skip_list.
+          Each value is a list of display names of created CX resources.
         """
         resources_objects = defaultdict(list)
         resources_skip_list = defaultdict(list)
 
         resources_objects = self._get_resource_objects(
-            source_agent, resources, resources_objects, skip_list)
+            source_agent, resources, resources_objects, skip_list
+        )
 
         # Create Objects in Destination Agent
         # For all objects, we will attempt to create them in the destination
@@ -704,25 +715,35 @@ def copy_paste_agent_resources( #pylint: disable=too-many-arguments
         # it to the resources_skip_list. Duplicates are determined by
         # display_name only at this time.
 
-        if 'webhooks' in resources_objects and 'webhooks' not in skip_list:
+        if "webhooks" in resources_objects and "webhooks" not in skip_list:
             resources_skip_list = self._create_webhook_resources(
-                destination_agent, resources_objects, resources_skip_list)
+                destination_agent, resources_objects, resources_skip_list
+            )
 
-        if 'entities' in resources_objects and 'entities' not in skip_list:
+        if "entities" in resources_objects and "entities" not in skip_list:
             resources_skip_list = self._create_entity_resources(
-                destination_agent, resources_objects, resources_skip_list)
+                destination_agent, resources_objects, resources_skip_list
+            )
 
-        if 'intents' in resources_objects and 'intents' not in skip_list:
+        if "intents" in resources_objects and "intents" not in skip_list:
             resources_skip_list = self._create_intent_resources(
-                source_agent, destination_agent, resources_objects,
-                resources_skip_list)
+                source_agent,
+                destination_agent,
+                resources_objects,
+                resources_skip_list,
+            )
 
         if (
-            'route_groups' in resources_objects and
-            'route_groups' not in skip_list):
+            "route_groups" in resources_objects
+            and "route_groups" not in skip_list
+        ):
             resources_skip_list = self._create_route_group_resources(
-                source_agent, destination_agent, destination_flow,
-                resources_objects,resources_skip_list)
+                source_agent,
+                destination_agent,
+                destination_flow,
+                resources_objects,
+                resources_skip_list,
+            )
 
         return resources_skip_list
 
@@ -730,14 +751,21 @@ def convert_from_source_page_dependencies(
         self,
         agent_id: str,
         pages_list: List[types.Page],
-        flow: str = 'Default Start Flow',
+        flow: str = "Default Start Flow",
     ) -> List[types.Page]:
         """Convert all Source Agent dependencies to Display Names.
-
         In order to copy resources over to a Destination Agent, we need to
         first convert all of the Resource IDs to their respective Display
         Names. We will then use the Display Names to convert back to the
         Destination Agent resource IDs using another method.
+        Args:
+          agent_id: the source Agent ID string in the following format:
+            projects/<project_id>/locations/<location_id>/agents/<agent_id>
+          pages_list: A list of DFCX Page objects
+          flow: The flow display name. Defaults to "Default Start Flow"
+        Returns:
+          The modified list of pages, with source agent resource IDs converted
+          to display names.
         """
 
         pages_mod = copy.deepcopy(pages_list)
@@ -756,25 +784,43 @@ def convert_from_source_page_dependencies(
         # to the appropriate destination UUID later.
         for page in pages_mod:
 
-            if 'entry_fulfillment' in page:
+            if "entry_fulfillment" in page:
                 page = self._convert_entry_webhooks(page, webhooks_map)
 
-            if 'transition_routes' in page:
+            if "transition_routes" in page:
                 page = self._convert_trans_routes(
-                    page, pages_map, intents_map, webhooks_map,
-                    convert_type='source')
+                    page,
+                    pages_map,
+                    intents_map,
+                    webhooks_map,
+                    flows_map,
+                    flow,
+                    convert_type="source",
+                )
 
-            if 'event_handlers' in page:
+            if "event_handlers" in page:
                 page = self._convert_event_handlers(
-                    page, pages_map, webhooks_map, convert_type='source')
+                    page,
+                    pages_map,
+                    webhooks_map,
+                    flows_map,
+                    flow,
+                    convert_type="source",
+                )
 
-            if 'form' in page:
-                if 'parameters' in page.form:
+            if "form" in page:
+                if "parameters" in page.form:
                     page = self._convert_form_parameters(
-                        page, pages_map, webhooks_map, entities_map,
-                        convert_type='source')
+                        page,
+                        pages_map,
+                        webhooks_map,
+                        entities_map,
+                        flows_map,
+                        flow,
+                        convert_type="source",
+                    )
 
-            if 'transition_route_groups' in page:
+            if "transition_route_groups" in page:
                 temp_list = []
                 for trg in page.transition_route_groups:
                     temp_list.append(rgs_map[trg])
@@ -787,15 +833,22 @@ def convert_to_destination_page_dependencies(
         self,
         agent_id: str,
         pages_list: List[types.Page],
-        flow: str = 'Default Start Flow'
+        flow: str = "Default Start Flow",
     ) -> List[types.Page]:
         """Convert from Display Names to Destination Agent Resource IDs.
-
         In order to copy resources over to a Destination Agent, we need to
         look up all the Display Names we previously converted and remap them
         to their respective Destination Agent Resource IDs. We will do this
         by extracting maps of the Destination Agent Resources and then
         performing a lookup with the Page objects in our pages_list.
+        Args:
+          agent_id: the target Agent ID string in the following format:
+            projects/<project_id>/locations/<location_id>/agents/<agent_id>
+          pages_list: A list of DFCX Page objects
+          flow: The flow display name. Defaults to "Default Start Flow"
+        Returns:
+          The modified list of pages, with display names converted to
+          resource IDs for the target agent.
         """
 
         pages_mod = copy.deepcopy(pages_list)
@@ -817,27 +870,43 @@ def convert_to_destination_page_dependencies(
         for page in pages_mod:
             page.name = pages_map[page.display_name]
 
-            if 'entry_fulfillment' in page:
+            if "entry_fulfillment" in page:
                 page = self._convert_entry_webhooks(page, webhooks_map)
 
-            if 'transition_routes' in page:
+            if "transition_routes" in page:
                 page = self._convert_trans_routes(
-                    page, pages_map, intents_map, webhooks_map,
-                    convert_type='destination'
+                    page,
+                    pages_map,
+                    intents_map,
+                    webhooks_map,
+                    flows_map,
+                    flow,
+                    convert_type="destination",
                 )
 
-            if 'event_handlers' in page:
+            if "event_handlers" in page:
                 page = self._convert_event_handlers(
-                    page, pages_map, webhooks_map, convert_type='destination')
-
+                    page,
+                    pages_map,
+                    webhooks_map,
+                    flows_map,
+                    flow,
+                    convert_type="destination",
+                )
 
-            if 'form' in page:
-                if 'parameters' in page.form:
+            if "form" in page:
+                if "parameters" in page.form:
                     page = self._convert_form_parameters(
-                        page, pages_map, webhooks_map, entities_map,
-                        convert_type='destination')
+                        page,
+                        pages_map,
+                        webhooks_map,
+                        entities_map,
+                        flows_map,
+                        flow,
+                        convert_type="destination",
+                    )
 
-            if 'transition_route_groups' in page:
+            if "transition_route_groups" in page:
                 temp_list = []
                 for trg in page.transition_route_groups:
                     temp_list.append(rgs_map[trg])
@@ -850,114 +919,181 @@ def convert_start_page_dependencies(
         self,
         agent_id,
         start_page,
-        agent_type='source',
-        flow='Default Start Flow',
+        agent_type="source",
+        flow="Default Start Flow",
     ):
         """Convert all Source Agent Start page dependencies to Display Names.
-
         In order to copy resources over to a Destination Agent, we need to
         first convert all of the Resource IDs to their respective Display
         Names. We will then use the Display Names to convert back to the
         Destination Agent resource IDs using another method. Start Pages are a
         special type of Page that exists inside of the Flow object, so they
         have to be handled differently.
+        Args:
+          agent_id: the source Agent ID string in the following format:
+            projects/<project_id>/locations/<location_id>/agents/<agent_id>
+          start_page: The DFCX Flow object corresponding to the start page of
+            the flow to be copied
+          agent_type: "source" or "destination". Defaults to "source"
+          flow: The flow display name. Defaults to "Default Start Flow"
+        Returns:
+          The modified start page, with the resource IDs converted to
+          display names for the source option and the display names converted
+          to resource IDs for the destination option.
         """
         page_mod = copy.deepcopy(start_page)
 
-        if agent_type == 'source':
+        if agent_type == "source":
             intents_map = self.intents.get_intents_map(agent_id)
             webhooks_map = self.webhooks.get_webhooks_map(agent_id)
             flows_map = self.flows.get_flows_map(agent_id, reverse=True)
             pages_map = self.pages.get_pages_map(flows_map[flow])
+            trgs_map = self.route_groups.get_route_groups_map(flows_map[flow])
 
             for trans_route in page_mod.transition_routes:
-                if 'target_page' in trans_route:
-                    if trans_route.target_page.split('/')[-1] == 'END_FLOW':
-                        trans_route.target_page = 'END_FLOW'
-                    elif trans_route.target_page.split('/')[-1] == 'START_PAGE':
-                        trans_route.target_page = 'START_PAGE'
+                if "target_page" in trans_route:
+                    if trans_route.target_page.split("/")[-1] == "END_FLOW":
+                        trans_route.target_page = "END_FLOW"
+                    elif trans_route.target_page.split("/")[-1] == "START_PAGE":
+                        trans_route.target_page = "START_PAGE"
                     else:
                         trans_route.target_page = pages_map[
-                            trans_route.target_page]
+                            trans_route.target_page
+                        ]
 
-                if 'intent' in trans_route:
+                if "intent" in trans_route:
                     trans_route.intent = intents_map[trans_route.intent]
-                    if 'webhook' in trans_route.trigger_fulfillment:
+                    if "webhook" in trans_route.trigger_fulfillment:
                         trans_route.trigger_fulfillment.webhook = webhooks_map[
                             trans_route.trigger_fulfillment.webhook
                         ]
-                elif ('condition' in trans_route and
-                'webhook' in trans_route.trigger_fulfillment):
+                elif (
+                    "condition" in trans_route
+                    and "webhook" in trans_route.trigger_fulfillment
+                ):
                     trans_route.trigger_fulfillment.webhook = webhooks_map[
                         trans_route.trigger_fulfillment.webhook
                     ]
+            for event_handler in page_mod.event_handlers:
+                if "target_page" in event_handler:
+                    if event_handler.target_page.split("/")[-1] == "END_FLOW":
+                        event_handler.target_page = "END_FLOW"
+                    elif (
+                        event_handler.target_page.split("/")[-1] == "START_PAGE"
+                    ):  # pylint: disable=line-too-long
+                        event_handler.target_page = "START_PAGE"
+                    else:
+                        event_handler.target_page = pages_map[
+                            event_handler.target_page
+                        ]
 
-        elif agent_type == 'destination':
+            for i, transition_route_group in enumerate(
+                page_mod.transition_route_groups
+            ):
+                page_mod.transition_route_groups[i] = trgs_map[
+                    transition_route_group
+                ]
+
+        elif agent_type == "destination":
             final_trs = []
+            final_ehs = []
+            final_trgs = []
             intents_map = self.intents.get_intents_map(agent_id, reverse=True)
             webhooks_map = self.webhooks.get_webhooks_map(
                 agent_id, reverse=True
             )
             flows_map = self.flows.get_flows_map(agent_id, reverse=True)
             pages_map = self.pages.get_pages_map(flows_map[flow], reverse=True)
+            trgs_map = self.route_groups.get_route_groups_map(
+                flows_map[flow], reverse=True
+            )
 
             page_mod.name = flows_map[flow]
             print(page_mod.name)
 
             for trans_route in page_mod.transition_routes:
-                if 'target_page' in trans_route:
-                    if trans_route.target_page in ['END_FLOW', 'START_PAGE']:
-                        if trans_route.target_page == 'END_FLOW':
+                if "target_page" in trans_route:
+                    if trans_route.target_page in ["END_FLOW", "START_PAGE"]:
+                        if trans_route.target_page == "END_FLOW":
                             trans_route.target_page = (
-                                flows_map[flow] + '/pages/END_FLOW')
-                        elif trans_route.target_page == 'START_PAGE':
+                                flows_map[flow] + "/pages/END_FLOW"
+                            )
+                        elif trans_route.target_page == "START_PAGE":
                             trans_route.target_page = (
-                                flows_map[flow] + '/pages/START_PAGE'
+                                flows_map[flow] + "/pages/START_PAGE"
                             )
 
                     elif trans_route.target_page in pages_map:
                         trans_route.target_page = pages_map[
-                            trans_route.target_page]
+                            trans_route.target_page
+                        ]
 
-                if 'intent' in trans_route:
+                if "intent" in trans_route:
                     if trans_route.intent not in intents_map:
                         logging.info(
-                            'Intent %s not in Intents Map. Skipping.',
-                            trans_route.intent)
+                            "Intent %s not in Intents Map. Skipping.",
+                            trans_route.intent,
+                        )
                     elif trans_route.intent in intents_map:
                         trans_route.intent = intents_map[trans_route.intent]
-                        if 'webhook' in trans_route.trigger_fulfillment:
+                        if "webhook" in trans_route.trigger_fulfillment:
                             trans_route.trigger_fulfillment.webhook = (
                                 webhooks_map[
                                     trans_route.trigger_fulfillment.webhook
-                                    ]
+                                ]
                             )
 
                         final_trs.append(trans_route)
 
-                elif ('condition' in trans_route and
-                'webhook' in trans_route.trigger_fulfillment):
+                elif (
+                    "condition" in trans_route
+                    and "webhook" in trans_route.trigger_fulfillment
+                ):
                     trans_route.trigger_fulfillment.webhook = webhooks_map[
                         trans_route.trigger_fulfillment.webhook
                     ]
                     final_trs.append(trans_route)
 
+                else:  # condition only
+                    final_trs.append(trans_route)
+
             page_mod.transition_routes = final_trs
 
-        return page_mod
+            for event_handler in page_mod.event_handlers:
+                if "target_page" in event_handler:
+                    if event_handler.target_page.split("/")[-1] == "END_FLOW":
+                        event_handler.target_page = "END_FLOW"
+                    elif (
+                        event_handler.target_page.split("/")[-1] == "START_PAGE"
+                    ):  # pylint: disable=line-too-long
+                        event_handler.target_page = "START_PAGE"
+                    else:
+                        event_handler.target_page = pages_map[
+                            event_handler.target_page
+                        ]
+                final_ehs.append(event_handler)
+            page_mod.event_handlers = final_ehs
+
+            for transition_route_group in page_mod.transition_route_groups:
+                if transition_route_group in trgs_map.keys():
+                    final_trgs.append(trgs_map[transition_route_group])
+                    print("Found")
+            page_mod.transition_route_groups = final_trgs
 
+        # Change display name to new flow name
+        page_mod.display_name = flow
+
+        return page_mod
 
     def get_page_dependencies(self, obj_list):
         """Pass in DFCX Page object(s) and retrieve all resource dependencies.
-
         Args:
-            - obj_list, a List of one or more DFCX Page Objects
-
+          obj_list: a List of one or more DFCX Page Objects
         Returns:
-            - resources, Dictionary containing all of the resource objects
+          Dictionary containing all of the resource objects
         """
         resources = defaultdict(list)
-        flow_id = '/'.join(obj_list[0].name.split('/')[0:8])
+        flow_id = "/".join(obj_list[0].name.split("/")[0:8])
 
         # Loop through Pages and find all dependencies
         for page in obj_list:
@@ -965,19 +1101,108 @@ def get_page_dependencies(self, obj_list):
             resources = self._get_condition_route_webhooks(page, resources)
             resources = self._get_intent_route_intents(page, resources)
             resources = self._get_form_entity_types(page, resources)
-            resources = self._get_route_groups_and_intents(page, flow_id,
-              resources)
+            resources = self._get_route_groups_and_intents(
+                page, flow_id, resources
+            )
 
         # Start Pages of Flows are special Page-like objects and have different
         # structure, so we need to extract the resources from them differently.
         resources = self._get_flow_intent_route_intents(
-            flow_id, obj_list, resources)
+            flow_id, obj_list, resources
+        )
 
         # Final check to look for additional Entity dependencies
-        if 'intents' in resources:
+        if "intents" in resources:
             resources = self._get_intent_entity_dependencies(resources)
 
         for key in resources:
             resources[key] = set(resources[key])
 
         return resources
+
+    def copy_route_groups(
+        self, agent_id: str, source_flow: str, target_flow: str
+    ):
+        """Creates new route groups in the target flow to match the route groups
+        in the source flow. Requires the pages to exist in the target flow. If
+        the pages do not exist, the destination of the route group routes will
+        be left as empty. Currently only works within an agent, not between
+        different agents. Also note that this will overwrite the content of the
+        route groups in the target flow if they already exist with the same
+        names.
+        """
+        flows_map = self.flows.get_flows_map(agent_id, reverse=True)
+        # Get the route groups for the source flow, and all the maps
+        target_route_groups_map = self.route_groups.get_route_groups_map(
+            flows_map[target_flow], reverse=True
+        )
+        source_route_groups = self.route_groups.list_transition_route_groups(
+            flows_map[source_flow]
+        )
+        target_route_groups = self.route_groups.list_transition_route_groups(
+            flows_map[target_flow]
+        )
+        source_pages_map = self.pages.get_pages_map(flows_map[source_flow])
+        target_pages_map = self.pages.get_pages_map(
+            flows_map[target_flow], reverse=True
+        )
+        # Need to create new route groups if they don't already exist
+        route_group_objects = []
+        for source_route_group in source_route_groups:
+            route_group_name = source_route_group.display_name
+            if route_group_name in target_route_groups_map:
+                print(
+                    f"Route group {route_group_name} already exists in target"\
+                    " flow"
+                )
+                for i, target_route_group in enumerate(target_route_groups):
+                    if target_route_group.display_name == route_group_name:
+                        route_group_objects.append(target_route_groups[i])
+                        # Assuming there aren't route groups with the same name
+                        break
+            else:
+                print(f"Creating new route group {route_group_name}")
+                route_group_objects.append(
+                    self.route_groups.create_transition_route_group(
+                        flows_map[target_flow],
+                        obj=None,
+                        display_name=route_group_name,
+                    )
+                )
+        # For each route group, update the routes
+        # source_route_groups and route_group_objects are in the same order
+        for i, source_route_group in enumerate(source_route_groups):
+            # route_group_objects[i] is the route group we will add routes to
+            # Note that this will overwrite the existing routes if a route group
+            # already existed in the target flow with this name
+            route_group_objects[
+                i
+            ].transition_routes = source_route_group.transition_routes
+            # Go through the route and update the references
+            for j, route in enumerate(route_group_objects[i].transition_routes):
+                if route.target_page:
+                    current_page_id = route.target_page
+                    new_page_id = None  # Empty transition by default
+                    if current_page_id in source_pages_map.keys():
+                        current_page_name = source_pages_map[current_page_id]
+                        if current_page_name in target_pages_map.keys():
+                            new_page_id = target_pages_map[current_page_name]
+                        else:
+                            print(
+                                f"Page '{current_page_name}' does not exist in"
+                                " target flow"
+                            )
+                            new_page_id = None  # Empty transition
+                    else:
+                        # Must be one of the special pages
+                        new_page_id = current_page_id.replace(
+                            flows_map[source_flow], flows_map[target_flow]
+                        )
+                    route_group_objects[i].transition_routes[
+                        j
+                    ].target_page = new_page_id
+            # Update the route group (hopefully)
+            print(f"Updating route group {route_group_objects[i].display_name}")
+            self.route_groups.update_transition_route_group(
+                route_group_objects[i].name, route_group_objects[i]
+            )
diff --git a/src/dfcx_scrapi/tools/dataframe_functions.py b/src/dfcx_scrapi/tools/dataframe_functions.py
index adc163d7..f8c50f0d 100644
--- a/src/dfcx_scrapi/tools/dataframe_functions.py
+++ b/src/dfcx_scrapi/tools/dataframe_functions.py
@@ -144,7 +144,7 @@ def _make_schema(columns: List[str]) -> pd.DataFrame:
             dataframe = dataframe.astype({column: type_map[column]})
             temp_data[column] = type_map[column]
 
-        dataframe = dataframe.append(temp_data, ignore_index=True)
+        dataframe = pd.concat([dataframe, temp_data], ignore_index=True)
 
         return dataframe
 
@@ -181,12 +181,12 @@ def _update_intent_from_dataframe(
           train_phrases: dataframe of training phrases in advanced have
             training_phrase and parts column to track the build
           params(optional): dataframe of parameters
-          mode: basic - build assuming one row is one training phrase no
-            entities, advance - build keeping track of training phrases and
+          mode: "basic" - build assuming one row is one training phrase no
+            entities, "advanced" - build keeping track of training phrases and
             parts with the training_phrase and parts column.
 
         Returns:
-          intent_pb: the new intents protobuf object
+          The new intents protobuf object
         """
 
         if mode == "basic":
@@ -266,10 +266,11 @@ def _update_intent_from_dataframe(
                 ]
                 parts = []
                 for _, row in tp_parts.iterrows():
+                    param_id = row["parameter_id"]
                     part = {
                         "text": row["text"],
-                        "parameter_id": row["parameter_id"],
-                    }
+                        "parameter_id": None if pd.isna(param_id) else param_id,
+                }
                     parts.append(part)
 
                 training_phrase = {"parts": parts, "repeat_count": 1, "id": ""}
@@ -333,8 +334,8 @@ def bulk_update_intents_from_dataframe(
             https://cloud.google.com/dialogflow/cx/docs/reference/language
 
         Returns:
-          modified_intents: dictionary with intent display names as keys and
-            the new intent protobufs as values
+          Dictionary with intent display names as keys and the new intent
+          protobufs as values
         """
 
         if mode == "basic":
@@ -496,15 +497,15 @@ def _create_intent_from_dataframe(
         Args:
           display_name: display_name parameter of the intent to create
           train_phrases: dataframe of training phrases in advanced have
-                  training_phrase and parts column to track the build
+            training_phrase and parts column to track the build
           params(optional): dataframe of parameters
           meta: dictionary
-          mode: basic - build assuming one row is one training phrase no
-                  entities, advance - build keeping track of training phrases
-                  and parts with the training_phrase and parts column.
+          mode: "basic" - build assuming one row is one training phrase no
+            entities, "advanced" - build keeping track of training phrases
+            and parts with the training_phrase and parts column.
 
         Returns:
-          intent_pb: the new intents protobuf object
+          The new intents protobuf object
         """
         if mode == "basic":
             if all(k in tp_df for k in ["text"]):
@@ -586,9 +587,10 @@ def _create_intent_from_dataframe(
                 ]
                 parts = []
                 for _, row in tp_parts.iterrows():
+                    param_id = row["parameter_id"]
                     part = {
                         "text": row["text"],
-                        "parameter_id": row["parameter_id"],
+                        "parameter_id": None if pd.isna(param_id) else param_id,
                     }
                     parts.append(part)
 
@@ -651,7 +653,7 @@ def bulk_create_intent_from_dataframe(
               the training_phrase and parts column.
           update_flag: True to update_flag the intents in the agent
           rate_limiter: number of seconds to wait between calls
-          meta: dictionary
+          meta: dictionary of intent metadata
           language_code: Language code of the intents being uploaded. Reference:
             https://cloud.google.com/dialogflow/cx/docs/reference/language
 
@@ -790,10 +792,10 @@ def create_entity_from_dataframe(
         Args:
           display_name: display_name parameter of the entity to update
           entity_df: dataframe values and synonyms
-          meta: dictionary
+          meta: dictionary of entity metadata
 
         Returns:
-          entity_pb: the new entity protobuf object
+          The new entity protobuf object
         """
         if not meta:
             meta = {}
@@ -837,8 +839,8 @@ def bulk_create_entity_from_dataframe(
           rate_limiter: seconds to sleep between operations.
 
         Returns:
-          custom_entities: dictionary with entity display names as keys and the
-            new entity protobufs as values
+          Dictionary with entity display names as keys and the
+          new entity protobufs as values
         """
 
         if "meta" in entities_df.columns:
@@ -897,8 +899,8 @@ def bulk_update_entity_from_dataframe(
           rate_limiter: seconds to sleep between operations.
 
         Returns:
-          custom_entities: dictionary with entity display names as keys and the
-            new entity protobufs as values
+          Dictionary with entity display names as keys and the
+          new entity protobufs as values
         """
         if "meta" in entities_df.columns:
             meta = (
@@ -943,8 +945,8 @@ def create_transition_route_from_dataframe(self, route_df):
         """Create transition route.
 
         Args:
-          route_df: dataframe with a singular routes data. Should only be one
-            row
+          route_df: dataframe with data for a single route. Should only be one
+          row. The columns required are:
             intent: intent id
             condition: string condition. ex.
               $session.params.dtmf_diy_opt_in = 1 AND
@@ -960,7 +962,7 @@ def create_transition_route_from_dataframe(self, route_df):
             rate_limiter: seconds to sleep between operations.
 
         Returns:
-          transitionRoute: transition route protobuf
+          The created transition route protobuf object
         """
 
         transition_route = types.TransitionRoute()
@@ -1015,30 +1017,32 @@ def create_transition_route_from_dataframe(self, route_df):
     def bulk_create_route_group_from_dataframe(
         self, display_name, agent_id, flow_id, route_group_df, update_flag=False
     ):
-        """create transition route - no support for end_session / just end flow.
+        """Create transition route - no support for end_session, just end flow.
 
         Args:
           display_name: name for the route group
           agent_id: agent id of target agent
           flow_id: flow id where to create route group
-          route_group_df: dataframe with a routes data
-            intent: intent id
-            condition: string condition. ex.
-              $session.params.dtmf_diy_opt_in = 1 AND
-              $session.params.dtmf_2_techinternet = 2
-            target_page: page id
-            target_flow: flow id
-            webhook: webhook id
-            webhook_tag: string webhook tag
-            custom_payload: a singular payload or list of payloads ex. [{}, {}]
-            fulfillment_text: = list of text ["yo", "hi"]
-            parameter_presets: = dictionary of parameter presets ex.
-              {"param1":"value","param2":"othervalues"}
-              update_flag: True to create the route group in the provided
+          route_group_df: dataframe with data for all routes in the route
+            group. Columns should have the following format:
+              intent: intent id
+              condition: string condition. ex.
+                $session.params.dtmf_diy_opt_in = 1 AND
+                $session.params.dtmf_2_techinternet = 2
+              target_page: page id
+              target_flow: flow id
+              webhook: webhook id
+              webhook_tag: string webhook tag
+              custom_payload: a singular payload or list of payloads
+                ex. [{}, {}]
+              fulfillment_text: = list of text ["yo", "hi"]
+              parameter_presets: = dictionary of parameter presets ex.
+                {"param1":"value","param2":"othervalues"}
+                update_flag: True to create the route group in the provided
                 flow id
 
         Returns:
-          rg: route group protobuf
+          The created route group protobuf object
         """
         if "intent" in route_group_df.columns:
             intents_map = self.intents.get_intents_map(
diff --git a/src/dfcx_scrapi/tools/levenshtein.py b/src/dfcx_scrapi/tools/levenshtein.py
index 6b97f375..f08c99f0 100644
--- a/src/dfcx_scrapi/tools/levenshtein.py
+++ b/src/dfcx_scrapi/tools/levenshtein.py
@@ -15,9 +15,10 @@
 # limitations under the License.
 
 import logging
+from typing import Union
+
 import pandas
 import numpy as np
-from typing import Union
 from dfcx_scrapi.core.intents import Intents
 from google.cloud.dialogflowcx_v3beta1 import types
 
@@ -32,9 +33,7 @@ class Levenshtein():
 
     @staticmethod
     def __levenshtein_ratio(s: str, t: str) -> float:
-        """ levenshtein_ratio_and_distance:
-            Calculates levenshtein distance between two strings.
-        """
+        """Calculates levenshtein distance between two strings."""
         rows = len(s)+1
         cols = len(t)+1
         distance = np.zeros((rows,cols),dtype = int)
@@ -69,28 +68,27 @@ def calc_tp_distances(
         threshold: float = 0.75,
         silent: bool = False
     ):
-        """
-        compares the training phrases between two intents to find the
+        """Compares the training phrases between two intents to find the
         levenshtein distance between all training phrases. Structure of the
         returned object depends on which intent is the key, and which is the
         comparator.
 
         Args:
           intent_key: intent protobuf or dataframe object. In the returned
-          object, the utterances of this intent are paired with a list of
-          utterances from the comparator.
+            object, the utterances of this intent are paired with a list of
+            utterances from the comparator.
 
           intent_comparator: intent protobuf or dataframe object. In the
-          returned object, the utterances of this intent will be included
-          in a list associated with every phrase whose similarity ratio
-          is above the defined threshold.
+            returned object, the utterances of this intent will be included
+            in a list associated with every phrase whose similarity ratio
+            is above the defined threshold.
 
           threshold: float describing the levenshtein distance above which
-          a pair of phrases should be associated. Default: .75
+            a pair of phrases should be associated. Default: .75
 
           silent: When set to True, the program will execute without
-          creating Info logs or updating the progress on the console.
-          Default=False
+            creating Info logs or updating the progress on the console.
+            Default=False
 
         Returns:
           Dict containing two major parts. The first ("stats") contains
@@ -99,11 +97,12 @@ def calc_tp_distances(
           from intent_key as the key, and utterance from intent_comparator
           with a similarity ratio above the defined threshold as the value.
         """
+        intents = Intents()
 
         if isinstance(intent_key, types.Intent):
-            list_keys = Intents.intent_proto_to_dataframe(
+            list_keys = intents.intent_proto_to_dataframe(
                 intent_key
-            ).tp
+            ).training_phrase
         elif isinstance(intent_key, pandas.core.frame.DataFrame):
             list_keys = intent_key.tp
         else:
@@ -115,9 +114,9 @@ def calc_tp_distances(
             return None
 
         if isinstance(intent_comparator, types.Intent):
-            list_comparators = Intents.intent_proto_to_dataframe(
+            list_comparators = intents.intent_proto_to_dataframe(
                 intent_comparator
-            ).tp
+            ).training_phrase
         elif isinstance(intent_comparator, pandas.core.frame.DataFrame):
             list_comparators = intent_comparator.tp
         else:
@@ -128,7 +127,7 @@ def calc_tp_distances(
                 """)
             return None
 
-        it = 0
+        count = 0
         completed = 0.0
         tp_distances = {}
         num_keys_overlapped = 0
@@ -140,7 +139,7 @@ def calc_tp_distances(
             found_key_similarity = False
 
             if not silent:
-                completed = float(it/len(list_keys))*100.0
+                completed = float(count/len(list_keys))*100.0
                 #print instead of logging.info is intentional.
                 #Needed to avoid spamming new lines with every percentile.
                 print(
@@ -163,7 +162,7 @@ def calc_tp_distances(
                         found_key_similarity = True
 
 
-            it += 1
+            count += 1
 
             #float greatest similarities to top
             phrase_similarity_list = dict(sorted(
diff --git a/src/dfcx_scrapi/tools/maker_util.py b/src/dfcx_scrapi/tools/maker_util.py
index 86e3006d..d20032ab 100644
--- a/src/dfcx_scrapi/tools/maker_util.py
+++ b/src/dfcx_scrapi/tools/maker_util.py
@@ -93,8 +93,8 @@ def make_transition_route(cls, obj=None, **kwargs):
         Note: if no args are provided, a blank Route object will be created.
 
         Args:
-          obj, (Optional) an existing Route object can be provided if the
-              user wants to modify or duplicate the object.
+          obj: (Optional) an existing Route object can be provided if the
+            user wants to modify or duplicate the object.
 
         Keyword Args:
           intent, (str): The UUID of the Intent to route to
@@ -102,7 +102,7 @@ def make_transition_route(cls, obj=None, **kwargs):
           target_page, (str): The UUID of the target page to transition to
           target_flow, (str): The UUID of the target flow to transition to
           trigger_fulfillment, (obj): Requires an object in the format of type
-          <google.cloud.dialogflowcx_v3beta1.types.fulfillment.Fulfillment>
+            <google.cloud.dialogflowcx_v3beta1.types.fulfillment.Fulfillment>
 
         Returns:
           Route object of type
@@ -143,16 +143,16 @@ def make_trigger_fulfillment(
         returned.
 
         Args:
-            messages: Optional list of Dialogue messages to send
-            webhook_id, (str): (Optional)
-                The UUID of the Dialogflow CX webhook to trigger
-                when the Fulfillment is triggered by the conversation.
-            webhook_tag, (str): (Required if webhook_id is provided)
-                User defined tag associated with
+          messages: Optional list of Dialogue messages to send
+          webhook_id, (str): (Optional)
+            The UUID of the Dialogflow CX webhook to trigger
+            when the Fulfillment is triggered by the conversation.
+          webhook_tag, (str): (Required if webhook_id is provided)
+            User defined tag associated with
 
         Returns:
-            Fulfillment object of type
-            <google.cloud.dialogflowcx_v3beta1.types.fulfillment.Fulfillment>
+          Fulfillment object of type
+          <google.cloud.dialogflowcx_v3beta1.types.fulfillment.Fulfillment>
         """
         fulfillment = types.fulfillment.Fulfillment()
 
diff --git a/src/dfcx_scrapi/tools/nlu_util.py b/src/dfcx_scrapi/tools/nlu_util.py
new file mode 100644
index 00000000..70bcd77d
--- /dev/null
+++ b/src/dfcx_scrapi/tools/nlu_util.py
@@ -0,0 +1,332 @@
+"""Utility file for NLU Utterance Similarity to work with CX resources."""
+
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import numpy as np
+import pandas as pd
+import scann
+import gspread
+from oauth2client.service_account import ServiceAccountCredentials
+from typing import Dict, Set
+
+import tensorflow_hub
+
+from dfcx_scrapi.core import flows
+from dfcx_scrapi.core import intents
+from dfcx_scrapi.core import pages
+from dfcx_scrapi.core import scrapi_base
+from dfcx_scrapi.core import transition_route_groups
+
+from google.colab import data_table
+
+data_table.enable_dataframe_formatter()
+
+SHEETS_SCOPE = [
+    "https://spreadsheets.google.com/feeds",
+    "https://www.googleapis.com/auth/drive",
+]
+
+
+class KonaEmbeddingModel:
+    """Download USE4 model and prep for calculating embeddings."""
+    def __init__(self):
+        module_url = "https://tfhub.dev/google/universal-sentence-encoder/4"
+        self.model = tensorflow_hub.load(module_url)
+
+    def embed(self, utterances, batch_size=512):
+        """Generates embeddings for a given set of utterances."""
+        embeddings = []
+        for next_idx in range(0, len(utterances), batch_size):
+            batch_utterances = utterances[next_idx: next_idx + batch_size]
+            batch_embeddings = self.model(batch_utterances).numpy()
+            embeddings.append(batch_embeddings)
+        embeddings = np.vstack(embeddings)
+
+        return embeddings
+
+class SheetsLoader:
+    """Load data from Google Sheets."""
+    def __init__(self, creds_path: str = None):
+        sheets_creds = ServiceAccountCredentials.from_json_keyfile_name(
+            filename=creds_path,
+            scopes=SHEETS_SCOPE,
+        )
+        self.sheets_client = gspread.authorize(sheets_creds)
+
+    def load_column_from_sheet(self, sheet_name, worksheet_name, column_name):
+        """Load a column from a Google Sheets file"""
+        try:
+            sheet = self.sheets_client.open(sheet_name)
+        except gspread.SpreadsheetNotFound as gse:
+            raise KeyError(
+                f"Couldn't find sheet '{sheet_name}'."
+                "Did you share it with your service account?"
+            ) from gse
+
+        worksheet = sheet.worksheet(worksheet_name)
+        worksheet_data = pd.DataFrame(worksheet.get_all_records())
+        try:
+            column_data = worksheet_data[column_name]
+        except KeyError as err:
+            raise KeyError(f"Couldn't find column '{column_name}'") from err
+
+        return column_data.to_numpy()
+
+
+class NaturalLanguageUnderstandingUtil(scrapi_base.ScrapiBase):
+    """Class to generate and analyze embeddings for a page."""
+
+    def __init__(
+        self,
+        agent_id: str,
+        flow_display_name: str,
+        page_display_name: str,
+        creds_path: str = None,
+        creds_dict: Dict[str, str] = None,
+        creds=None,
+    ):
+        super().__init__(
+            creds_path=creds_path,
+            creds_dict=creds_dict,
+            creds=creds,
+        )
+
+        print("Loading training data...")
+        self._load_data(agent_id, flow_display_name, page_display_name)
+
+        print("Loading embedder...")
+        self.embedder = KonaEmbeddingModel()
+
+        print("Generating embeddings for training data...")
+        (
+            self.training_intents,
+            self.training_phrases,
+        ) = self._get_training_phrases()
+        self.training_embeddings = self.generate_embeddings(
+            self.training_phrases
+        )
+
+        print("Loading ScaNN searcher...")
+        self.searcher = self._build_searcher(self.training_embeddings)
+
+    def _load_data(
+        self, agent_id: str, flow_display_name: str, page_display_name: str
+    ):
+        flow_loader = flows.Flows(creds=self.creds)
+        self.flow = flow_loader.get_flow_by_display_name(
+            flow_display_name, agent_id
+        )
+
+        page_loader = pages.Pages(creds=self.creds)
+        page_map = page_loader.get_pages_map(self.flow.name, reverse=True)
+        page_id = page_map.get(page_display_name, None)
+        if page_id is None:
+            raise ValueError(
+                f'Page "f{page_display_name}" does not exist in the '
+                "specified agent."
+            )
+        self.page = page_loader.get_page(page_id)
+
+        trg_loader = transition_route_groups.TransitionRouteGroups(
+            creds=self.creds
+        )
+        self.trgs = trg_loader.list_transition_route_groups(self.flow.name)
+        self.name_to_trg = {i.name: i for i in self.trgs}
+
+        intent_names = self._list_page_intents()
+        all_intents = intents.Intents(creds=self.creds).list_intents(agent_id)
+        self.intents = list(
+            filter(lambda x: x.name in intent_names, all_intents)
+        )
+
+    def _list_page_intents(self) -> Set[str]:
+        """Lists all intents in-scope at the given page."""
+        # See the public doc:
+        # https://cloud.google.com/dialogflow/cx/docs/concept/handler#eval
+        relevant_intents = set()
+        relevant_trgs = set(self.flow.transition_route_groups)
+        for tr in self.flow.transition_routes:
+            if tr.intent:
+                relevant_intents.add(tr.intent)
+        relevant_trgs |= set(self.page.transition_route_groups)
+        for tr in self.page.transition_routes:
+            if tr.intent:
+                relevant_intents.add(tr.intent)
+        for trg_name in relevant_trgs:
+            trg = self.name_to_trg[trg_name]
+            for tr in trg.transition_routes:
+                if tr.intent:
+                    relevant_intents.add(tr.intent)
+
+        return relevant_intents
+
+    def _get_training_phrases(self):
+        intent_list = []
+        training_phrases = []
+        for intent in self.intents:
+            for training_phrase in intent.training_phrases:
+                phrase_str = "".join([i.text for i in training_phrase.parts])
+                training_phrases.append(phrase_str)
+                intent_list.append(intent.display_name)
+
+        return np.array(intent_list), np.array(training_phrases)
+
+    def generate_embeddings(self, utterances):
+        return self.embedder.embed(utterances)
+
+    def _build_searcher(self, embeddings, num_neighbors=10):
+        normalized_dataset = (
+            embeddings / np.linalg.norm(embeddings, axis=1)[:, np.newaxis]
+        )
+
+        # Use ScaNN brute force. This is fine for up to ~20k points
+        searcher = (
+            scann.scann_ops_pybind.builder(
+                normalized_dataset, num_neighbors, "dot_product"
+            )
+            .score_brute_force()
+            .build()
+        )
+
+        return searcher
+
+    def find_similar_phrases(self, utterances):
+        embeddings = self.generate_embeddings(utterances)
+        nearest_idx, similarities = self.searcher.search_batched(embeddings)
+
+        df = pd.DataFrame(
+            {
+                "Utterance": utterances,
+                "Nearest Training Phrase": self.training_phrases[
+                    nearest_idx[:, 0]
+                ],
+                "Nearest Intent": self.training_intents[nearest_idx[:, 0]],
+                "Similarity": similarities[:, 0],
+            }
+        )
+
+        return df
+
+    def find_new_groups(self, utterances):
+        utterances = np.unique(utterances)
+
+        embeddings = self.generate_embeddings(utterances)
+
+        train_nearest_idx, train_similarities = self.searcher.search_batched(
+            embeddings
+        )
+
+        new_searcher = self._build_searcher(embeddings)
+        new_nearest_idx, new_similarities = new_searcher.search_batched(
+            embeddings
+        )
+
+        # Count how many new utterances are more similar
+        # than any training phrase.
+        closer_count = np.sum(
+            new_similarities > train_similarities[:, :1], axis=1
+        )
+
+        # Pull out the largest groups.
+        grouped_utterance_ids = set()
+        groups = []
+        similar_training_phrases = []
+        similar_intents = []
+        training_phrase_distances = []
+        for utterance_idx in np.argsort(closer_count)[::-1]:
+            if utterance_idx in grouped_utterance_ids:
+                continue
+
+            if closer_count[utterance_idx] < 2:
+                break
+
+            group_utterances = []
+            for other_idx in new_nearest_idx[
+                utterance_idx, : closer_count[utterance_idx]
+            ]:
+                if other_idx in grouped_utterance_ids:
+                    # Some of the utterances in this group were
+                    #  assigned to another group already, ignore this group.
+                    break
+                grouped_utterance_ids.add(other_idx)
+                group_utterances.append(utterances[other_idx])
+            else:
+                # Found a new group, add it.
+                match_idx = train_nearest_idx[utterance_idx, 0]
+                similar_training_phrases.append(
+                    self.training_phrases[match_idx]
+                )
+                similar_intents.append(self.training_intents[match_idx])
+                training_phrase_distances.append(
+                    train_similarities[utterance_idx, 0]
+                )
+                groups.append('"' + ('", "'.join(group_utterances)) + '"')
+
+        df = pd.DataFrame(
+            {
+                "Utterances": groups,
+                "Nearest Training Phrase": similar_training_phrases,
+                "Nearest Intent": similar_intents,
+                "Similarity": training_phrase_distances,
+            }
+        )
+
+        return df
+
+    def find_similar_training_phrases_in_different_intents(self):
+        num_utterances = len(self.training_phrases)
+        all_idx_1 = np.tile(np.arange(num_utterances)[:, None], 10)
+        all_idx_2, similarities = self.searcher.search_batched(
+            self.training_embeddings
+        )
+
+        # Only keep pairs in different intents
+        def intents_differ(idx_1, idx_2):
+            return self.training_intents[idx_1] != self.training_intents[idx_2]
+
+        different_intent_mask = np.vectorize(intents_differ)(
+            all_idx_1, all_idx_2
+        )
+
+        mismatch_mask = different_intent_mask & (similarities > 0.8)
+        mismatch_idx_1 = all_idx_1[mismatch_mask]
+        mismatch_idx_2 = all_idx_2[mismatch_mask]
+        mismatch_similarities = similarities[mismatch_mask]
+
+        # Remove any duplicates
+        sort_mask = mismatch_idx_1 > mismatch_idx_2
+        sort_vals_1 = mismatch_idx_1[sort_mask]
+        mismatch_idx_1[sort_mask] = mismatch_idx_2[sort_mask]
+        mismatch_idx_2[sort_mask] = sort_vals_1
+        (unique_idx_1, unique_idx_2), unique_index = np.unique(
+            [mismatch_idx_1, mismatch_idx_2], axis=1, return_index=True
+        )
+        unique_similarities = mismatch_similarities[unique_index]
+
+        df = (
+            pd.DataFrame(
+                {
+                    "Training phrase 1": self.training_phrases[unique_idx_1],
+                    "Training phrase 2": self.training_phrases[unique_idx_2],
+                    "Intent 1": self.training_intents[unique_idx_1],
+                    "Intent 2": self.training_intents[unique_idx_2],
+                    "Similarity": unique_similarities,
+                }
+            )
+            .sort_values("Similarity", ascending=False)
+            .reset_index(drop=True)
+        )
+
+        return df
diff --git a/src/dfcx_scrapi/tools/search_util.py b/src/dfcx_scrapi/tools/search_util.py
index d155589c..2ec17d27 100644
--- a/src/dfcx_scrapi/tools/search_util.py
+++ b/src/dfcx_scrapi/tools/search_util.py
@@ -1,4 +1,4 @@
-"""util class for doing searches"""
+"""Util class for doing searches"""
 
 # Copyright 2022 Google LLC
 #
@@ -39,7 +39,7 @@
 
 
 class SearchUtil(scrapi_base.ScrapiBase):
-    """class for searching items"""
+    """Class for searching items"""
 
     def __init__(
         self,
@@ -81,21 +81,23 @@ def __init__(
     @staticmethod
     def get_route_df(page_df: pd.DataFrame, route_group_df: pd.DataFrame):
         """Gets a route dataframe from page- and route-group-dataframes.
+
         Args:
-            page_df: dataframe with required columns flow_name, page_name,
-                routes (where routes are types.TransitionRoute) such as
-                from get_page_df().
-            route_group_df: dataframe woth required columns flow_name,
-                page_name, route_group_name, routes (where routes are
-                types.TransitionRoute) such as from get_route_group_df().
+          page_df: dataframe with required columns flow_name, page_name,
+            routes (where routes are types.TransitionRoute) such as
+            from get_page_df().
+          route_group_df: dataframe woth required columns flow_name,
+            page_name, route_group_name, routes (where routes are
+            types.TransitionRoute) such as from get_route_group_df().
+
         Returns:
-            routes dataframe with columns:
-                flow_name,
-                page_name,
-                route_group_name,
-                intent,
-                condition,
-                trigger_fulfillment
+          routes dataframe with columns:
+            flow_name,
+            page_name,
+            route_group_name,
+            intent,
+            condition,
+            trigger_fulfillment
         """
         routes_df = (
             pd.concat(
@@ -118,16 +120,18 @@ def get_route_df(page_df: pd.DataFrame, route_group_df: pd.DataFrame):
     @staticmethod
     def get_param_df(page_df: pd.DataFrame):
         """Gets a parameter dataframe from an input page dataframe.
+
         Args:
-            page_df: dataframe with minimum columns flow_name, page_name,
-                parameters (types.Form.Parameter), such as from get_page_df().
+          page_df: dataframe with minimum columns flow_name, page_name,
+            parameters (types.Form.Parameter), such as from get_page_df().
+
         Returns:
-            dataframe with columns:
-                flow_name,
-                page_name,
-                parameter_name,
-                reprompt_event_handlers,
-                initial_prompt_fulfillment
+          dataframe with columns:
+            flow_name,
+            page_name,
+            parameter_name,
+            reprompt_event_handlers,
+            initial_prompt_fulfillment
         """
         param_df = (
             page_df[["flow_name", "page_name", "parameters"]]
@@ -151,16 +155,17 @@ def get_param_df(page_df: pd.DataFrame):
     @staticmethod
     def get_event_handler_df(page_df, param_reprompt_event_handler_df):
         """Gets an event handler dataframe from page- and parameter-dataframes.
+
         Args:
-            page_df: dataframe with minimum columns flow_name, page_name,
-                event_handlers (types.EventHandler), such as from
-                get_page_df().
+          page_df: dataframe with minimum columns flow_name, page_name,
+            event_handlers (types.EventHandler), such as from get_page_df().
             param_reprompt_event_handler_df: dataframe with minimum columns
-                flow_name, page_name, parameter_name, reprompt_event_handlers
-                (types.EventHandler), such as from get_param_df().
+              flow_name, page_name, parameter_name, reprompt_event_handlers
+              (types.EventHandler), such as from get_param_df().
+
         Returns:
-            dataframe with columns: flow_name, page_name, parameter_name, event,
-                trigger_fulfillment.
+          dataframe with columns: flow_name, page_name, parameter_name, event,
+            trigger_fulfillment.
         """
         event_handler_df = (
             pd.concat(
@@ -187,10 +192,12 @@ def get_event_handler_df(page_df, param_reprompt_event_handler_df):
     @staticmethod
     def _get_msg_type(message: types.ResponseMessage):
         """Gets the response message type for a message from a fulfillment.
+
         Args:
-            message: message structure from a fulfillment.
+          message: message structure from a fulfillment.
+
         Returns:
-            type in {np.nan, text, custom_payload, play_audio,
+          type in {np.nan, text, custom_payload, play_audio,
             live_agent_handoff, conversation_success, output_audio_text}.
         """
         if pd.isna(message):
@@ -218,10 +225,12 @@ def _get_msg_type(message: types.ResponseMessage):
     @staticmethod
     def _gather_text_responses(text_message: types.ResponseMessage.Text):
         """Flattens a Dialogflow CX text structure.
+
         Args:
-            text_message: text such as is inside types.ResponseMessage.
+          text_message: text such as is inside types.ResponseMessage.
+
         Returns:
-            flattened text in a string.
+          Flattened text in a string.
         """
         flat_texts = "\n".join(text_message.text)
         return flat_texts
@@ -230,11 +239,13 @@ def _format_response_message(
         self, message: types.ResponseMessage, message_format: str
     ):
         """Conditionally unpacks message formats.
+
         Args:
-            message: structure such as from a fulfillment.
-            message_format: 'dict' or 'human-readable'
+          message: structure such as from a fulfillment.
+          message_format: 'dict' or 'human-readable'
+
         Returns:
-            unpacked contents of message.
+          Unpacked contents of message.
         """
         if pd.isna(message):
             contents = np.nan
@@ -260,7 +271,8 @@ def _format_response_message(
                 message.conversation_success.metadata
             )
             contents = (
-                {"conversation_success": c} if (message_format == "dict") else c
+                {"conversation_success": c} if (
+                    message_format == "dict") else c
             )
         elif "output_audio_text" in message:
             c = message.output_audio_text.text
@@ -321,7 +333,7 @@ def _find_true_routes_flow_level(self, flow_display_name, flow_map):
                     ]
                 ],
             )
-            pages_dataframe = pages_dataframe.append(page_dataframe)
+            pages_dataframe = pd.concat([pages_dataframe, page_dataframe])
 
         return pages_dataframe
 
@@ -336,7 +348,8 @@ def _flow_level_handlers(self):
 
             for handler in flow_level_event_handlers:
                 flow_level_event_handlers_dataframe = (
-                    flow_level_event_handlers_dataframe.append(
+                    pd.concat([
+                        flow_level_event_handlers_dataframe,
                         pd.DataFrame(
                             columns=[
                                 "flow",
@@ -355,11 +368,12 @@ def _flow_level_handlers(self):
                                 ]
                             ],
                         )
-                    )
+                    ])
                 )
-                flow_event_handler_data = flow_event_handler_data.append(
+                flow_event_handler_data = pd.concat([
+                    flow_event_handler_data,
                     flow_level_event_handlers_dataframe
-                )
+                ])
 
         return flow_event_handler_data
 
@@ -375,7 +389,8 @@ def _page_level_handlers(self):
                 page_level_event_handlers_dataframe = pd.DataFrame()
                 for handler in page_level_event_handlers:
                     page_level_event_handlers_dataframe = (
-                        page_level_event_handlers_dataframe.append(
+                        pd.concat([
+                            page_level_event_handlers_dataframe,
                             pd.DataFrame(
                                 columns=[
                                     "flow",
@@ -396,14 +411,13 @@ def _page_level_handlers(self):
                                     ]
                                 ],
                             )
-                        )
+                        ])
                     )
 
-                page_level_event_handlers_all_dataframe = (
-                    page_level_event_handlers_all_dataframe.append(
-                        page_level_event_handlers_dataframe
-                    )
-                )
+                page_level_event_handlers_all_dataframe = pd.concat([
+                    page_level_event_handlers_all_dataframe,
+                    page_level_event_handlers_dataframe
+                ])
         return page_level_event_handlers_all_dataframe
 
     # Parameters - event handlers
@@ -420,7 +434,8 @@ def _parameter_level_handlers(self):
                     )
                     param_lvl_event_df = pd.DataFrame()
                     for handler in parameter_event_handlers:
-                        param_lvl_event_df = param_lvl_event_df.append(
+                        param_lvl_event_df = pd.concat([
+                            param_lvl_event_df,
                             pd.DataFrame(
                                 columns=[
                                     "flow",
@@ -443,12 +458,11 @@ def _parameter_level_handlers(self):
                                     ]
                                 ],
                             )
-                        )
-                    parameter_level_event_handlers_all_dataframe = (
-                        parameter_level_event_handlers_all_dataframe.append(
-                            param_lvl_event_df
-                        )
-                    )
+                        ])
+                    parameter_level_event_handlers_all_dataframe = pd.concat([
+                        parameter_level_event_handlers_all_dataframe,
+                        param_lvl_event_df
+                    ])
         return parameter_level_event_handlers_all_dataframe
 
     def find_list_parameters(self, agent_id):
@@ -462,11 +476,11 @@ def find_list_parameters(self, agent_id):
         all instances of parameters being used as lists on pages.
 
         Args:
-          - agent_id, the Agent ID string in the following format:
+          agent_id: the Agent ID string in the following format:
             projects/<project_id>/locations/<location_id>/agents/<agent_id>
 
         Returns:
-          - params_map, a Dict of parameter names and Pages they belong to
+          A Dictionary of parameter names and Pages they belong to
         """
 
         # entities = self.dfcx.list_entity_types(agent_id)
@@ -484,14 +498,14 @@ def find_list_parameters(self, agent_id):
         return params_list
 
     def search_conditionals_page(self, page_id, search):
-        """search page for an exact string in conditional routes
+        """Search page for an exact string in conditional routes
 
         Args:
-          - page_id, the formatted CX Page ID to use
-          - search, string to search
+          page_id: the formatted CX Page ID to use
+          search: string to search
 
         Returns:
-          - locator, dataframe of the results of where this string was found
+          Dataframe of the results of where this string was found
         """
 
         locator = pd.DataFrame()
@@ -503,20 +517,20 @@ def search_conditionals_page(self, page_id, search):
                     columns=["resource_id", "condition", "route_id"],
                     data=[[page_id, route.condition, i]],
                 )
-                locator = locator.append(iter_frame)
+                locator = pd.concat([locator, iter_frame])
             i += 1
 
         return locator
 
     def search_conditionals_flow(self, flow_id, search):
-        """search flow for an exact string in conditional routes
+        """Search flow for an exact string in conditional routes
 
         Args:
-        - flow_id, the formatted CX Flow ID to use
-        - search, string to search
+          flow_id: the formatted CX Flow ID to use
+          search: string to search
 
         Returns:
-        - locator, dataframe of the results of where this string was found
+          Dataframe of the results of where this string was found
         """
 
         locator = pd.DataFrame()
@@ -528,7 +542,7 @@ def search_conditionals_flow(self, flow_id, search):
                     columns=["resource_id", "condition", "route_id"],
                     data=[[flow_id, route.condition, i]],
                 )
-                locator = locator.append(iter_frame)
+                locator = pd.concat([locator, iter_frame])
             i += 1
 
         return locator
@@ -546,24 +560,25 @@ def search_conditionals(
         Search conditionals for an exact string in conditional routes.
 
         Args:
-            - search, string to search
-            - agent_id, the formatted CX Agent ID to use
-            - flow_name, (optional) the display name of the flow to search
-            - page_name,  (optional) the display name of the page to search
-            - flag_search_all, (optional)
-                When set to True:
-                    -if just an agent_id then entire agent is searched
-                    -if just an agent_id and flow_name are specified
-                        an entire flow is searched
-                    -if an agent_id, flow_name and page_name are specified
-                        a page is searched
-                When set to False:
-                    -if just an agent_id and flow_name are specified
-                        only the start page of the flow is searched
-                    -if an agent_id, flow_name and page_name are specified
-                        a page is searched
+          search: string to search
+          agent_id: the formatted CX Agent ID to use
+          flow_name: (optional) the display name of the flow to search
+          page_name:  (optional) the display name of the page to search
+          flag_search_all: (optional)
+            When set to True:
+              if just an agent_id, then entire agent is searched.
+              if just an agent_id and flow_name are specified,
+                then an entire flow is searched.
+              if an agent_id, flow_name and page_name are specified,
+                then a page is searched.
+            When set to False:
+              if just an agent_id and flow_name are specified,
+                then only the start page of the flow is searched.
+              if an agent_id, flow_name and page_name are specified,
+                then a page is searched.
+
         Returns:
-            - locator, dataframe of the results of where this string was found
+          Dataframe of the results of where this string was found
         """
 
         if page_name:
@@ -605,7 +620,7 @@ def search_conditionals(
                 )
                 flow_search.insert(0, "resource_name", flow_name)
                 flow_search.insert(0, "resource_type", "flow")
-                locator = locator.append(flow_search)
+                locator = pd.concat([locator, flow_search])
             except ValueError:
                 logging.error(
                     "%s is not a valid flow_name for agent %s",
@@ -625,7 +640,7 @@ def search_conditionals(
                     time.sleep(0.5)
                     page_search.insert(0, "resource_name", page)
                     page_search.insert(0, "resource_type", "page")
-                    locator = locator.append(page_search)
+                    locator = pd.concat([locator, page_search])
 
             return locator
 
@@ -641,7 +656,7 @@ def search_conditionals(
                 )
                 flow_search.insert(0, "resource_name", flow)
                 flow_search.insert(0, "resource_type", "flow")
-                locator = locator.append(flow_search)
+                locator = pd.concat([locator, flow_search])
                 pages_map = self.pages.get_pages_map(
                     flow_id=flows_map[flow], reverse=True
                 )
@@ -652,7 +667,7 @@ def search_conditionals(
                     time.sleep(0.5)
                     page_search.insert(0, "resource_name", page)
                     page_search.insert(0, "resource_type", "page")
-                    locator = locator.append(page_search)
+                    locator = pd.concat([locator, page_search])
             return locator
 
         # not found
@@ -665,19 +680,19 @@ def find_true_routes(self, agent_id: str = None):
         can escape this page no matter what.
 
         Args:
-          - agent_id: The properly formatted CX Agent ID
+          agent_id: The properly formatted CX Agent ID
 
         Returns:
-          - agent_results: dataframe with:
-              flow_display_name: display name of the associated page
-              page_display_name: display name of the page with the associated
-                data
-              webhook_entry_fulfillments: True if a page has a webhook on entry
-                else False
-              has_parameters: True if a page has parameters else False
-              has_true_route: True if a page has a true route else False
-              has_true_and_final_route: True if a page has a route with true
-                + page.params.status=Final else False
+          agent_results: dataframe with:
+            flow_display_name: display name of the associated page
+            page_display_name: display name of the page with the
+              associated data
+            webhook_entry_fulfillments: True if a page has a webhook on the
+              entry fulfillment else False
+            has_parameters: True if a page has parameters else False
+            has_true_route: True if a page has a true route else False
+            has_true_and_final_route: True if a page has a route with true
+              and page.params.status=Final else False
         """
 
         if not agent_id:
@@ -690,7 +705,7 @@ def find_true_routes(self, agent_id: str = None):
             flow_scan = self._find_true_routes_flow_level(
                 flow_display_name, flow_map
             )
-            agent_results = agent_results.append(flow_scan)
+            agent_results = pd.concat([agent_results, flow_scan])
         return agent_results
 
     # Event handlers Main Function
@@ -698,13 +713,11 @@ def find_event_handlers(self):
         """This method extracts event handlers at the flow, page and parameter
         level and displays data about their associated event. A user can use
         this data to spot patterns in event types and look for detrimental
-        patterns.
-
-        Args:
-          - agent_id must specify agent id when instantiating the class
+        patterns. To use this function, you must specify agent id when
+        instantiating the class.
 
         Returns:
-          - dictionary with flow, page and parameter events
+          Dictionary with flow, page and parameter events
         """
         event_handler_scan = {
             "flow": self._flow_level_handlers(),
@@ -723,21 +736,22 @@ def get_agent_fulfillment_message_df(
         exception of webhook content.
 
         Args:
-            agent_id: ID of the Dialogflow CX agent.
-            format_message: minor processing of the message to be more readable,
-                default True.
+          agent_id: ID of the Dialogflow CX agent.
+          format_message: minor processing of the message to be more readable,
+            default True.
+
         Returns:
-            dataframe with columns:
-                flow_name,
-                page_name,
-                parameter_name,
-                event,
-                route_group_name,
-                intent,
-                condition,
-                response_type,
-                response_message,
-                conditional_cases
+          dataframe with columns:
+            flow_name,
+            page_name,
+            parameter_name,
+            event,
+            route_group_name,
+            intent,
+            condition,
+            response_type,
+            response_message,
+            conditional_cases
         """
         if message_format not in {"proto", "dict", "human-readable"}:
             raise ValueError(
@@ -795,22 +809,24 @@ def get_raw_agent_fulfillment_df(self, agent_id: str):
         """Gets all fulfillment structures for an agent.
 
         Args:
-            agent_id: ID of the Dialogflow CX agent.
+          agent_id: ID of the Dialogflow CX agent.
+
         Returns:
-            dataframe with columns:
-                flow_name,
-                page_name,
-                parameter_name,
-                event,
-                route_group_name,
-                intent,
-                condition,
-                fulfillment
+          dataframe with columns:
+            flow_name,
+            page_name,
+            parameter_name,
+            event,
+            route_group_name,
+            intent,
+            condition,
+            fulfillment
         """
         flow_df = self.get_flow_df(agent_id)
         page_df = self.get_page_df(flow_df)
 
-        route_group_df = self.get_route_group_df(page_df, list(flow_df.flow_id))
+        route_group_df = self.get_route_group_df(
+            page_df, list(flow_df.flow_id))
         route_df = SearchUtil.get_route_df(page_df, route_group_df)
         intent_map = self.intents.get_intents_map(agent_id)
         route_df.intent = route_df.intent.map(intent_map)
@@ -849,7 +865,8 @@ def get_raw_agent_fulfillment_df(self, agent_id: str):
                 event_handler_df.rename(
                     columns={"trigger_fulfillment": "fulfillment"}
                 ),
-                route_df.rename(columns={"trigger_fulfillment": "fulfillment"}),
+                route_df.rename(
+                    columns={"trigger_fulfillment": "fulfillment"}),
                 param_initial_prompt_fulfillment_df.rename(
                     columns={"initial_prompt_fulfillment": "fulfillment"}
                 ),
@@ -862,14 +879,15 @@ def get_flow_df(self, agent_id: str):
         """Gets a flow dataframe for an agent.
 
         Args:
-            agent_id: ID of the Dialogflow CX agent.
+          agent_id: ID of the Dialogflow CX agent.
+
         Returns:
-            flow dataframe with columns:
-                flow_name,
-                flow_id,
-                routes,
-                event_handlers,
-                route_groups
+          flow dataframe with columns:
+            flow_name,
+            flow_id,
+            routes,
+            event_handlers,
+            route_groups
         """
         flowlist = self.flows.list_flows(agent_id=agent_id)
         flow_df = pd.DataFrame(
@@ -888,46 +906,48 @@ def get_flow_df(self, agent_id: str):
 
     def get_page_df(self, flow_df: pd.DataFrame):
         """Gets pages dataframe for an agent.
+
         Args:
-            flow_df: flow dataframe from get_flow_df().
+          flow_df: flow dataframe from get_flow_df().
+
         Returns:
-            page dataframe with columns:
-                flow_name,
-                page_name,
-                routes,
-                event_handlers,
-                route_groups,
-                parameters,
-                entry_fulfillment
+          page dataframe with columns:
+            flow_name,
+            page_name,
+            routes,
+            event_handlers,
+            route_groups,
+            parameters,
+            entry_fulfillment
         """
         page_df = (
             flow_df[["flow_name", "flow_id"]]
             .assign(page_obj=flow_df.flow_id.apply(self.pages.list_pages))
             .explode("page_obj", ignore_index=True)
-            )
+        )
 
         # Handle edge case where Flow exists without Pages
         page_df = page_df[~page_df.page_obj.isna()]
 
         page_df = page_df.assign(
-                page_name=lambda df: df.page_obj.apply(
-                    attrgetter("display_name")
-                ),
-                entry_fulfillment=lambda df: df.page_obj.apply(
-                    attrgetter("entry_fulfillment")
-                ),
-                parameters=lambda df: df.page_obj.apply(
-                    attrgetter("form.parameters")
-                ),
-                route_groups=lambda df: df.page_obj.apply(
-                    attrgetter("transition_route_groups")
-                ),
-                routes=lambda df: df.page_obj.apply(
-                    attrgetter("transition_routes")
-                ),
-                event_handlers=lambda df: df.page_obj.apply(
-                    attrgetter("event_handlers")
-                ))
+            page_name=lambda df: df.page_obj.apply(
+                attrgetter("display_name")
+            ),
+            entry_fulfillment=lambda df: df.page_obj.apply(
+                attrgetter("entry_fulfillment")
+            ),
+            parameters=lambda df: df.page_obj.apply(
+                attrgetter("form.parameters")
+            ),
+            route_groups=lambda df: df.page_obj.apply(
+                attrgetter("transition_route_groups")
+            ),
+            routes=lambda df: df.page_obj.apply(
+                attrgetter("transition_routes")
+            ),
+            event_handlers=lambda df: df.page_obj.apply(
+                attrgetter("event_handlers")
+            ))
 
         page_df = page_df.drop(columns="page_obj")
 
@@ -942,18 +962,20 @@ def get_route_group_df(
         self, page_df: pd.DataFrame, flow_id_list: List[str]
     ):
         """Gets route groups dataframe for the pages in an input dataframe.
+
         Args:
-            page_df: dataframe with required columns flow_name, page_name,
-                route_groups (where route_groups are route group IDs) such as
-                from get_page_df().
-            flow_id_list: contains the flow IDs for flows containing the pages
-                in page_df arg.
+          page_df: dataframe with required columns flow_name, page_name,
+            route_groups (where route_groups are route group IDs) such as
+            from get_page_df().
+          flow_id_list: contains the flow IDs for flows containing the pages
+            in page_df arg.
+
         Returns:
-            route group dataframe with columns:
-                flow_name,
-                page_name,
-                route_group_name,
-                routes
+          route group dataframe with columns:
+            flow_name,
+            page_name,
+            route_group_name,
+            routes
         """
         agent_route_groups = []
         for flow_id in flow_id_list:
diff --git a/src/dfcx_scrapi/tools/semantic_clustering.py b/src/dfcx_scrapi/tools/semantic_clustering.py
index e395b131..e9911607 100644
--- a/src/dfcx_scrapi/tools/semantic_clustering.py
+++ b/src/dfcx_scrapi/tools/semantic_clustering.py
@@ -60,11 +60,12 @@ def __init__(self, phrases: pd.DataFrame):
     @staticmethod
     def _string_cleaner(string):
         """Clean text by removing tokens, punctuation, and applying lower().
+
         Args:
-            string: text string to be cleaned
+          string: text string to be cleaned
 
         Returns:
-            x: cleaned string
+          Cleaned string
         """
         string = re.sub(r"[^\w\s]", "", string.lower())
         tokens = ["\n", "\r", "\t"]
@@ -85,23 +86,24 @@ def _single_cluster_algo(
         n_jobs=-1,
     ):
         """Cluster phrases using a model with set hyperparameters
+
         Args:
-            data: DataFrame to cluster
-            eps: max distance between two points for them to be
-                considered in same neighborhood.
-            min_samples: minimum number of samples a cluster can have
-            metric: metric for measuring distance between instances
-                in a feature array.
-            metric_params: additional keywords for metric function
-            algorithm: algo used to compute pointwise distances and
-                find nearest neighbors.
-            leaf-size: only passed to BallTree or cKDTree algorithms.
-            p: power of Minkowski metric to calculate distance between points.
-                DEFAULT = 2 (Euclidean distance)
-            n_jobs: number of parallel jobs to run. -1 means all processors
+          data: DataFrame to cluster
+          eps: max distance between two points for them to be
+            considered in same neighborhood.
+          min_samples: minimum number of samples a cluster can have
+          metric: metric for measuring distance between instances
+            in a feature array.
+          metric_params: additional keywords for metric function
+          algorithm: algo used to compute pointwise distances and
+            find nearest neighbors.
+          leaf-size: only passed to BallTree or cKDTree algorithms.
+          p: power of Minkowski metric to calculate distance between points.
+            DEFAULT = 2 (Euclidean distance)
+          n_jobs: number of parallel jobs to run. -1 means all processors
 
         Returns:
-            data: input data with associated clusters by the text column.
+          Input data with associated clusters by the text column.
         """
 
         input_data = list(data["cleaned_text"])
@@ -149,25 +151,25 @@ def cluster(
             with increasing neighborhood sizes.
 
         Args:
-            stop_threshold: Percentage of data which can be in no cluster
-                to signify that new models can stop being created.
-            max_rounds: maximum number of rounds that take place of
-                trying new model hyperparameters to get to the stop_threshold.
-            iterator: eps value to change by in each round.
-            start_eps: eps value to run on the first algo.
-            min_samples: minimum number of samples a cluster can have
-            metric: metric for measuring distance between instances
-                in a feature array.
-            metric_params: additional keywords for metric function
-            algorithm: algo used to compute pointwise distances and find
-                nearest neighbors.
-            leaf-size: only passed to BallTree or cKDTree algorithms.
-            p: power of Minkowski metric to calculate distance between points.
-                DEFAULT = 2 (Euclidean distance)
-            n_jobs: number of parallel jobs to run. -1 means all processors
+          stop_threshold: Percentage of data which can be in no cluster
+            to signify that new models can stop being created.
+          max_rounds: maximum number of rounds that take place of
+            trying new model hyperparameters to get to the stop_threshold.
+          iterator: eps value to change by in each round.
+          start_eps: eps value to run on the first algo.
+          min_samples: minimum number of samples a cluster can have
+          metric: metric for measuring distance between instances
+            in a feature array.
+          metric_params: additional keywords for metric function
+          algorithm: algo used to compute pointwise distances and find
+            nearest neighbors.
+          leaf-size: only passed to BallTree or cKDTree algorithms.
+          p: power of Minkowski metric to calculate distance between points.
+            DEFAULT = 2 (Euclidean distance)
+          n_jobs: number of parallel jobs to run. -1 means all processors
 
         Returns:
-            clustered: DataFrame of clustered data.
+          clustered: DataFrame of clustered data.
         """
 
         if not hasattr(self, "transformed_data"):
@@ -220,7 +222,7 @@ def cluster(
                 )
                 clustered_this_round.insert(0, "eps", eps)
                 clustered_this_round.insert(0, "round", cluster_round)
-                clustered = clustered.append(clustered_this_round)
+                clustered = pd.concat([clustered, clustered_this_round])
                 unclustered = cluster_attempt.copy()[
                     cluster_attempt["cluster"] == -1
                 ]
@@ -247,9 +249,8 @@ def cluster(
             return clustered
 
         unclustered = unclustered.drop(columns="cluster")
-        clustered = clustered.sort_values(by="cluster", ascending=True).append(
-            unclustered
-        )
+        clustered = pd.concat(
+            [clustered.sort_values(by="cluster", ascending=True), unclustered])
 
         if cluster_round > max_rounds:
             logging.info("max clutering rounds reached before stop threshold")
diff --git a/src/dfcx_scrapi/tools/utterance_generator_util.py b/src/dfcx_scrapi/tools/utterance_generator_util.py
index 9b74c10b..54dfc129 100644
--- a/src/dfcx_scrapi/tools/utterance_generator_util.py
+++ b/src/dfcx_scrapi/tools/utterance_generator_util.py
@@ -16,8 +16,10 @@
 
 import logging
 import string
-import pandas as pd
 from typing import List, Dict
+
+import pandas as pd
+
 from google.oauth2 import service_account
 from dfcx_scrapi.core import scrapi_base
 from dfcx_scrapi.core import intents
@@ -66,11 +68,10 @@ def _progress_bar(
         """Display progress bar for processing.
 
         Args:
-            current: number for current iteration.
-            total: number for total iterations.
-            bar_length: number of spaces to make the progress bar,
-                default 50.
-            type_: label for the bar, default 'Progress'.
+          current: number for current iteration.
+          total: number for total iterations.
+          bar_length: number of spaces to make the progress bar, default 50.
+          type_: label for the bar, default 'Progress'.
         """
         percent = float(current) * 100 / total
         arrow = "-" * int(percent / 100 * bar_length - 1) + ">"
@@ -87,13 +88,15 @@ def _clean_string(string_raw: str) -> str:
         Cleans a string with the same steps for comparison whether
         the generated text exists or not, removes phrases which
         only differ by:
-            -case,
-            -punctuation, or
-            -leading and trailing spaces.
+          case,
+          punctuation, or
+          leading and trailing spaces.
+
         Args:
-            string_raw: phrase to clean
+          string_raw: phrase to clean
+
         Returns:
-            cleaned string
+          Cleaned string
         """
         return (
             string_raw.translate(str.maketrans("", "", string.punctuation))
@@ -113,12 +116,13 @@ def _remove_training(
         clean_string to both.
 
         Args:
-            synth_intent_dataset: dataframe containing generated training
-                phrases.
-            existing_phrases: list of phrases that already exist as intent
-                training phrases.
+          synth_intent_dataset: dataframe containing generated training
+            phrases.
+          existing_phrases: list of phrases that already exist as intent
+            training phrases.
+
         Returns:
-            a dataframe of new only generated phrases.
+          A dataframe of new only generated phrases.
         """
         existing_phrases_cleaned = [
             self._clean_string(phrase) for phrase in existing_phrases
@@ -161,16 +165,17 @@ def _generate_phrases_intent(
     ) -> pd.DataFrame:
         """Generates new synthetic phrases.
 
-        main internal function for generating new synthetic phrases from
+        Main internal function for generating new synthetic phrases from
         the existing training phrases within an intent. The synthetic phrases
         are only as good as the training phrases in the intent.
 
         Args:
-            training_phrases_one_intent: input phrases from which to generate
-                new training phrases.
-            synthetic_phrases_per_intent: number of phrases to generate.
+          training_phrases_one_intent: input phrases from which to generate
+            new training phrases.
+          synthetic_phrases_per_intent: number of phrases to generate.
+
         Returns:
-            a DataFrame containing synthetic training phrases.
+          A DataFrame containing synthetic training phrases.
         """
         synthetic_instances = (
             synthetic_phrases_per_intent // len(training_phrases_one_intent)
@@ -221,12 +226,13 @@ def _generate_phrases(
         user-specified intents.
 
         Args:
-            training_phrases: df of training phrases for multiple intents with
-                an Intent "display_name" column.
-            dataset_size: number of requested phrases to generate over all
-                specified intents.
+          training_phrases: df of training phrases for multiple intents with
+            an Intent "display_name" column.
+          dataset_size: number of requested phrases to generate over all
+            specified intents.
+
         Returns:
-            a DataFrame of generated training phrases.
+            A DataFrame of generated training phrases.
         """
         # TODO: fix math for training phrase generation.
         synthetic_dataset = pd.DataFrame()
@@ -243,7 +249,7 @@ def _generate_phrases(
             intent_set = self._generate_phrases_intent(
                 training_phrases_one_intent, synthetic_phrases_per_intent
             )
-            synthetic_dataset = synthetic_dataset.append(intent_set)
+            synthetic_dataset = pd.concat([synthetic_dataset, intent_set])
             i += 1
             self._progress_bar(i, len(intents_list))
 
@@ -255,22 +261,21 @@ def create_synthetic_dataset(
         """Creates a synthetic test dataset.
 
         Creates a test dataset where none of the utterances in the test
-            dataset are in the training of the existing phrases.
+        dataset are in the training of the existing phrases.
 
         Args:
-            agent_id: ID of the DFCX agent.
-            intent_subset: intents to generate a test dataset for.
-            dataset_size: number of synthetic phrases to generate, default
-                100.
+          agent_id: ID of the DFCX agent.
+          intent_subset: intents to generate a test dataset for.
+          dataset_size: number of synthetic phrases to generate, default 100.
+
         Returns:
-            a DataFrame containing synthetic test dataset utterances
-                columns:
-                id: IDs of original utterances.
-                synthetic instances: number of synthetic phrases per
-                    original phrase.
-                utterance: original utterances.
-                synthetic_phrases: generated phrases.
-                intent: intent the utterance is from.
+          DataFrame containing synthetic test dataset utterances with columns:
+            id: IDs of original utterances.
+            synthetic instances: number of synthetic phrases per
+              original phrase.
+            utterance: original utterances.
+            synthetic_phrases: generated phrases.
+            intent: intent the utterance is from.
         """
         training_phrases = self.intents.bulk_intent_to_df(
             agent_id=agent_id, intent_subset=intent_subset
@@ -302,30 +307,29 @@ def create_test_dataset(
         run the set at the correct location.
 
         Args:
-            agent_id: name parameter of the agent to pull intents from
-                full path to agent.
-            intent_subset: display names of the intents to create a new
-                phrases for, base phrases come from the training in the
-                intent.
+          agent_id: name parameter of the agent to pull intents from
+            full path to agent.
+          intent_subset: display names of the intents to create a new
+            phrases for, base phrases come from the training in the intent.
+          flow_display_name: display name of the flow location at which
+            to run the core.conversations run_intent_detection
+            function, default Default Start Flow.
+          page_display_name: display name of the page location at which
+            to run the core.conversations run_intent_detection function,
+            default START_PAGE.
+          dataset_size: overall target size of the test set to create,
+            may be less depending if new independent phrases can be
+            generated from the data. The function tries to get even
+            entries per intent, default 100.
+
+        Returns:
+          Dataframe with columns:
             flow_display_name: display name of the flow location at which
-                to run the core.conversations run_intent_detection
-                function, default Default Start Flow.
+              to run the core.conversations run_intent_detection
+              function.
             page_display_name: display name of the page location at which
-                to run the core.conversations run_intent_detection function,
-                default START_PAGE.
-            dataset_size: overall target size of the test set to create,
-                may be less depending if new independent phrases can be
-                generated from the data. The function tries to get even
-                entries per intent, default 100.
-        Returns:
-            Dataframe with columns:
-                flow_display_name: display name of the flow location at which
-                    to run the core.conversations run_intent_detection
-                    function.
-                page_display_name: display name of the page location at which
-                    to run the core.conversations run_intent_detection
-                    function.
-                utterance: original utterances.
+              to run the core.conversations run_intent_detection function.
+            utterance: original utterances.
         """
         synthetic_dataset = self.create_synthetic_dataset(
             agent_id, intent_subset, dataset_size
@@ -359,19 +363,19 @@ def create_new_training_phrases(
         be used to make the final updates to the CX agent.
 
         Args:
-            agent_id: name parameter of the agent to pull intents from - full
-                path to agent.
-            intent_subset: display names of the intents to create a new phrases
-                for, base phrases come from the training in the intent.
-            new_phrases: overall target size of new phrases to create, may be
-                less depending if new independent phrases can be generated from
-                the data. The function tries to get even entries per intent.
+          agent_id: name parameter of the agent to pull intents from - full
+            path to agent.
+          intent_subset: display names of the intents to create a new phrases
+            for, base phrases come from the training in the intent.
+          new_phrases: overall target size of new phrases to create, may be
+            less depending if new independent phrases can be generated from
+            the data. The function tries to get even entries per intent.
 
         Returns:
-            Dataframe with columns:
-                display_name: intent to add the phrase to.
-                phrase: new phrase.
-                action: "add".
+          Dataframe with columns:
+            display_name: intent to add the phrase to.
+            phrase: new phrase.
+            action: "add".
         """
         synthetic_dataset = self.create_synthetic_dataset(
             agent_id, intent_subset, new_phrases
diff --git a/src/dfcx_scrapi/tools/validation_util.py b/src/dfcx_scrapi/tools/validation_util.py
index f792961d..86caea2e 100644
--- a/src/dfcx_scrapi/tools/validation_util.py
+++ b/src/dfcx_scrapi/tools/validation_util.py
@@ -53,12 +53,13 @@ def validation_results_to_dataframe(self, validation_results: Dict):
         """ "Transform the Validation results into a dataframe.
         Note will not work if you call get_validation_result with a
         flow_id specified. For calling validate ensure lro is complete
+
         Args:
-            validation_results: dictionary of validation results
-                passed back from get_validation_result or validate functions
+          validation_results: dictionary of validation results
+            assed back from get_validation_result or validate functions
 
         Return:
-            df: dataframe containing the validation results
+          Dataframe containing the validation results
         """
 
         agent_id = "/".join(validation_results["name"].split("/")[0:6])
@@ -75,7 +76,7 @@ def validation_results_to_dataframe(self, validation_results: Dict):
                 temp_df = pd.DataFrame(val_msg)
                 temp_df.insert(0, "flow", flows_map[temp])
 
-                max_cols_new = max([len(x) for x in temp_df.resourceNames])
+                max_cols_new = max(len(x) for x in temp_df.resourceNames)
 
                 if max_cols_new > max_cols_old:
                     for i in range(1, max_cols_new + 1):
@@ -90,30 +91,30 @@ def validation_results_to_dataframe(self, validation_results: Dict):
                         ]
                         i += 1
 
-                dataframe = dataframe.append(temp_df)
+                dataframe = pd.concat([dataframe, temp_df])
                 max_cols_old = 0
 
         return dataframe
 
     def intent_disambiguation(self, agent_id, refresh=False, flow=None):
         """Obtains the intent disambiguation tasks from the validation tool
-            Args:
-                refresh: (optional) False means validation results are pulled
-                    as is. True means the validation tool is refreshed then
-                    results are pulled
-                flow: (optional) If specified results are returned
-                    for the indicated flow display name
 
+        Args:
+          refresh: (optional) False means validation results are pulled
+            as is. True means the validation tool is refreshed then
+            results are pulled
+          flow: (optional) If specified results are returned for the
+            indicated flow display name
 
         Returns:
           Dictionary of intent disambiguation Validation results
           in two dataframes.
-              extended: All intent disambiguation validtion results as
-                seperate instances. If 5 training phrases conflict
-                in 5 intents they will be shown as 5 rows.
-              compact: Only showing the first instance of a conflict
-                for each grouping. If 5 trainig phrases conflic in 5 intents
-                only the first training phrase will show.
+            extended: All intent disambiguation validtion results as
+              seperate instances. If 5 training phrases conflict
+              in 5 intents they will be shown as 5 rows.
+            compact: Only showing the first instance of a conflict
+              for each grouping. If 5 trainig phrases conflic in 5 intents
+              only the first training phrase will show.
         """
 
         if refresh:
diff --git a/src/dfcx_scrapi/tools/webhook_util.py b/src/dfcx_scrapi/tools/webhook_util.py
index 95123431..2425e6ae 100644
--- a/src/dfcx_scrapi/tools/webhook_util.py
+++ b/src/dfcx_scrapi/tools/webhook_util.py
@@ -37,7 +37,7 @@ def build_page_info(param_name, param_state):
           param_state: One of the following states [EMPTY, INVALID, FILLED]
 
         Returns:
-          page_info: A JSON object in the appropriate Response format
+          A JSON object in the appropriate Response format
         """
         page_info = {
             'formInfo': {
@@ -65,8 +65,7 @@ def build_session_info(parameters):
             session params
 
         Returns:
-          session_info: JSON object formatted for the DFCX Fulfillment Response
-            Body
+          JSON object formatted for the DFCX Fulfillment Response Body
 
         Example Input: {'key1':'value1', 'key2':'value2'}
         """
@@ -146,7 +145,7 @@ def get_user_utterance(request, cleaned=False):
               punctuation, and returned as lowercase.
 
         Returns:
-          user_utterance: The final user utterance that was captured.
+          The final user utterance that was captured.
         """
 
         if cleaned:


Test patch:
diff --git a/src/dfcx_scrapi/core/test_cases.py b/src/dfcx_scrapi/core/test_cases.py
index c7f8408a..4ace6ad9 100644
--- a/src/dfcx_scrapi/core/test_cases.py
+++ b/src/dfcx_scrapi/core/test_cases.py
@@ -67,7 +67,7 @@ def list_test_cases(self, agent_id: str = None):
             `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`
 
         Returns:
-          response: list of test cases from an agent.
+          List of test cases from an agent.
         """
 
         if not agent_id:
@@ -101,26 +101,23 @@ def export_test_cases(
         """Export test cases from an agent to cloud storage
 
         Args:
-          gcs_uri:
-            The GCS URI to export the test cases to. The format of this URI
-              must be `gs://<bucket-name>/<object-name>`. If unspecified,
-              the serialized test cases is returned inline.
+          gcs_uri: The GCS URI to export the test cases to. The format of this
+            URI must be `gs://<bucket-name>/<object-name>`. If unspecified,
+            the serialized test cases are returned inline.
           agent_id: The agent to export test cases from.
             `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`
-          data_format:
-            The data format of the exported test cases. If not specified,
-            `BLOB` is assumed.
-          data_filter:
-            The filter expression used to filter exported test cases, see
-            `API Filtering <https://aip.dev/160>`__. The expression is case
-            insensitive and supports the following syntax:
+          data_format: The data format of the exported test cases. If not
+            specified, `BLOB` is assumed.
+          data_filter: The filter expression used to filter exported test
+            cases, see `API Filtering <https://aip.dev/160>`__. The expression
+            is case insensitive and supports the following syntax:
               name = [OR name = ] ...
               For example:
                 -  "name = t1 OR name = t2" matches the test case with the
                 exact resource name "t1" or "t2".
 
         Returns:
-          response: long running operation for export
+          Long running operation for export
         """
 
         if not agent_id:
@@ -144,12 +141,12 @@ def create_test_case(self, test_case: types.TestCase, agent_id: str = None):
         """Create a new Test Case in the specified CX Agent.
 
         Args:
-            test_case: The test case to create.
-            agent_id: The agent to create the test case for. Format:
+          test_case: The test case to create.
+          agent_id: The agent to create the test case for. Format:
               `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`
 
         Returns:
-          response: test case which was created
+          The test case which was created
         """
         if not agent_id:
             agent_id = self.agent_id
@@ -174,7 +171,7 @@ def get_test_case(self, test_case_id: str):
               testCases/<TestCase ID>`
 
         Returns:
-          response: test case
+          The test case object
         """
 
         request = types.test_case.GetTestCaseRequest()
@@ -197,7 +194,7 @@ def import_test_cases(self, gcs_uri: str, agent_id: str = None):
             `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`
 
         Returns:
-          response: long running operation for importing test cases.
+          Long running operation for importing test cases.
         """
 
         if not agent_id:
@@ -229,7 +226,7 @@ def batch_delete_test_cases(
             `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`.
 
         Returns:
-          response: deleted test cases
+          None
         """
 
         if not agent_id:
@@ -252,11 +249,11 @@ def list_test_case_results(self, test_case_id: str):
           test_case_id: The test case to list results for. Format:
             `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
               testCases/<TestCase ID>`
-              NOTE: Specify a ``-`` as a wildcard for TestCase ID to list
-                results across multiple test cases.
+            NOTE: Specify a ``-`` as a wildcard for TestCase ID to list
+            results across multiple test cases.
 
         Returns:
-          response: List of test case results
+          List of test case results
         """
 
         request = types.test_case.ListTestCaseResultsRequest()
@@ -293,7 +290,7 @@ def batch_run_test_cases(
             environments/<Environment ID>`
 
         Returns:
-          response: results for the set of run test cases.
+          Results for the set of run test cases.
         """
 
         if not agent_id:
@@ -324,7 +321,7 @@ def update_test_case(
           obj: The Test Case obj of types.TestCase to use for the update.
 
         Returns:
-          response: updated Test Case.
+          The updated Test Case.
         """
 
         if obj:
@@ -363,7 +360,7 @@ def run_test_case(self, test_case_id: str, environment: str = None):
               agents/<Agent ID>/environments/<Environment ID>`
 
         Returns:
-          response: test case result.
+          The test case result.
         """
 
         request = types.test_case.RunTestCaseRequest()
@@ -387,7 +384,7 @@ def get_test_case_result(self, test_case_result_id: str):
               testCases/<TestCase ID>/results/<TestCaseResult ID>
 
         Returns:
-          response: test case result.
+          The test case result.
         """
         request = types.test_case.GetTestCaseResultRequest()
         request.name = test_case_result_id
@@ -411,7 +408,7 @@ def calculate_coverage(self, coverage_type: int, agent_id: str = None):
             `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`
 
         Returns:
-          response: Coverage of the test cases for the type specified.
+          The Coverage response of the test cases for the type specified.
         """
 
         if not agent_id:
diff --git a/src/dfcx_scrapi/core_async/test_cases.py b/src/dfcx_scrapi/core_async/test_cases.py
index 00c494d0..71669943 100644
--- a/src/dfcx_scrapi/core_async/test_cases.py
+++ b/src/dfcx_scrapi/core_async/test_cases.py
@@ -69,7 +69,7 @@ def list_test_cases(self, agent_id: str = None):
              `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`.
 
         Returns:
-          response: List of test cases from an agent as a coroutine
+          List of test cases from an agent as a coroutine
         """
 
         if not agent_id:
@@ -114,7 +114,7 @@ def export_test_cases(
               exact resource name "t1" or "t2".
 
         Returns:
-          response: Long running operation for export as a coroutine
+          Long running operation for export as a coroutine
         """
 
         if not agent_id:
@@ -145,7 +145,7 @@ def create_test_case(
             `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`.
 
         Returns:
-          response: test case which was created as a coroutine
+          Test case which was created as a coroutine
         """
 
         if not agent_id:
@@ -171,7 +171,7 @@ def get_test_case(self, test_case_id: str):
               testCases/<TestCase ID>`.
 
         Returns:
-          response: Test Case object as coroutine
+          Test Case object as coroutine
         """
 
         request = types.test_case.GetTestCaseRequest()
@@ -194,8 +194,7 @@ def import_test_cases(self, gcs_uri: str, agent_id: str = None):
             `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`.
 
         Returns:
-          response: long running operation for importing test cases as
-            couroutine.
+          Long running operation for importing test cases as a couroutine.
         """
 
         if not agent_id:
@@ -226,7 +225,7 @@ def batch_delete_test_cases(
             `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`.
 
         Returns:
-          response: deleted test cases
+          None
         """
 
         if not agent_id:
@@ -240,8 +239,7 @@ def batch_delete_test_cases(
         client = services.test_cases.TestCasesAsyncClient(
             credentials=self.creds, client_options=client_options
         )
-        response = client.batch_delete_test_cases(request)
-        return response
+        client.batch_delete_test_cases(request)
 
     def list_test_case_results(self, test_case_id: str):
         """List a set of Test Case results for a given Test Case ID.
@@ -253,7 +251,7 @@ def list_test_case_results(self, test_case_id: str):
               TestCase ID to list results across multiple test cases.
 
         Returns:
-          response: List of Test Case results
+          List of Test Case results
         """
 
         request = types.test_case.ListTestCaseResultsRequest()
@@ -282,7 +280,7 @@ def batch_run_test_cases(
             environments/<Environment ID>`.
 
         Returns:
-          response: Results for the set of Test Cases as a coroutine.
+          Results for the set of Test Cases as a coroutine.
         """
 
         if not agent_id:
@@ -311,7 +309,7 @@ def update_test_case(
           test_case_id: (Optional) The Test Case ID to update.
 
         Returns:
-          response: updated test case as coroutine.
+          Updated test case as coroutine.
         """
         test_case = obj
         if test_case_id:
@@ -345,7 +343,7 @@ def run_test_case(self, test_case_id: str, environment: str = None):
               <Location ID>/agents/<Agent ID>/environments/<Environment ID>`.
 
         Returns:
-          response: Test Case result as coroutine
+          Test Case result as coroutine
         """
 
         request = types.test_case.RunTestCaseRequest()
@@ -368,7 +366,7 @@ def get_test_case_result(self, test_case_result_id: str):
               testCases/<TestCase ID>/results/<TestCaseResult ID>
 
         Returns:
-          response: Test Case result as coroutine.
+          Test Case result as coroutine.
         """
 
         request = types.test_case.GetTestCaseResultRequest()
@@ -393,7 +391,7 @@ def calculate_coverage(self, coverage_type: int, agent_id: str = None):
             `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`.
 
         Returns:
-          response: coroutine for returning the coverage of the test cases for
+          Coroutine for returning the coverage of the test cases for
             the type_ specified.
         """