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

Issue:
feat: add new match_type for LLM
Adding new ENUM for match_type = LLM
[BUG] Intent builder adds a space before TP parts that start with an apostrophe when adding a new TP
When using the intent builder to add a new training phrase as the one shown below, the builder adds a space between the annotated part and the following part whose text starts with the possessive "'s":

"phone's not working" where "phone" is annotated with ProvidedProductLine

## Expected Behavior
Adding the tp as follows:
intent_builder.add_training_phrase(["phone", "'s not working"], ["ProvidedProductLine", ""])

should result in a tp "phone's not working" where "phone" is annotated with ProvidedProductLine

## Current Behavior
Adding the tp as follows:
intent_builder.add_training_phrase(["phone", "'s not working"], ["ProvidedProductLine", ""])

results in a tp "phone 's not working" where "phone" is annotated with ProvidedProductLine

## Possible Solution
Check if a part starts with the possessive "'s", and, if it does, do not add a space before it. This may be true in other situations as well, for example "," and "?" (these may already be accounted for, I haven't seen them, just a thought.)


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/.gitignore b/.gitignore
index 0bdbf412..a60d4cf3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
 .ipynb_checkpoints
 *scratch.ipynb
 *scratch.py
+tmp/
 
 # IPython
 profile_default/
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 e1ec406e..4a16e611 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -421,6 +421,6 @@ valid-metaclass-classmethod-first-arg=mcs
 
 # Exceptions that will emit a warning when being caught. Defaults to
 # "Exception"
-overgeneral-exceptions=StandardError,
-                       Exception,
-                       BaseException
+overgeneral-exceptions=builtins.StandardError,
+                       builtins.Exception,
+                       builtins.BaseException
diff --git a/README.md b/README.md
index 721a1f73..c60be1ae 100644
--- a/README.md
+++ b/README.md
@@ -61,12 +61,87 @@ With DFCX SCRAPI you can perform many bot building and maintenance actions at sc
 - Extract Change History information
 - Search across all Flows/Pages/Routes to find a specific parameter or utterance using Search Util functions
 - Quickly move CX resources between agents using Copy Util functions!
-- Build the fundamental protobuf objects that CX uses for each resource type using Maker/Builder Util functions
+- Build the fundamental protobuf objects that CX uses for each resource type using Builder methods
 - ...and much, much more!
 
 ## Built With
 * Python 3.8+
 
+
+<!-- AUTHENTICATION -->
+# Authentication  
+Authentication can vary depending on how and where you are interacting with SCRAPI.
+
+## Google Colab
+If you're using SCRAPI with a [Google Colab](https://colab.research.google.com/) notebook, you can add the following to the top of your notebook for easy authentication:
+```py
+project_id = '<YOUR_GCP_PROJECT_ID>'
+
+# this will launch an interactive prompt that allows you to auth with GCP in a browser
+!gcloud auth application-default login --no-launch-browser
+
+# this will set your active project to the `project_id` above
+!gcloud auth application-default set-quota-project $project_id
+```
+
+After running the above, Colab will pick up your credentials from the environment and pass them to SCRAPI directly. No need to use Service Account keys!
+You can then use SCRAPI simply like this:
+```py
+from dfcx_scrapi.core.intents import Intents
+
+agent_id = '<YOUR_AGENT_ID>'
+i = Intents() # <-- Creds will be automatically picked up from the environment
+intents_map = i.get_intents_map(agent_id)
+```
+---
+## Cloud Functions / Cloud Run
+If you're using SCRAPI with [Cloud Functions](https://cloud.google.com/functions) or [Cloud Run](https://cloud.google.com/run), SCRAPI can pick up on the default environment creds used by these services without any additional configuration! 
+
+1. Add `dfcx-scrapi` to your `requirements.txt` file
+2. Ensure the Cloud Function / Cloud Run service account has the appropriate Dialogflow IAM Role
+
+Once you are setup with the above, your function code can be used easily like this:
+```py
+from dfcx_scrapi.core.intents import Intents
+
+agent_id = '<YOUR_AGENT_ID>'
+i = Intents() # <-- Creds will be automatically picked up from the environment
+intents_map = i.get_intents_map(agent_id)
+```
+
+---
+## Local Python Environment
+Similar to Cloud Functions / Cloud Run, SCRAPI can pick up on your local authentication creds _if you are using the gcloud CLI._
+
+1. Install [gcloud CLI](https://cloud.google.com/sdk/docs/install).
+2. Run `gcloud init`.
+3. Run `gcloud auth login`
+4. Run `gcloud auth list` to ensure your principal account is active.
+
+This will authenticate your principal GCP account with the gcloud CLI, and SCRAPI can pick up the creds from here.  
+
+---
+## Exceptions and Misc.
+There are some classes in SCRAPI which still rely on Service Account Keys, notably the [DataframeFunctions](https://github.com/GoogleCloudPlatform/dfcx-scrapi/blob/main/src/dfcx_scrapi/tools/dataframe_functions.py) class due to how it authenticates with Google Sheets.
+
+In order to use these functions, you will need a Service Account that has appropriate access to your GCP project.  
+For more information and to view the official documentation for service accounts go to [Creating and Managing GCP Service Accounts](https://cloud.google.com/iam/docs/creating-managing-service-accounts).
+
+Once you've obtained a Service Account Key with appropriate permissions, you can use it as follows:
+```py
+from dfcx_scrapi.core.intents import Intents
+from dfcx_scrapi.tools.dataframe_functions import DataframeFunctions
+
+agent_id = '<YOUR_AGENT_ID>'
+creds_path = '<PATH_TO_YOUR_SERVICE_ACCOUNT_JSON_FILE>'
+
+i = Intents(creds_path=creds_path)
+dffx = DataframeFunctions(creds_path=creds_path)
+
+df = i.bulk_intent_to_df(agent_id)
+dffx.dataframe_to_sheets('GOOGLE_SHEET_NAME', 'TAB_NAME', df)
+```
+
 <!-- GETTING STARTED -->
 # Getting Started
 ## Environment Setup
@@ -82,12 +157,7 @@ source ./venv/bin/activate
 pip install -r requirements.txt
 ```
 
-## Authentication  
-In order to use the functions and API calls to Dialogflow CX, you will need a Service Account that has appropriate access to your GCP project.  
-For more information and to view the official documentation for service accounts go to [Creating and Managing GCP Service Accounts](https://cloud.google.com/iam/docs/creating-managing-service-accounts).
-
-<!-- USAGE EXAMPLES -->
-# Usage
+## Usage
 To run a simple bit of code you can do the following:
 - Import a Class from `dfcx_scrapi.core`
 - Assign your Service Account to a local variable
@@ -108,19 +178,13 @@ i = Intents(creds_path, agent_id=agent_path)
 df = i.bulk_intent_to_df()
 ```
 
-_For more examples, please refer to [Examples](/examples) or [Tools](/src/dfcx_scrapi/tools)._
-
 # Library Composition
 Here is a brief overview of the SCRAPI library's structure and the motivation behind that structure.
 
 ## Core  
-The [Core](/src/dfcx_scrapi/core) folder is synonymous with the core Resource types in the DFCX Agents like:
-- agents
-- intents
-- entity_types
-- etc.
-
-The [Core](/src/dfcx_scrapi/core) folder is meant to contain the fundamental building blocks for even higher level customized tools and applications that can be built with this library.
+The [Core](/src/dfcx_scrapi/core) folder is synonymous with the core Resource types in the DFCX Agents (agents, intents, flows, etc.)
+* This folder contains the high level building blocks of SCRAPI
+* These classes and methods can be used to build higher level methods or custom tools and applications
 
 ## Tools
 The [Tools](/src/dfcx_scrapi/tools) folder contains various customized toolkits that allow you to do more complex bot management tasks, such as
@@ -129,6 +193,12 @@ The [Tools](/src/dfcx_scrapi/tools) folder contains various customized toolkits
 - Move data to and from DFCX and other GCP Services like BigQuery, Sheets, etc.
 - Create customized search queries inside of your agent resources
 
+## Builders
+The [Builders](/src/dfcx_scrapi/builders) folder contains simple methods for constructing the underlying protos in Dialogflow CX
+- Proto objects are the fundamental building blocks of Dialogflow CX
+- Builder classes allow the user to construct Dialogflow CX resource _offline_ without any API calls
+- Once the resource components are constructed, they can then be pushed to a live Dialogflow CX agent via API
+
 <!-- CONTRIBUTING -->
 # Contributing
 We welcome any contributions or feature requests you would like to submit!
@@ -146,8 +216,7 @@ Distributed under the Apache 2.0 License. See [LICENSE](LICENSE.txt) for more in
 <!-- CONTACT -->
 # Contact
 Patrick Marlow - pmarlow@google.com  - [@kmaphoenix](https://github.com/kmaphoenix)  
-David "DC" Collier - dcollier@google.com  - [@DCsan](https://github.com/dcsan)  
-Lauren Greenford - greenford@google.com - [@Greenford](https://github.com/Greenford)
+Milad Tabrizi - miladt@google.com - [@MRyderOC](https://github.com/MRyderOC)
 
 Project Link: [https://github.com/GoogleCloudPlatform/dfcx-scrapi](https://github.com/GoogleCloudPlatform/dfcx-scrapi)
 
diff --git a/config.yaml b/config.yaml
index 265451af..d057d44f 100644
--- a/config.yaml
+++ b/config.yaml
@@ -1,4 +1,4 @@
-# Copyright 2021 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
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/nlu_evals_sample.csv b/data/nlu_evals_sample.csv
new file mode 100644
index 00000000..f6d1e45e
--- /dev/null
+++ b/data/nlu_evals_sample.csv
@@ -0,0 +1,19 @@
+flow_display_name,utterance,page_display_name,expected_intent,expected_parameters,description
+Default Start Flow,I need to get my order status,START_PAGE,head_intent.order_status,,Demo Tests
+Default Start Flow,Trying to check the status of my order,START_PAGE,head_intent.order_status,,Demo Tests
+Default Start Flow,I hate this order status agent!,START_PAGE,head_intent.order_status,,Demo Tests
+Default Start Flow,Wha'ts the point of ordering anything?,START_PAGE,NO_MATCH,,Demo Tests
+Default Start Flow,I was looking at the order of operations yesterday but couldn't figure it out,START_PAGE,NO_MATCH,,Demo Tests
+Default Start Flow,Thanks for getting my that status so quickly!,START_PAGE,head_intent.order_status,,Demo Tests
+Default Start Flow,I need to make a payment,START_PAGE,head_intent.pay_arrangement,,Demo Tests
+Default Start Flow,I'm trying to setup a new payment,START_PAGE,head_intent.pay_arrangement,,Demo Tests
+Default Start Flow,Did you get the pavement,START_PAGE,NO_MATCH,,Demo Tests
+Default Start Flow,I've been trying to pay my bill all day!,START_PAGE,head_intent.pay_arrangement,,Demo Tests
+Default Start Flow,Why can't I get my bill paid at all online?,START_PAGE,head_intent.pay_arrangement,,Demo Tests
+Default Start Flow,Why can't I get my bill paid at all online? Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?,START_PAGE,head_intent.pay_arrangement,,Demo Tests
+Date Collection,12/13/2022,Collect Date,-,"{'collected_date':
+{ ""day"": 13, ""month"": 12, ""year"": 2022 }, 'formatted_date': '13/12/2022'}}",Demo Tests
+Proper Names Demo,Patrick Marlow,names,-,"{'people_names':
+{ ""original"": ""Patrick Marlow"", ""name"": ""Patrick Marlow"" }}",Demo Tests
+Proper Names Demo,Andrew Smith,names,,"{'people_names':
+{ ""original"": ""Andrew Smith"", ""name"": ""Andrew Smith"" }}",Demo Tests
diff --git a/examples/bot_building_series/bot_building_101.ipynb b/examples/bot_building_series/bot_building_101.ipynb
index 64911330..aee83c78 100644
--- a/examples/bot_building_series/bot_building_101.ipynb
+++ b/examples/bot_building_series/bot_building_101.ipynb
@@ -3,8 +3,10 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
-    "# Copyright 2021 Google LLC\n",
+    "# Copyright 2023 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",
@@ -17,42 +19,42 @@
     "# 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 show you how to build a Dialogflow CX Agent from scratch using plain text inputs and a simple CSV file.\n",
     "\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"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "import pandas as pd\n",
     "\n",
@@ -64,31 +66,31 @@
     "from dfcx_scrapi.tools.maker_util import MakerUtil\n",
     "\n",
     "creds_path = '<YOUR_CREDS_PATH_HERE>'"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Method 1 - Creating an Agent from Simple Text Inputs"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "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."
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "# First we will instansiate our Agent object\n",
     "a = Agents(creds_path=creds_path)\n",
@@ -100,12 +102,11 @@
     "\n",
     "# Then we will call the `create_agent` method and capture the result in a var call `my_agent`\n",
     "my_agent = a.create_agent(project_id, display_name, gcp_region)\n"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "If you inspect your `my_agent` variable, you will see it is of types.agent.Agent\n",
     "and contains a structure similar to this below\n",
@@ -117,11 +118,11 @@
     "time_zone: \"America/Chicago\"\n",
     "start_flow: \"projects/<your-gcp-project>/locations/us-central1/agents/08e7647e-8b6e-4f1e-abdb-1d0448127a05/flows/00000000-0000-0000-0000-000000000000\"\n",
     "```"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "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",
@@ -131,12 +132,13 @@
     "\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've included a file located at `data/sample_intent_tp.csv` that we'll pull into our dataframe to use."
-   ],
-   "metadata": {}
+   ]
   },
   {
    "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",
@@ -146,24 +148,24 @@
     "\n",
     "# Finally, we will use `dffx` to push our Intents to our Agent\n",
     "my_intents = dffx.bulk_create_intent_from_dataframe(my_agent.name, df, update_flag=True)"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "## Create Your First Page\n",
     "Next, we'll use the `create_page` method to create a Page in our Default Start Flow and attach our newly created Intent to it.\n",
     "\n",
     "For Page object manipulation, you'll want to know your Flow ID metadata.   \n",
     "We'll use the `Flows` class and `get_flows_map` method to provide this info."
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "# First, we will instantiate our Flows and Pages objects.\n",
     "f = Flows(creds_path=creds_path)\n",
@@ -175,12 +177,11 @@
     "\n",
     "# Now that we know our Flow ID, we'll create a Page using the Flow ID as the Page parent\n",
     "my_page = p.create_page(flows_map['Default Start Flow'], display_name='My First Page!')\n"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "If you inspect the `my_page` object, you will see it is of types.page.Page and contains a structure similar to this below\n",
     "\n",
@@ -188,11 +189,11 @@
     "name: \"projects/<your-gcp-project>/locations/us-central1/agents/fc5e72f6-c194-43ec-871f-6a636010f4ae/flows/00000000-0000-0000-0000-000000000000/pages/63037247-d3f2-42e5-8c43-651bcbcf2c0e\"\n",
     "display_name: \"My First Page!\"\n",
     "```"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "## Add Intent Route to Default Start Flow and Link Page\n",
     "In our last step, we will link all of our previous steps together by:\n",
@@ -210,12 +211,13 @@
     "Dialogflow Resource IDs can always be found in the `x.name` namespace.  \n",
     "For our intent, it would be `my_intent.name`  \n",
     "For our page, it would be `my_page.name`   "
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "# We'll first get the Default Start Flow (dsf for short)\n",
     "dsf = f.get_flow(flows_map['Default Start Flow'])\n",
@@ -234,12 +236,11 @@
     "\n",
     "# Finally, we'll update the DSF object in our agent\n",
     "my_updated_flow = f.update_flow(flows_map['Default Start Flow'], obj=dsf, transition_routes=dsf.transition_routes)"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Bot Building 101 End\n",
     "## And there you have it!   \n",
@@ -247,37 +248,36 @@
     "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.\n",
     "\n",
     "If you're ready to dive into a more advanced style of bot building, check out the Bot Building 201 notebook which shows you how to build a Dialogflow CX agent using a YAML file."
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
-   "source": [],
-   "metadata": {}
+   "metadata": {},
+   "source": []
   }
  ],
  "metadata": {
-  "orig_nbformat": 4,
+  "interpreter": {
+   "hash": "a46639dc4ce59764707f7d29e7ddf4b543fd040f828fe9a3cb5baeb9324df219"
+  },
+  "kernelspec": {
+   "display_name": "Python 3.8.10 64-bit ('scrapi': conda)",
+   "name": "python3"
+  },
   "language_info": {
-   "name": "python",
-   "version": "3.8.10",
-   "mimetype": "text/x-python",
    "codemirror_mode": {
     "name": "ipython",
     "version": 3
    },
-   "pygments_lexer": "ipython3",
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
    "nbconvert_exporter": "python",
-   "file_extension": ".py"
-  },
-  "kernelspec": {
-   "name": "python3",
-   "display_name": "Python 3.8.10 64-bit ('scrapi': conda)"
+   "pygments_lexer": "ipython3",
+   "version": "3.8.10"
   },
-  "interpreter": {
-   "hash": "a46639dc4ce59764707f7d29e7ddf4b543fd040f828fe9a3cb5baeb9324df219"
-  }
+  "orig_nbformat": 4
  },
  "nbformat": 4,
  "nbformat_minor": 2
-}
\ No newline at end of file
+}
diff --git a/examples/bot_building_series/bot_building_101.py b/examples/bot_building_series/bot_building_101.py
index 052a0bcc..1be38b5a 100644
--- a/examples/bot_building_series/bot_building_101.py
+++ b/examples/bot_building_series/bot_building_101.py
@@ -1,5 +1,5 @@
 """A sample script for building a DFCX bot from scratch using simple inputs."""
-# Copyright 2021 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
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
deleted file mode 100644
index 7fba38cc..00000000
--- a/examples/bot_building_series/bot_building_102-intents-with-annotated_tp.py
+++ /dev/null
@@ -1,119 +0,0 @@
-#!/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 pandas as pd
-from dfcx_scrapi.core.agents import Agents
-from dfcx_scrapi.core.intents import Intents
-from dfcx_scrapi.core.flows import Flows
-from dfcx_scrapi.core.pages import Pages
-from dfcx_scrapi.tools.dataframe_functions import DataframeFunctions
-from dfcx_scrapi.tools.maker_util import MakerUtil
-
-
-# # 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)
-
-	# First we will instantiate our Agent object
-	a = 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 = a.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')
-	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
-	my_intents = 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)
-	# First we will instantiate our Agent object and retrieve the agent using its id
-	a = Agents(creds_path=creds_path)
-	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')
-	params_df = pd.read_csv('../../data/intent_sample_params.csv')
-
-	#bulk update the existing intents
-	my_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])
-    build_agent(CREDS_PATH, PROJECT_ID)
\ No newline at end of file
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
similarity index 99%
rename from examples/bot_building_series/bot_building_102-intents-with-annotated_tp.ipynb
rename to examples/bot_building_series/bot_building_102_intents_with_annotated_tp.ipynb
index 72ef1a89..701f9013 100644
--- 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
@@ -6,7 +6,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "# Copyright 2021 Google LLC\n",
+    "# Copyright 2023 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",
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..ef3cf814
--- /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 2023 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
index 9bfbc5e8..119e07b2 100644
--- a/examples/bot_building_series/builders_101.ipynb
+++ b/examples/bot_building_series/builders_101.ipynb
@@ -7,7 +7,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "# Copyright 2022 Google LLC\n",
+    "# Copyright 2023 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",
@@ -119,7 +119,7 @@
     "ab = AgentBuilder()\n",
     "\n",
     "# Create New Agent\n",
-    "ab.create_new_agent(\n",
+    "ab.create_new_proto_obj(\n",
     "    display_name = 'Patrick Awesome Agent',\n",
     "    time_zone='America/Chicago',\n",
     "    default_language_code='en',\n",
@@ -310,7 +310,7 @@
     "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",
+    "ib.create_new_proto_obj(display_name=display_name, description=description, overwrite=True)\n",
     "\n",
     "# Show Intent\n",
     "ib.show_intent()\n",
@@ -418,7 +418,7 @@
     "eb = EntityTypeBuilder()\n",
     "\n",
     "# Create New Entity Type\n",
-    "eb.create_new_entity_type(\n",
+    "eb.create_new_proto_obj(\n",
     "    display_name='my_builder_entity_type',\n",
     "    kind=1,\n",
     "    enable_fuzzy_extraction=True,\n",
diff --git a/examples/copy_paste_series/copy_paste_pages.ipynb b/examples/copy_paste_series/copy_paste_pages.ipynb
index 73ec5432..d80bdbbb 100644
--- a/examples/copy_paste_series/copy_paste_pages.ipynb
+++ b/examples/copy_paste_series/copy_paste_pages.ipynb
@@ -3,8 +3,10 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
-    "# Copyright 2021 Google LLC\n",
+    "# Copyright 2023 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",
@@ -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
index b8f11c88..78324468 100644
--- a/examples/google_sheets_series/custom_entities_to_sheets.ipynb
+++ b/examples/google_sheets_series/custom_entities_to_sheets.ipynb
@@ -6,7 +6,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "# Copyright 2022 Google LLC\n",
+    "# Copyright 2023 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",
diff --git a/examples/google_sheets_series/cx_to_sheets.ipynb b/examples/google_sheets_series/cx_to_sheets.ipynb
index f27ee0a5..c53aebfe 100644
--- a/examples/google_sheets_series/cx_to_sheets.ipynb
+++ b/examples/google_sheets_series/cx_to_sheets.ipynb
@@ -3,8 +3,10 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
-    "# Copyright 2021 Google LLC\n",
+    "# Copyright 2023 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",
@@ -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 Google Sheets can be used as a simple medium for managing, updating, and evaluating Intents and Training Phrases in Dialogflow CX.\n",
@@ -31,39 +32,39 @@
     "\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"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "from dfcx_scrapi.core.intents import Intents\n",
     "from dfcx_scrapi.tools.dataframe_functions import DataframeFunctions"
-   ],
-   "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",
@@ -75,35 +76,36 @@
     "- `agent_id`: Your Dialogflow CX Agent ID in String format\n",
     "- `google_sheet_name`: The name of your Google Sheet\n",
     "- `google_sheet_tab_write`:  The name of the tab in your Google Sheet where the data will be written"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "creds_path = '<YOUR_CREDS_PATH_HERE>'\n",
     "agent_id = '<YOUR_AGENT_ID_HERE>'\n",
     "google_sheet_name = 'My Google Sheet Name'\n",
     "google_sheet_tab_write = 'Write To My Tab Name'"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# CX to Sheets\n",
     "Here, we'll demonstrate how to extract all of the Intents and Training Phrases from your Dialogflow CX Agent and push them to a Google Sheet.\n",
     "\n",
     "## Prerequisites\n",
     "- In order for the `DataframeFunctions` class to interact with Google Sheets, you *must* share your Google Sheet with your Service Account email address."
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "# Instanstiate Classes\n",
     "intents = Intents(creds_path)\n",
@@ -117,12 +119,11 @@
     "\n",
     "print('Total # of Intents = {}'.format(df.intent.nunique()))\n",
     "print('Total # of Training Phrases = {}'.format(df.tp.nunique()))"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Final Thoughts and Wrap-Up\n",
     "You should see your Google Sheet is now updated with all of the Intents and Training Phrases from your Dialogflow CX Agent.\n",
@@ -131,37 +132,36 @@
     "- Exclude 1 or more Intents\n",
     "- Push Intents that contain > X # of Training Phrases to Tab A\n",
     "- Push Intents that contain < Y # of Training Phrases to Tab B"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
-   "source": [],
-   "metadata": {}
+   "metadata": {},
+   "source": []
   }
  ],
  "metadata": {
-  "orig_nbformat": 4,
+  "interpreter": {
+   "hash": "a46639dc4ce59764707f7d29e7ddf4b543fd040f828fe9a3cb5baeb9324df219"
+  },
+  "kernelspec": {
+   "display_name": "Python 3.8.10 64-bit ('scrapi': conda)",
+   "name": "python3"
+  },
   "language_info": {
-   "name": "python",
-   "version": "3.8.10",
-   "mimetype": "text/x-python",
    "codemirror_mode": {
     "name": "ipython",
     "version": 3
    },
-   "pygments_lexer": "ipython3",
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
    "nbconvert_exporter": "python",
-   "file_extension": ".py"
-  },
-  "kernelspec": {
-   "name": "python3",
-   "display_name": "Python 3.8.10 64-bit ('scrapi': conda)"
+   "pygments_lexer": "ipython3",
+   "version": "3.8.10"
   },
-  "interpreter": {
-   "hash": "a46639dc4ce59764707f7d29e7ddf4b543fd040f828fe9a3cb5baeb9324df219"
-  }
+  "orig_nbformat": 4
  },
  "nbformat": 4,
  "nbformat_minor": 2
-}
\ No newline at end of file
+}
diff --git a/examples/google_sheets_series/cx_to_sheets_by_flow.ipynb b/examples/google_sheets_series/cx_to_sheets_by_flow.ipynb
index 0e32bbb0..1b4d4992 100644
--- a/examples/google_sheets_series/cx_to_sheets_by_flow.ipynb
+++ b/examples/google_sheets_series/cx_to_sheets_by_flow.ipynb
@@ -3,8 +3,10 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
-    "# Copyright 2021 Google LLC\n",
+    "# Copyright 2023 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",
@@ -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 Google Sheets can be used as a simple medium for managing, updating, and evaluating Intents and Training Phrases in Dialogflow CX.\n",
@@ -31,40 +32,40 @@
     "\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"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "import pandas as pd\n",
     "from dfcx_scrapi.tools.copy_util import CopyUtil\n",
     "from dfcx_scrapi.tools.dataframe_functions import DataframeFunctions"
-   ],
-   "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",
@@ -76,24 +77,24 @@
     "- `agent_id`: Your Dialogflow CX Agent ID in String format\n",
     "- `google_sheet_name`: The name of your Google Sheet\n",
     "- `google_sheet_tab_read`:  The name of the tab in your Google Sheet to read the data from"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "creds_path = '<YOUR_CREDS_PATH_HERE>'\n",
     "agent_id = '<YOUR_AGENT_ID_HERE>'\n",
     "flow = '<YOUR_FLOW_DISPLAY_NAME>'\n",
     "google_sheet_name = 'My Google Sheet Name'\n",
     "google_sheet_tab_write = 'Write To My Tab Name'"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# CX to Sheets - Filtered by Intents in Scope of a Flow\n",
     "Here, we will demonstrate how to extract all of the Intents and Training Phrases associated with a specific `Flow` inside of a Dialogflow CX Agent.\n",
@@ -104,12 +105,13 @@
     "\n",
     "## Prerequisites\n",
     "- In order for the `DataframeFunctions` class to interact with Google Sheets, you *must* share your Google Sheet with your Service Account email address."
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "cu = CopyUtil(creds_path=creds_path, agent_id=agent_id)\n",
     "dffx = DataframeFunctions(creds_path)\n",
@@ -139,12 +141,11 @@
     "\n",
     "print('Total # of Intents = {}'.format(df.intent.nunique()))\n",
     "print('Total # of Training Phrases = {}'.format(df.tp.nunique()))"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Final Thoughts and Wrap-Up\n",
     "You should see your Google Sheet is now updated with the Intents and Training Phrases from your Dialogflow CX Agent that are in scope of the `Flow` that you specified.\n",
@@ -153,37 +154,36 @@
     "- Exclude 1 or more Intents\n",
     "- Push Intents that contain > X # of Training Phrases to Tab A\n",
     "- Push Intents that contain < Y # of Training Phrases to Tab B"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
-   "source": [],
-   "metadata": {}
+   "metadata": {},
+   "source": []
   }
  ],
  "metadata": {
-  "orig_nbformat": 4,
+  "interpreter": {
+   "hash": "a46639dc4ce59764707f7d29e7ddf4b543fd040f828fe9a3cb5baeb9324df219"
+  },
+  "kernelspec": {
+   "display_name": "Python 3.8.10 64-bit ('scrapi': conda)",
+   "name": "python3"
+  },
   "language_info": {
-   "name": "python",
-   "version": "3.8.10",
-   "mimetype": "text/x-python",
    "codemirror_mode": {
     "name": "ipython",
     "version": 3
    },
-   "pygments_lexer": "ipython3",
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
    "nbconvert_exporter": "python",
-   "file_extension": ".py"
-  },
-  "kernelspec": {
-   "name": "python3",
-   "display_name": "Python 3.8.10 64-bit ('scrapi': conda)"
+   "pygments_lexer": "ipython3",
+   "version": "3.8.10"
   },
-  "interpreter": {
-   "hash": "a46639dc4ce59764707f7d29e7ddf4b543fd040f828fe9a3cb5baeb9324df219"
-  }
+  "orig_nbformat": 4
  },
  "nbformat": 4,
  "nbformat_minor": 2
-}
\ No newline at end of file
+}
diff --git a/examples/google_sheets_series/sheets_to_cx.ipynb b/examples/google_sheets_series/sheets_to_cx.ipynb
index 66aae02e..0a9285b0 100644
--- a/examples/google_sheets_series/sheets_to_cx.ipynb
+++ b/examples/google_sheets_series/sheets_to_cx.ipynb
@@ -1,39 +1,12 @@
 {
- "metadata": {
-  "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.10"
-  },
-  "orig_nbformat": 2,
-  "kernelspec": {
-   "name": "python3",
-   "display_name": "Python 3.8.10 64-bit ('scrapi': conda)"
-  },
-  "metadata": {
-   "interpreter": {
-    "hash": "547aa68a3b05ceb5ef445141f7d93a53d8c7bfd6e879bc63089b49209d101e50"
-   }
-  },
-  "interpreter": {
-   "hash": "a46639dc4ce59764707f7d29e7ddf4b543fd040f828fe9a3cb5baeb9324df219"
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 2,
  "cells": [
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
-    "# Copyright 2021 Google LLC\n",
+    "# Copyright 2023 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",
@@ -46,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 Google Sheets can be used as a simple medium for managing, updating, and evaluating Intents and Training Phrases in Dialogflow CX.\n",
@@ -60,39 +32,39 @@
     "\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"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "from dfcx_scrapi.core.intents import Intents\n",
     "from dfcx_scrapi.tools.dataframe_functions import DataframeFunctions"
-   ],
-   "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",
@@ -104,23 +76,23 @@
     "- `agent_id`: Your Dialogflow CX Agent ID in String format\n",
     "- `google_sheet_name`: The name of your Google Sheet\n",
     "- `google_sheet_tab_read`:  The name of the tab in your Google Sheet to read the data from"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "creds_path = '<YOUR_CREDS_PATH_HERE>'\n",
     "agent_id = '<YOUR_AGENT_ID_HERE>'\n",
     "google_sheet_name = 'My Google Sheet Name'\n",
     "google_sheet_tab_read = 'Read From My Tab Name'"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Sheets to CX\n",
     "Here, we'll demonstrate how to `update` an **_Existing Intent_** in your Dialogflow CX Agent based on data from a Google Sheet.\n",
@@ -130,23 +102,23 @@
     "- For this specific function, you must have exactly 2 columns with the following names:\n",
     "    - `display_name`\n",
     "    - `text`"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "dffx = DataframeFunctions(creds_path)\n",
     "\n",
     "df = dffx.sheets_to_dataframe(google_sheet_name, google_sheet_tab_read)\n",
     "dffx.bulk_update_intents_from_dataframe(agent_id, df, update_flag=True, rate_limiter=1)"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Final Thoughts and Wrap-Up\n",
     "In this notebook, we demonstrated how to take data from a Google Sheet and update Intent/Training Phrases for an `existing` Intent in Dialogflow CX.\n",
@@ -156,13 +128,41 @@
     "- `bulk_update_intents_from_dataframe(mode='advanced')`\n",
     "- `bulk_create_intents_from_dataframe(mode='basic')`\n",
     "- `bulk_create_intents_from_dataframe(mode='advanced')`"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
-   "source": [],
-   "metadata": {}
+   "metadata": {},
+   "source": []
   }
- ]
-}
\ No newline at end of file
+ ],
+ "metadata": {
+  "interpreter": {
+   "hash": "a46639dc4ce59764707f7d29e7ddf4b543fd040f828fe9a3cb5baeb9324df219"
+  },
+  "kernelspec": {
+   "display_name": "Python 3.8.10 64-bit ('scrapi': conda)",
+   "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.10"
+  },
+  "metadata": {
+   "interpreter": {
+    "hash": "547aa68a3b05ceb5ef445141f7d93a53d8c7bfd6e879bc63089b49209d101e50"
+   }
+  },
+  "orig_nbformat": 2
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/examples/misc/agent_backups.ipynb b/examples/misc/agent_backups.ipynb
index b8e791ed..8abaad59 100644
--- a/examples/misc/agent_backups.ipynb
+++ b/examples/misc/agent_backups.ipynb
@@ -6,7 +6,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "# Copyright 2022 Google LLC\n",
+    "# Copyright 2023 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",
diff --git a/examples/nlu_analysis_series/intent_levenshtein_demo.ipynb b/examples/nlu_analysis_series/intent_levenshtein_demo.ipynb
index 72236033..4a8a187c 100644
--- a/examples/nlu_analysis_series/intent_levenshtein_demo.ipynb
+++ b/examples/nlu_analysis_series/intent_levenshtein_demo.ipynb
@@ -6,7 +6,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "# Copyright 2022 Google LLC\n",
+    "# Copyright 2023 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",
diff --git a/examples/nlu_analysis_series/nlu_semantic_similarity_demo.ipynb b/examples/nlu_analysis_series/nlu_semantic_similarity_demo.ipynb
index 00581b63..cc4ed4d5 100644
--- a/examples/nlu_analysis_series/nlu_semantic_similarity_demo.ipynb
+++ b/examples/nlu_analysis_series/nlu_semantic_similarity_demo.ipynb
@@ -6,7 +6,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "# Copyright 2022 Google LLC\n",
+    "# Copyright 2023 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",
diff --git a/examples/scrapi_prebuilt.ipynb b/examples/scrapi_prebuilt.ipynb
index 0e811663..08131802 100644
--- a/examples/scrapi_prebuilt.ipynb
+++ b/examples/scrapi_prebuilt.ipynb
@@ -6,7 +6,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "# Copyright 2022 Google LLC\n",
+    "# Copyright 2023 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",
diff --git a/examples/template.ipynb b/examples/template.ipynb
index 5dc2caa6..f9cacade 100644
--- a/examples/template.ipynb
+++ b/examples/template.ipynb
@@ -2,6 +2,7 @@
  "cells": [
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# NOTEBOOK TEMPLATE (Remove This Cell Before Publishing)\n",
     "This `.ipynb` file can be used as a template to create new example notebooks to share with users.\n",
@@ -24,14 +25,15 @@
     "\n",
     "In each section below we've included sample instructions and minimal code as a pointer for the template.   \n",
     "If you need further motivation, see [this sample notebook](https://github.com/GoogleCloudPlatform/dfcx-scrapi/blob/main/examples/bot_building_series/bot_building_101.ipynb)"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
-    "# Copyright 2021 Google LLC\n",
+    "# Copyright 2023 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",
@@ -44,12 +46,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 show you how to \\<_INSERT TOPIC OF NOTEBOOK HERE_\\>.\n",
@@ -59,39 +60,39 @@
     "## Prerequisites\n",
     "- Prereq #1 Goes Here\n",
     "- Prereq #2 Goes Here - [Document Link](www.example.com)"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "!pip install dfcx-scrapi"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Imports\n",
     "\\<_INSERT INFORMATION ABOUT NON-STANDARD IMPORTS HERE_\\>\n",
     "\n",
     "_Example: We're importing the `tqdm` library to build a progress bar for our long running function in this notebook._"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "from dfcx_scrapi.core.intents import Intents"
-   ],
-   "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",
@@ -103,30 +104,31 @@
     "- `creds_path`: Your local path to your GCP Service Account Credentials\n",
     "- `agent_id`: Your Dialogflow CX Agent ID in String format\n",
     "- `intent_subset`: A list of strings containing the Intent Names to include"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "creds_path = '<YOUR_CREDS_PATH_HERE>'\n",
     "agent_id = '<YOUR_AGENT_ID_HERE>'\n",
     "intent_subset = ['confirmation.yes','confirmation.no']"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Extract Intents and Training Phrases"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "intents = Intents(creds_path=creds_path, agent_id=agent_id)\n",
     "\n",
@@ -135,26 +137,21 @@
     "\n",
     "else:\n",
     "    all_intents = intents.bulk_intent_to_df()"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "## View Results Sample"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 26,
-   "source": [
-    "all_intents.head(10)"
-   ],
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
       "text/html": [
        "<div>\n",
@@ -248,14 +245,18 @@
        "9  confirmation.yes        yep"
       ]
      },
+     "execution_count": 26,
      "metadata": {},
-     "execution_count": 26
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "all_intents.head(10)"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Ending and Wrap-Up\n",
     "\\<_INSERT ANY LESSONS LEARNED OR WRAP UP INFORMATION ABOUT THIS NOTEBOOK_\\>\n",
@@ -263,37 +264,36 @@
     "_Example:   \n",
     "In this notebook, we've shown how to extract specific Intents and Training Phrease from an Agent by providing an intent subset list._   \n",
     "_For more advanced features, see our follow-up notebook located [here](www.example.com)_"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
-   "source": [],
-   "metadata": {}
+   "metadata": {},
+   "source": []
   }
  ],
  "metadata": {
-  "orig_nbformat": 4,
+  "interpreter": {
+   "hash": "19fe958eff886c70bc7b0837ba1e6b09536c8944c54196036e51b6ba767223fc"
+  },
+  "kernelspec": {
+   "display_name": "Python 3.8.11 64-bit ('scrapi-local': conda)",
+   "name": "python3"
+  },
   "language_info": {
-   "name": "python",
-   "version": "3.8.11",
-   "mimetype": "text/x-python",
    "codemirror_mode": {
     "name": "ipython",
     "version": 3
    },
-   "pygments_lexer": "ipython3",
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
    "nbconvert_exporter": "python",
-   "file_extension": ".py"
-  },
-  "kernelspec": {
-   "name": "python3",
-   "display_name": "Python 3.8.11 64-bit ('scrapi-local': conda)"
+   "pygments_lexer": "ipython3",
+   "version": "3.8.11"
   },
-  "interpreter": {
-   "hash": "19fe958eff886c70bc7b0837ba1e6b09536c8944c54196036e51b6ba767223fc"
-  }
+  "orig_nbformat": 4
  },
  "nbformat": 4,
  "nbformat_minor": 2
-}
\ No newline at end of file
+}
diff --git a/examples/vertex_ai_conversation/evaluation_tool__numeric_score__colab.ipynb b/examples/vertex_ai_conversation/evaluation_tool__numeric_score__colab.ipynb
new file mode 100644
index 00000000..21eb3d10
--- /dev/null
+++ b/examples/vertex_ai_conversation/evaluation_tool__numeric_score__colab.ipynb
@@ -0,0 +1,333 @@
+{
+  "cells": [
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "WpkyirmC-F33"
+      },
+      "source": [
+        "# Vertex AI Conversation - Evaluation Tool\n",
+        "\n",
+        "This tool requieres user's input in several steps. Please run the cells one by one (Shift+Enter) to ensure all the steps are succesfully completed.\n",
+        "\n",
+        "## Instructions:\n",
+        "\n",
+        "1.  **Set-up**\n",
+        "    1. First cell: install and import dependencies\n",
+        "    2. Second cell: authentication - it requieres following the steps in the pop-up window. Alternatively, it can be replaced by other [supported authentication method](https://github.com/GoogleCloudPlatform/dfcx-scrapi#authentication)\n",
+        "    3. Third cell: introduce values for project, location and agent in the right panel; then run the cell.\n",
+        "    4. Fourth cell: run examples to validate set-up is correct\n",
+        "2.  **Generate Questions & Answer**\n",
+        "    1. First cell: save a sample csv file with correct format\n",
+        "    2. Second cell: upload csv file with the fields `user_query` and an `ideal_answer` for all examples\n",
+        "    3. Third cell: bulk generation of `agent_answer` that includes the text and link\n",
+        "3.  **Rating**\n",
+        "    1. First cell: download csv and add the ratings offline\n",
+        "    2. Second cell: upload csv file with the ratings\n",
+        "4. **Results**\n",
+        "    1. First cell: visualize distribution of ratings\n",
+        "\n",
+        "This notebook calls `DetectIntent` using [dfcx-scrapi library](https://github.com/GoogleCloudPlatform/dfcx-scrapi) for Dialogflow CX.\n",
+        "\n",
+        "\n",
+        "## Rating guidance:\n",
+        "\n",
+        "For each sample (aka row), the rater should evaluate each answer (including ythe link) that was generated by the agent. The answer will be evaluated with a integer number (escalar) from -1 to 3 as following:\n",
+        "*   **+3** : Perfect answer > fully addresses the question with correct information and polite tone\n",
+        "*   **+2** : Good answer > may contain unnecessary info, may miss some info, or may not be perfectly articulated\n",
+        "*   **+1** : Slightly good answer > some truth to the answer\n",
+        "*   **0** : Neutral answer > no answer or answer contains irrelevant info\n",
+        "*   **-1** : Hurtful answer > wrong or misleading info, or inappropriate tone\n",
+        "\n"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "Afvsuux0zaWZ"
+      },
+      "source": [
+        "## Set-up\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "PPJYRHN83bHg"
+      },
+      "outputs": [],
+      "source": [
+        "# Dependencies\n",
+        "!pip install dfcx-scrapi --quiet\n",
+        "\n",
+        "import io\n",
+        "import matplotlib.pyplot as plt\n",
+        "import numpy as np\n",
+        "import pandas as pd\n",
+        "\n",
+        "from dfcx_scrapi.core.sessions import Sessions\n",
+        "from google.auth import default\n",
+        "from google.colab import auth\n",
+        "from google.colab import files"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "sztyBjNlIGAw"
+      },
+      "outputs": [],
+      "source": [
+        "# Authentication\n",
+        "\n",
+        "auth.authenticate_user()\n",
+        "creds, _ = default()\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "mRUB0Uf-3uzS"
+      },
+      "outputs": [],
+      "source": [
+        "# Agent config\n",
+        "project_id = '' #@param{type: 'string'}\n",
+        "location = 'global'  #@param{type: 'string'}\n",
+        "agent_id = ''  #@param{type: 'string'}\n",
+        "\n",
+        "agent_id = f\"projects/{project_id}/locations/{location}/agents/{agent_id}\"\n",
+        "print(agent_id)\n",
+        "\n",
+        "s = Sessions(agent_id=agent_id)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "OChJbblt3dt7"
+      },
+      "outputs": [],
+      "source": [
+        "# Test\n",
+        "user_query = 'Hello World!'\n",
+        "agent_answer = s.get_agent_answer(user_query)\n",
+        "print(f\" Q: {user_query}\\n A: {agent_answer}\")\n",
+        "\n",
+        "user_query = 'Which is the cheapest plan?'\n",
+        "agent_answer = s.get_agent_answer(user_query)\n",
+        "print(f\" Q: {user_query}\\n A: {agent_answer}\")\n"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "L2WQime-8-Dw"
+      },
+      "source": [
+        "## Generate Questions & Answer"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "q3II66B04F0j"
+      },
+      "outputs": [],
+      "source": [
+        "# Create sample csv\n",
+        "\n",
+        "sample_df = pd.DataFrame({\n",
+        "  \"user_query\": [],\n",
+        "  \"ideal_answer\": [],\n",
+        "  \"agent_answer\": [],\n",
+        "  \"rating\": [],\n",
+        "  \"comment\": []\n",
+        "})\n",
+        "\n",
+        "sample_df.loc[0] = [\"Who are you?\", \"I am an assistant\", \"\", 0, \"\"]\n",
+        "sample_df.loc[1] = [\"Which is the cheapest plan?\", \"Basic plan\", \"\", 0, \"\"]\n",
+        "sample_df.loc[2] = [\"My device is not working\", \"Call 888-555\", \"\", 0, \"\"]\n",
+        "\n",
+        "# Export to local drive as csv file\n",
+        "file_name = 'data_sample.csv'\n",
+        "sample_df.to_csv(file_name, encoding='utf-8-sig', index=False)\n",
+        "files.download(file_name)\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "OYr4Dy77KbfL"
+      },
+      "outputs": [],
+      "source": [
+        "input(f\"In your local drive, you can find the csv file '{file_name}' Add the user_query and ideal_answer per example \\nWhen done, click 'Enter'\")\n",
+        "print('done')\n",
+        "\n",
+        "# Import from local drive the csv file with the user_query and ideal_answer per examples\n",
+        "uploaded = files.upload()\n",
+        "file_name2 = next(iter(uploaded))\n",
+        "df = pd.read_csv(io.BytesIO(uploaded[file_name2]))\n",
+        "\n",
+        "assert df.shape[0] > 0, \"The csv has zero rows\"\n",
+        "assert set(df.columns) == set(sample_df.columns), f\"The csv must have the following columns: {sample_df.columns.values}\"\n",
+        "\n",
+        "df"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "RmJcxpFI881j"
+      },
+      "outputs": [],
+      "source": [
+        "# Generate answers for each query\n",
+        "df['agent_answer'] = df.apply(lambda row: s.get_agent_answer(row[\"user_query\"]), axis=1)\n",
+        "\n",
+        "df"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "yO2x7lc2BRDR"
+      },
+      "source": [
+        "# Rating"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "ZfAMlQbS8qsy"
+      },
+      "outputs": [],
+      "source": [
+        "# Export to local drive as csv file\n",
+        "file_name = 'output.csv'\n",
+        "df.to_csv(file_name, encoding='utf-8-sig', index=False)\n",
+        "files.download(file_name)\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "SEU44Mcy9mBU"
+      },
+      "outputs": [],
+      "source": [
+        "input(f\"In your local drive, you can find the csv file '{file_name}' Rate each agent_answer using ideal_answer as reference. Rating from -1 to 3. \\nWhen done, click 'Enter'\")\n",
+        "print('done')\n",
+        "\n",
+        "# Import from local drive the csv file with the ratings\n",
+        "uploaded = files.upload()\n",
+        "file_name2 = next(iter(uploaded))\n",
+        "df = pd.read_csv(io.BytesIO(uploaded[file_name2]))\n",
+        "\n",
+        "assert df.shape[0] > 0, \"The csv has zero rows\"\n",
+        "assert set(df.columns) == set(sample_df.columns), f\"The csv must have the following columns: {sample_df.columns.values}\"\n",
+        "\n",
+        "df"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "W5j9yAewRmNO"
+      },
+      "source": [
+        "# Results\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "I5209MB7VS1q"
+      },
+      "outputs": [],
+      "source": [
+        "# Rating distribution\n",
+        "#df[\"rating\"].describe()\n",
+        "\n",
+        "# Histogram\n",
+        "ratings_set = [-1, 0, 1, 2, 3]\n",
+        "ratings_values = df['rating'].values\n",
+        "ratings_count = len(ratings_values)\n",
+        "\n",
+        "bar_centers = np.linspace(min(ratings_set), max(ratings_set), len(ratings_set))\n",
+        "bar_edges = np.linspace(min(ratings_set)-0.5, max(ratings_set)+0.5, len(ratings_set)+1)\n",
+        "bar_heights, _ = np.histogram(ratings_values, bins=bar_edges, density=True)\n",
+        "\n",
+        "for center, _h in zip(bar_centers, bar_heights):\n",
+        "  print(f\"{center}: count={round(_h*ratings_count):.0f}, percentage={_h*100:.2f}%\")\n",
+        "\n",
+        "# Plot\n",
+        "height_sum = 100  # for percentage, use 100\n",
+        "fig, axs = plt.subplots(1, 1, figsize=(6, 4), tight_layout=True)\n",
+        "\n",
+        "plt.bar(bar_centers, height_sum*bar_heights, width=0.8)\n",
+        "ratings_mean = np.mean(ratings_values)\n",
+        "plt.plot([ratings_mean, ratings_mean], [0, height_sum], '--', label=f\"mean={ratings_mean:.2f}\", color='red')\n",
+        "ratings_median = np.median(ratings_values)\n",
+        "plt.plot([ratings_median, ratings_median], [0, height_sum], '--', label=f\"median={ratings_median:.2f}\", color='green')\n",
+        "\n",
+        "plt.axis((min(bar_edges), max(bar_edges), 0, round(1.2*max(height_sum*bar_heights), 1)))\n",
+        "plt.legend(loc='upper left')\n",
+        "plt.gca().grid(axis='y')\n",
+        "plt.xlabel('Rating')\n",
+        "plt.ylabel('Percentage [%]')\n",
+        "plt.title(f\"Rating distribution (count={ratings_count})\")\n",
+        "\n",
+        "plt.tight_layout()\n",
+        "plt.show()\n",
+        "\n",
+        "fig.savefig('ratings_distribution.png', dpi=fig.dpi)\n",
+        "\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "rYwsIZ0Ej-v9"
+      },
+      "outputs": [],
+      "source": []
+    }
+  ],
+  "metadata": {
+    "colab": {
+      "provenance": []
+    },
+    "kernelspec": {
+      "display_name": "Python 3",
+      "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.10"
+    }
+  },
+  "nbformat": 4,
+  "nbformat_minor": 0
+}
diff --git a/examples/vertex_ai_conversation/evaluation_tool__numeric_score__nocolab.ipynb b/examples/vertex_ai_conversation/evaluation_tool__numeric_score__nocolab.ipynb
new file mode 100644
index 00000000..30507310
--- /dev/null
+++ b/examples/vertex_ai_conversation/evaluation_tool__numeric_score__nocolab.ipynb
@@ -0,0 +1,355 @@
+{
+  "cells": [
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "WpkyirmC-F33"
+      },
+      "source": [
+        "# Infobot Eval\n",
+        "\n",
+        "This tool requieres user's input in several steps. Please run the cells one by one (Shift+Enter) to ensure all the steps are succesfully completed.\n",
+        "\n",
+        "## Instructions:\n",
+        "\n",
+        "1.  **Set-up**\n",
+        "    1. First cell: install and import dependencies\n",
+        "    2. Second cell: authentication - it requieres following the steps in the pop-up window. Alternatively, it can be replaced by other [supported authentication method](https://github.com/GoogleCloudPlatform/dfcx-scrapi#authentication)\n",
+        "    3. Third cell: introduce values for project, location and agent in the right panel; then run the cell.\n",
+        "    4. Fourth cell: run examples to validate set-up is correct\n",
+        "2.  **Generate Questions & Answer**\n",
+        "    1. First cell: save a sample csv file with correct format\n",
+        "    2. Second cell: upload csv file with the fields `user_query` and an `ideal_answer` for all examples\n",
+        "    3. Third cell: bulk generation of `agent_answer` that includes the text and link\n",
+        "3.  **Rating**\n",
+        "    1. First cell: download csv and add the ratings offline\n",
+        "    2. Second cell: upload csv file with the ratings\n",
+        "4. **Results**\n",
+        "    1. First cell: visualize distribution of ratings\n",
+        "\n",
+        "This notebook calls `DetectIntent` using [dfcx-scrapi library](https://github.com/GoogleCloudPlatform/dfcx-scrapi) for Dialogflow CX.\n",
+        "\n",
+        "\n",
+        "## Rating guidance:\n",
+        "\n",
+        "For each sample (aka row), the rater should evaluate each answer (including ythe link) that was generated by the agent. The answer will be evaluated with a integer number (escalar) from -1 to 3 as following:\n",
+        "*   **+3** : Perfect answer > fully addresses the question with correct information and polite tone\n",
+        "*   **+2** : Good answer > may contain unnecessary info, may miss some info, or may not be perfectly articulated\n",
+        "*   **+1** : Slightly good answer > some truth to the answer\n",
+        "*   **0** : Neutral answer > no answer or answer contains irrelevant info\n",
+        "*   **-1** : Hurtful answer > wrong or misleading info, or inappropriate tone\n",
+        "\n"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "Afvsuux0zaWZ"
+      },
+      "source": [
+        "## Set-up\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "PPJYRHN83bHg"
+      },
+      "outputs": [],
+      "source": [
+        "# Dependencies\n",
+        "!pip install dfcx-scrapi --quiet\n",
+        "\n",
+        "import io\n",
+        "import matplotlib.pyplot as plt\n",
+        "import numpy as np\n",
+        "import pandas as pd\n",
+        "\n",
+        "from dfcx_scrapi.core.sessions import Sessions"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "ldbOTqPVnXRj"
+      },
+      "source": [
+        "**<span style=\"color:red\">ATTENTION</span>: MANUAL STEP**\n",
+        "\n",
+        "Instruction: Run the following commands one by one in the Terminal in order to authenticate the notebook\n",
+        "```\n",
+        "gcloud auth login\n",
+        "gcloud auth application-default login\n",
+        "```\n"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "OegHGMr_nXRj"
+      },
+      "source": [
+        "**<span style=\"color:red\">ATTENTION</span>: MANUAL STEP**\n",
+        "\n",
+        "Instruction: In the next cell, edit the values of the Agent config, then run the cell\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "mRUB0Uf-3uzS"
+      },
+      "outputs": [],
+      "source": [
+        "# Agent config\n",
+        "project_id = ''  #@param{type: 'string'}\n",
+        "location = 'global'  #@param{type: 'string'}\n",
+        "agent_id = ''  #@param{type: 'string'}\n",
+        "\n",
+        "agent_id = f\"projects/{project_id}/locations/{location}/agents/{agent_id}\"\n",
+        "print(agent_id)\n",
+        "\n",
+        "s = Sessions(agent_id=agent_id)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "OChJbblt3dt7"
+      },
+      "outputs": [],
+      "source": [
+        "# Test\n",
+        "user_query = 'Hello World!'\n",
+        "agent_answer = s.get_agent_answer(user_query)\n",
+        "print(f\" Q: {user_query}\\n A: {agent_answer}\")\n",
+        "\n",
+        "user_query = 'Which is the cheapest plan?'\n",
+        "agent_answer = s.get_agent_answer(user_query)\n",
+        "print(f\" Q: {user_query}\\n A: {agent_answer}\")"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "L2WQime-8-Dw"
+      },
+      "source": [
+        "## Generate Questions & Answer"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "q3II66B04F0j"
+      },
+      "outputs": [],
+      "source": [
+        "# Create sample csv\n",
+        "\n",
+        "sample_df = pd.DataFrame({\n",
+        "  \"user_query\": [],\n",
+        "  \"ideal_answer\": [],\n",
+        "  \"agent_answer\": [],\n",
+        "  \"rating\": [],\n",
+        "  \"comment\": []\n",
+        "})\n",
+        "\n",
+        "sample_df.loc[0] = [\"Who are you?\", \"I am an assistant\", \"\", 0, \"\"]\n",
+        "sample_df.loc[1] = [\"Which is the cheapest plan?\", \"Basic plan\", \"\", 0, \"\"]\n",
+        "sample_df.loc[2] = [\"My device is not working\", \"Call 888-555\", \"\", 0, \"\"]\n",
+        "\n",
+        "# Export to local drive as csv file\n",
+        "file_name = 'data_sample.csv'\n",
+        "sample_df.to_csv(file_name, encoding='utf-8-sig', index=False)"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "4n-dGQLonXRm"
+      },
+      "source": [
+        "**<span style=\"color:red\">ATTENTION</span>: MANUAL STEP**\n",
+        "\n",
+        "Instructions:\n",
+        "\n",
+        "1. Download the file `data_sample.csv` to your local drive by right-clicking in the file\n",
+        "2. Open the csv file `data_sample.csv` and add the `user_query` and `ideal_answer` per example\n",
+        "3. Upload the updated file from your local drive to the Jupyter File system by clicking 'Upload File'\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "OYr4Dy77KbfL"
+      },
+      "outputs": [],
+      "source": [
+        "\n",
+        "file_name2 = file_name\n",
+        "df = pd.read_csv(file_name2)\n",
+        "\n",
+        "assert df.shape[0] > 0, \"The csv has zero rows\"\n",
+        "assert set(df.columns) == set(sample_df.columns), f\"The csv must have the following columns: {sample_df.columns.values}\"\n",
+        "\n",
+        "df"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "RmJcxpFI881j"
+      },
+      "outputs": [],
+      "source": [
+        "# Generate answers for each query\n",
+        "df['agent_answer'] = df.apply(lambda row: s.get_agent_answer(row[\"user_query\"]), axis=1)\n",
+        "\n",
+        "# Export to local drive as csv file\n",
+        "file_name3 = file_name2\n",
+        "df.to_csv(file_name3, encoding='utf-8-sig', index=False)\n",
+        "\n",
+        "df"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "yO2x7lc2BRDR"
+      },
+      "source": [
+        "# Rating"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "uB0txK4QnXRn"
+      },
+      "source": [
+        "**<span style=\"color:red\">ATTENTION</span>: MANUAL STEP**\n",
+        "\n",
+        "Instructions:\n",
+        "\n",
+        "1. Download the file `data_sample.csv` to your local drive by right-clicking in the file\n",
+        "2. Open the csv file `data_sample.csv` and add the `rating` and `comment` (optionally) per example\n",
+        "3. Upload the updated file from your local drive to the Jupyter File system by clicking 'Upload File'\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "SEU44Mcy9mBU"
+      },
+      "outputs": [],
+      "source": [
+        "\n",
+        "df = pd.read_csv(file_name3)\n",
+        "\n",
+        "assert df.shape[0] > 0, \"The csv has zero rows\"\n",
+        "assert set(df.columns) == set(sample_df.columns), f\"The csv must have the following columns: {sample_df.columns.values}\"\n",
+        "\n",
+        "df"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "W5j9yAewRmNO"
+      },
+      "source": [
+        "# Results\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "I5209MB7VS1q"
+      },
+      "outputs": [],
+      "source": [
+        "# Rating distribution\n",
+        "#df[\"rating\"].describe()\n",
+        "\n",
+        "# Histogram\n",
+        "ratings_set = [-1, 0, 1, 2, 3]\n",
+        "ratings_values = df['rating'].values\n",
+        "ratings_count = len(ratings_values)\n",
+        "\n",
+        "bar_centers = np.linspace(min(ratings_set), max(ratings_set), len(ratings_set))\n",
+        "bar_edges = np.linspace(min(ratings_set)-0.5, max(ratings_set)+0.5, len(ratings_set)+1)\n",
+        "bar_heights, _ = np.histogram(ratings_values, bins=bar_edges, density=True)\n",
+        "\n",
+        "for center, _h in zip(bar_centers, bar_heights):\n",
+        "  print(f\"{center}: count={round(_h*ratings_count):.0f}, percentage={_h*100:.2f}%\")\n",
+        "\n",
+        "# Plot\n",
+        "height_sum = 100  # for percentage, use 100\n",
+        "fig, axs = plt.subplots(1, 1, figsize=(6, 4), tight_layout=True)\n",
+        "\n",
+        "plt.bar(bar_centers, height_sum*bar_heights, width=0.8)\n",
+        "ratings_mean = np.mean(ratings_values)\n",
+        "plt.plot([ratings_mean, ratings_mean], [0, height_sum], '--', label=f\"mean={ratings_mean:.2f}\", color='red')\n",
+        "ratings_median = np.median(ratings_values)\n",
+        "plt.plot([ratings_median, ratings_median], [0, height_sum], '--', label=f\"median={ratings_median:.2f}\", color='green')\n",
+        "\n",
+        "plt.axis((min(bar_edges), max(bar_edges), 0, round(1.2*max(height_sum*bar_heights), 1)))\n",
+        "plt.legend(loc='upper left')\n",
+        "plt.gca().grid(axis='y')\n",
+        "plt.xlabel('Rating')\n",
+        "plt.ylabel('Percentage [%]')\n",
+        "plt.title(f\"Rating distribution (count={ratings_count})\")\n",
+        "plt.show()\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "rYwsIZ0Ej-v9"
+      },
+      "outputs": [],
+      "source": []
+    }
+  ],
+  "metadata": {
+    "colab": {
+      "provenance": [],
+      "toc_visible": true
+    },
+    "environment": {
+      "kernel": "python3",
+      "name": "tf2-cpu.2-11.m112",
+      "type": "gcloud",
+      "uri": "gcr.io/deeplearning-platform-release/tf2-cpu.2-11:m112"
+    },
+    "kernelspec": {
+      "display_name": "Python 3",
+      "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"
+    }
+  },
+  "nbformat": 4,
+  "nbformat_minor": 0
+}
diff --git a/requirements.txt b/requirements.txt
index a2d91b6d..f8e90c5b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,9 +3,10 @@ google-cloud-dialogflow-cx
 google-auth
 google-oauth
 oauth2client
+pyparsing==2.4.7
 pandas
 tabulate
-gspread
+gspread==5.10.0
 gspread_dataframe
 numpy
 requests
@@ -13,7 +14,7 @@ pylint==2.8.3
 pytest==6.0.2
 pytest-cov==2.11.1
 pytest-xdist==2.1.0
-pyyaml==5.3.1
+pyyaml==5.4
 torch
 transformers
 sentencepiece
diff --git a/setup.py b/setup.py
index 2f2d6442..ae21a6a1 100644
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,6 @@
 """Setuptools for SCRAPI package."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -23,7 +23,7 @@
 
 setup(
     name='dfcx-scrapi',
-    version='1.5.1',
+    version='1.9.0',
     description='A high level scripting API for bot builders, developers, and\
       maintainers.',
     long_description=long_description,
diff --git a/src/agent_assist/agent_assist.py b/src/agent_assist/agent_assist.py
index 1c015b45..c346e94a 100644
--- a/src/agent_assist/agent_assist.py
+++ b/src/agent_assist/agent_assist.py
@@ -1,6 +1,6 @@
 """Agent Assist Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/src/dfcx_scrapi/agent_extract/__init__.py b/src/dfcx_scrapi/agent_extract/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/dfcx_scrapi/agent_extract/agents.py b/src/dfcx_scrapi/agent_extract/agents.py
new file mode 100644
index 00000000..0bcf8879
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/agents.py
@@ -0,0 +1,146 @@
+"""Agent processing methods and functions."""
+
+# Copyright 2023 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
+import time
+import os
+import shutil
+from typing import Dict
+
+from dfcx_scrapi.core import agents
+from dfcx_scrapi.core import operations
+from dfcx_scrapi.core import scrapi_base
+from dfcx_scrapi.agent_extract import graph
+from dfcx_scrapi.agent_extract import flows
+from dfcx_scrapi.agent_extract import intents
+from dfcx_scrapi.agent_extract import entity_types
+from dfcx_scrapi.agent_extract import test_cases
+from dfcx_scrapi.agent_extract import webhooks
+from dfcx_scrapi.agent_extract import gcs_utils
+from dfcx_scrapi.agent_extract import types
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+class Agents(scrapi_base.ScrapiBase):
+    """Agent Metadata methods and functions."""
+    def __init__(
+        self,
+        agent_id: str,
+        lang_code: str = "en",
+        creds_path: str = None,
+        creds_dict: Dict = None,
+        creds=None,
+        scope=False
+    ):
+        super().__init__(
+            creds_path=creds_path,
+            creds_dict=creds_dict,
+            creds=creds,
+            scope=scope,
+        )
+        self.agent_id = agent_id
+        self.lang_code = lang_code
+        self._core_agents = agents.Agents(creds=creds)
+        self.gcs = gcs_utils.GcsUtils()
+        self.flows = flows.Flows()
+        self.intents = intents.Intents()
+        self.etypes = entity_types.EntityTypes()
+        self.webhooks = webhooks.Webhooks()
+        self.tcs = test_cases.TestCases()
+        self.ops = operations.Operations()
+
+    @staticmethod
+    def prep_local_dir(agent_local_path: str):
+        """Prepare the local directory for agent zip file."""
+        if os.path.isdir(agent_local_path):
+            logging.info("Cleaning up old directory...")
+            shutil.rmtree(agent_local_path)
+            logging.info(f"Making temp directory: {agent_local_path}")
+            os.mkdir(agent_local_path)
+        else:
+            os.mkdir(agent_local_path)
+
+    def await_lro(self, lro: str):
+        """Wait for long running operation to complete."""
+        try:
+            i = 0
+            while not self.ops.get_lro(lro).done:
+                time.sleep(1)
+                i += 1
+                if i == 20:
+                    break
+
+        except UserWarning:
+            print("LRO Failed.")
+
+        return True
+
+    def export_agent(self, agent_id: str, gcs_bucket_uri: str,
+                      environment_display_name: str = None):
+        """Handle the agent export, LRO and logging."""
+        export_start = time.time()
+        logging.info("Exporting agent...")
+        lro = self._core_agents.export_agent(
+            agent_id=agent_id,gcs_bucket_uri=gcs_bucket_uri, data_format="JSON",
+            environment_display_name=environment_display_name)
+
+
+        self.await_lro(lro)
+        logging.info("Export Complete.")
+        logging.debug(f"EXPORT: {time.time() - export_start}")
+
+    def download_and_extract(self, agent_local_path: str, gcs_bucket_uri: str):
+        """Handle download from GCS and extracting ZIP file."""
+        if not os.path.exists(agent_local_path):
+            os.makedirs(agent_local_path)
+
+        download_start = time.time()
+        logging.info("Downloading agent file from GCS Bucket...")
+        agent_file = self.gcs.download_gcs(
+            gcs_path=gcs_bucket_uri, local_path=agent_local_path)
+        logging.info("Download complete.")
+        logging.debug(f"DOWNLOAD: {time.time() - download_start}")
+
+        self.gcs.unzip(agent_file, agent_local_path)
+
+
+    def process_agent(self, agent_id: str, gcs_bucket_uri: str,
+                      environment_display_name: str = None):
+        """Process the specified Agent for offline data gathering."""
+        agent_local_path = "/tmp/agent"
+        self.prep_local_dir(agent_local_path)
+        self.export_agent(agent_id, gcs_bucket_uri, environment_display_name)
+        self.download_and_extract(agent_local_path, gcs_bucket_uri)
+
+        logging.info("Processing Agent...")
+        data = types.AgentData()
+        data.graph = graph.Graph()
+        data.lang_code = self.lang_code
+        data.agent_id = agent_id
+        data = self.flows.process_flows_directory(agent_local_path, data)
+        data = self.intents.process_intents_directory(agent_local_path, data)
+        data = self.etypes.process_entity_types_directory(
+            agent_local_path, data)
+        data = self.webhooks.process_webhooks_directory(agent_local_path, data)
+        data = self.tcs.process_test_cases_directory(agent_local_path, data)
+        logging.info("Processing Complete.")
+
+        return data
diff --git a/src/dfcx_scrapi/agent_extract/common.py b/src/dfcx_scrapi/agent_extract/common.py
new file mode 100644
index 00000000..26b86ff3
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/common.py
@@ -0,0 +1,80 @@
+"""Common methods and helper functions used throughout library."""
+
+# Copyright 2023 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
+import re
+from dfcx_scrapi.agent_extract import types
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(message)s",
+)
+
+class Common:
+    """Common methods and helper functions used throughout library."""
+
+    @staticmethod
+    def parse_filepath(in_path: str, resource_type: str) -> str:
+        """Parse file path to provide quick reference for resource."""
+
+        regex_map = {
+            "flow": r".*\/flows\/([^\/]*)",
+            "page": r".*\/pages\/([^\/]*)\.",
+            "entity_type": r".*\/entityTypes\/([^\/]*)",
+            "intent": r".*\/intents\/([^\/]*)",
+            "route_group": r".*\/transitionRouteGroups\/([^\/]*)",
+            "webhook": r".*\/webhooks\/([^\/]*)\."
+        }
+        resource_name = re.match(regex_map[resource_type], in_path).groups()[0]
+
+        return resource_name
+
+    @staticmethod
+    def clean_display_name(display_name: str):
+        """Replace cspecial haracters from map for the given display name."""
+        patterns = {
+            "%22": '"',
+            "%23": "#",
+            "%24": "$",
+            "%26": "&",
+            "%27": "'",
+            "%28": "(",
+            "%29": ")",
+            "%2b": "+",
+            "%2c": ",",
+            "%2f": "/",
+            "%3a": ":",
+            "%3c": "<",
+            "%3d": "=",
+            "%3e": ">",
+            "%3f": "?",
+            "%5b": "[",
+            "%5d": "]",
+            "%e2%80%9c": "“",
+            "%e2%80%9d": "”",
+        }
+
+        for key, value in patterns.items():
+            if key in display_name:
+                display_name = display_name.replace(key, value)
+
+        return display_name
+
+    @staticmethod
+    def check_lang_code(lang_code: str, stats: types.AgentData):
+        """Check to see if file lang_code matches user input lang_code."""
+        return stats.lang_code == lang_code
diff --git a/src/dfcx_scrapi/agent_extract/entity_types.py b/src/dfcx_scrapi/agent_extract/entity_types.py
new file mode 100644
index 00000000..17469b58
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/entity_types.py
@@ -0,0 +1,168 @@
+"""Entity Type processing methods and functions."""
+
+# Copyright 2023 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 json
+import os
+
+from typing import Dict
+
+from dfcx_scrapi.agent_extract import common
+from dfcx_scrapi.agent_extract import types
+
+class EntityTypes:
+    """Entity Type processing methods and functions."""
+
+    def __init__(self):
+        self.common = common.Common()
+
+    @staticmethod
+    def build_entity_type_path_list(agent_local_path: str):
+        """Builds a list of dirs, each representing an Entity Type directory.
+
+        Ex: /path/to/agent/entityTypes/<entity_type_dir>
+
+        This dir path can then be used to find the next level of information
+        in the directory by appending the appropriate next dir structures like:
+        - <entity_type_name>.json, for the Entity Type object
+        - /entities, for the Entities dir
+        """
+        root_dir = agent_local_path + "/entityTypes"
+
+        entity_type_paths = []
+
+        for entity_type_dir in os.listdir(root_dir):
+            entity_type_dir_path = f"{root_dir}/{entity_type_dir}"
+            entity_type_paths.append(entity_type_dir_path)
+
+        return entity_type_paths
+
+    @staticmethod
+    def build_lang_code_paths(etype: types.EntityType):
+        """Builds dict of lang codes and file locations.
+
+        The language_codes and paths for each file are stored in a dictionary
+        inside of the Entity Type dataclass. This dict is accessed later to
+        lint each file and provide reporting based on each language code.
+        """
+        root_dir = etype.dir_path + "/entities"
+
+        for lang_file in os.listdir(root_dir):
+            lang_code = lang_file.split(".")[0]
+            lang_code_path = f"{root_dir}/{lang_file}"
+            etype.entities[lang_code] = {"file_path": lang_code_path}
+
+    @staticmethod
+    def build_excluded_phrases_path(etype: types.EntityType, lang_code: str):
+        """Builds a dict of excluded phrases and file locations."""
+        root_dir = etype.dir_path + "/excludedPhrases"
+        lang_code_path = f"{root_dir}/{lang_code}.json"
+
+        return lang_code_path
+
+    @staticmethod
+    def process_entity_type_metadata(etype: types.EntityType):
+        """Extract metadata for Entity Type for later processing."""
+        metadata_file = etype.dir_path + f"/{etype.display_name}.json"
+
+        with open(metadata_file, "r", encoding="UTF-8") as etype_file:
+            etype.data = json.load(etype_file)
+            etype.resource_id = etype.data.get("name", None)
+            etype.kind = etype.data.get("kind", None)
+            etype.auto_expansion = etype.data.get("autoExpansionMode", None)
+            etype.fuzzy_extraction = etype.data.get(
+                "enableFuzzyExtraction", False)
+
+            etype_file.close()
+
+    def process_excluded_phrases_language_codes(
+            self, data: Dict[str, str], lang_code_path: str):
+        """Process all ecluded phrases lang_code files."""
+        with open(lang_code_path, "r", encoding="UTF-8") as ent_file:
+            new_data = json.load(ent_file)
+            data["excluded_phrases"] = new_data.get("excludedPhrases", None)
+
+        return data
+
+    def process_excluded_phrases(self, etype: types.EntityType, lang_code: str,
+                                 data: Dict[str, str]):
+        """Process the excluded phrases if they exist."""
+        if "excludedPhrases" in os.listdir(etype.dir_path):
+            lang_code_path = self.build_excluded_phrases_path(etype, lang_code)
+            data = self.process_excluded_phrases_language_codes(
+                data, lang_code_path)
+
+        return data
+
+    def process_language_codes(
+            self, etype: types.EntityType, stats: types.AgentData):
+        """Process all Entity Type lang_code files."""
+        for lang_code in etype.entities:
+            ent_file_path = etype.entities[lang_code]["file_path"]
+
+            if not self.common.check_lang_code(lang_code, stats):
+                continue
+
+            with open(ent_file_path, "r", encoding="UTF-8") as ent_file:
+                data = json.load(ent_file)
+                data["name"] = f"{stats.agent_id}/entityTypes/"\
+                    f"{etype.resource_id}"
+                data["display_name"] = etype.display_name
+                data["kind"] = etype.kind
+                data["entities"] = data.get("entities", None)
+                data = self.process_excluded_phrases(etype, lang_code, data)
+                stats.entity_types.append(data)
+
+                ent_file.close()
+
+        return stats
+
+    def process_entities(self, etype: types.EntityType, stats: types.AgentData):
+        """Process the Entity files inside of an Entity Type."""
+        if "entities" in os.listdir(etype.dir_path):
+            self.build_lang_code_paths(etype)
+            stats = self.process_language_codes(etype, stats)
+
+        return stats
+
+    def process_entity_type(
+            self, etype: types.EntityType, stats: types.AgentData):
+        """Process a Single Entity Type dir and all subdirectories."""
+
+        etype.display_name = self.common.parse_filepath(
+            etype.dir_path, "entity_type")
+        etype.display_name = self.common.clean_display_name(etype.display_name)
+
+        self.process_entity_type_metadata(etype)
+        stats = self.process_entities(etype, stats)
+        stats.total_entity_types += 1
+
+        return stats
+
+    def process_entity_types_directory(
+            self, agent_local_path: str, stats: types.AgentData):
+        """Processing the Entity Types dir in the JSON Package structure."""
+        # Create a list of all Entity Type paths to iter through
+        entity_type_paths = self.build_entity_type_path_list(agent_local_path)
+
+        for entity_type_path in entity_type_paths:
+            etype = types.EntityType()
+            etype.dir_path = entity_type_path
+
+            stats = self.process_entity_type(etype, stats)
+            full_etype_id = f"{stats.agent_id}/entityTypes/{etype.resource_id}"
+            stats.entity_types_map[etype.display_name] = full_etype_id
+
+        return stats
diff --git a/src/dfcx_scrapi/agent_extract/flows.py b/src/dfcx_scrapi/agent_extract/flows.py
new file mode 100644
index 00000000..6d991dac
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/flows.py
@@ -0,0 +1,301 @@
+"""Flow extract methods and functions."""
+
+# Copyright 2023 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 json
+import os
+
+from typing import List
+
+from dfcx_scrapi.agent_extract import graph
+from dfcx_scrapi.agent_extract import common
+from dfcx_scrapi.agent_extract import types
+from dfcx_scrapi.agent_extract import pages
+from dfcx_scrapi.agent_extract import routes
+from dfcx_scrapi.agent_extract import route_groups
+
+
+class Flows:
+    """Flow processing methods and functions."""
+
+    def __init__(self):
+        self.common = common.Common()
+        self.pages = pages.Pages()
+        self.rgs = route_groups.RouteGroups()
+        self.routes = routes.Fulfillments()
+        self.special_pages = [
+            "End Session",
+            "End Flow",
+            "Start Page",
+            "Current Page",
+            "Previous Page",
+        ]
+
+    @staticmethod
+    def build_flow_path_list(agent_local_path: str):
+        """Builds a list of dirs, each representing a Flow directory.
+
+        Ex: /path/to/agent/flows/<flow_dir>
+
+        This dir path can then be used to find the next level of information
+        in the directory by appending the appropriate next dir structures like:
+        - <flow_name>.json, for the Flow object
+        - /transitionRouteGroups, for the Route Groups dir
+        - /pages, for the Pages dir
+        """
+        root_dir = agent_local_path + "/flows"
+
+        flow_paths = []
+
+        for flow_dir in os.listdir(root_dir):
+            flow_dir_path = f"{root_dir}/{flow_dir}"
+            flow_paths.append(flow_dir_path)
+
+        return flow_paths
+
+    @staticmethod
+    def remove_flow_pages_from_set(input_set: set) -> set:
+        """Remove any transitions tagged with FLOW.
+
+        Some route transitions go to Flow instead of Page. For these
+        transitions, we tag them with `FLOW` for easier identification later.
+        However, when reporting on Graph inconsistencies like Dangling or
+        Unreachable pages, we want to remove these from any result sets as they
+        are not relevant.
+        """
+        filtered_set = set()
+
+        for page in input_set:
+            if "FLOW" not in page:
+                filtered_set.add(page)
+
+        return filtered_set
+
+    def find_unreachable_pages(self, flow: types.Flow):
+        """Find Unreachable Pages in the graph.
+
+        An Unreachable Page is defined as:
+          - A Page which has no incoming edge when traversed from Start Page.
+            That is, it is unreachable in the graph by any practical means.
+          - A Page which is connected to a root unreachable page. That is, a
+            page that could have both incoming or outgoing routes, but due to
+            its connectedness to the root orphan page, is unreachable in the
+            graph.
+
+        Here we will compute the symmetric difference of 2 sets:
+          - Active Pages (i.e. Pages that were reachable in the graph)
+          - Used Pages (i.e. Pages that were used by some Route)
+
+        If an Unreachable Page has children that it routes to, those children
+        will appear in Used Pages, although they will ultimately be
+        unreachable. It's possible for an Unreachable Page to route back to an
+        Active Page in the graph. For these instances, we don't want to count
+        those pages as unreachable, because they are reachable via other
+        sections of the graph.
+        """
+        filtered_set = flow.active_pages.symmetric_difference(
+            flow.graph.used_nodes
+        )
+        filtered_set = self.remove_flow_pages_from_set(filtered_set)
+        flow.unreachable_pages.update(filtered_set)
+
+        return flow
+
+    def find_unused_pages(self, flow: types.Flow):
+        """Find Unused Pages in the graph.
+
+        An Unused Page is defined as:
+          - A Page which has no incoming or outgoing edge AND
+          - A Page which exists in the Agent design time, but which is not
+            present anywhere in the graph, either visible or non-visible.
+
+        Here we will compute the difference of 2 sets:
+          - All Pages (i.e. Pages that exist in the Agent Design Time)
+          - Used Pages (i.e. Pages that were used by some Route)
+
+        The resulting set will consist of 2 types of Pages:
+          - Truly Unused Pages
+          - Unreachable Root Pages
+
+        Unreachable Root Pages end up in the results due to the fact that no
+        other Active Page is pointing to them. We remove these from the
+        resulting set before presenting the Truly Unused Pages.
+        """
+
+        # Discard special pages as they are non-relevant for final outcome
+        for page in self.special_pages:
+            flow.all_pages.discard(page)
+
+        prelim_unused = flow.all_pages.difference(flow.graph.used_nodes)
+
+        # Filter out Unreachable Root Pages
+        filtered_set = set()
+
+        for page in prelim_unused:
+            if page not in flow.graph.edges:
+                filtered_set.add(page)
+            else:
+                flow.unreachable_pages.add(page)
+
+        flow.unused_pages = filtered_set
+
+        return flow
+
+    def recurse_edges(
+        self, edges: List, page: types.Page, dangling: set, visited: set
+    ):
+        """Recursive method searching graph edges for Active / Dangling Pages.
+
+        A byproduct of searching for Dangling Pages in the graph is that we can
+        produce a set of Active Pages in the graph. These are pages that are
+        reachable when traversing from the Start Page. These can then be used
+        to determine Unreachable Pages in another method.
+        """
+        # For Flow Start Pages, we prepend the Flow name for later
+        # identification. For this section, we'll need to strip it off to
+        # compare with the other sets.
+        if page in edges:
+            for inner_page in edges[page]:
+                if inner_page not in visited:
+                    visited.add(inner_page)
+                    dangling, visited = self.recurse_edges(
+                        edges, inner_page, dangling, visited
+                    )
+
+        else:
+            dangling.add(page)
+
+        return dangling, visited
+
+    def find_dangling_pages(self, flow: types.Flow):
+        """Find Dangling Pages in the graph.
+
+        Dangling Page is defined as:
+          - Any page that exists in the graph that has no outgoing edge
+        Active Page is defined as:
+          - Any page that is reachable via an active route in the graph and can
+            be traced back to the Start Page.
+
+        These pages can result in a conversational "dead end" which is
+        potentially unrecoverable.
+        A byproduct of searching for the dangling pages is locating all of the
+        "active" pages. These are the pages that are "visited" as we traverse
+        the graph. We'll also return Active Pages in this method since they
+        will be used for downstream tasks.
+        """
+
+        flow.dangling_pages, flow.active_pages = self.recurse_edges(
+            flow.graph.edges,
+            f"{flow.display_name}: Start Page",
+            flow.dangling_pages,
+            flow.active_pages,
+        )
+
+        # Clean up Special Pages
+        for page in self.special_pages:
+            flow.dangling_pages.discard(page)
+
+        flow.dangling_pages = self.remove_flow_pages_from_set(
+            flow.dangling_pages
+        )
+
+        return flow
+
+    def process_start_page(self, flow: types.Flow, stats: types.AgentData):
+        """Process a single Flow Path file."""
+        with open(flow.start_page_file, "r", encoding="UTF-8") as flow_file:
+            page = types.Page(flow=flow)
+            page.display_name = f"{flow.display_name}: Start Page"
+
+            # We keep track of an instance specific Flow graph for the current
+            # Flow, and then a main Graph for the entire agent.
+            flow.graph.add_node(page.display_name)
+            stats.graph.add_node(page.display_name)
+
+            page.data = json.load(flow_file)
+            page.events = page.data.get("eventHandlers", None)
+            page.routes = page.data.get("transitionRoutes", None)
+            page.route_groups = page.data.get("transitionRouteGroups", None)
+            stats.flows.append(page.data)
+
+            flow.resource_id = page.data.get("name", None)
+
+            # Order of processing is important
+            stats = self.routes.process_routes(page, stats)
+            stats = self.routes.process_events(page, stats)
+
+            if page.route_groups:
+                page, stats = self.routes.set_route_group_targets(page, stats)
+
+            flow_file.close()
+
+        full_flow_id = f"{stats.agent_id}/flows/{flow.resource_id}"
+        stats.flows_map[flow.display_name] = full_flow_id
+        stats.flow_page_map[flow.display_name] = {
+            "id": full_flow_id,
+            "pages": {}
+            }
+
+        return stats
+
+    def process_flow(self, flow: types.Flow, stats: types.AgentData):
+        """Process a Single Flow dir and all subdirectories."""
+        flow.file_name = self.common.parse_filepath(flow.dir_path, "flow")
+        flow.display_name = self.common.clean_display_name(flow.file_name)
+
+        flow.start_page_file = f"{flow.dir_path}/{flow.file_name}.json"
+
+        stats.pages[flow.display_name] = []
+        stats.active_intents[flow.display_name] = []
+        stats = self.process_start_page(flow, stats)
+        stats = self.pages.process_pages_directory(flow, stats)
+        stats = self.rgs.process_route_groups_directory(flow, stats)
+
+        # Order of Find Operations is important here!
+        flow = self.find_unused_pages(flow)
+        flow = self.find_dangling_pages(flow)
+        flow = self.find_unreachable_pages(flow)
+
+        stats.active_pages[flow.display_name] = flow.active_pages
+        stats.unused_pages[flow.display_name] = flow.unused_pages
+        stats.unreachable_pages[flow.display_name] = flow.unreachable_pages
+
+        return stats
+
+    def process_flows_directory(
+            self, agent_local_path: str, stats: types.AgentData):
+        """Process the top level Flows dir in the JSON Package structure.
+
+        The following files/dirs exist under the `flows` dir:
+        - Flow object (i.e. Flow START_PAGE)
+        - transitionRouteGroups
+        - pages
+
+        In Dialogflow CX, the START_PAGE of each Flow is a special kind of Page
+        that exists within the Flow object itself. In this method, we will lint
+        the Flow object, all files in the transitionRouteGroups dir and all
+        files in the pages dir.
+        """
+        # Create a list of all Flow paths to iter through
+        flow_paths = self.build_flow_path_list(agent_local_path)
+        stats.total_flows = len(flow_paths)
+
+        for flow_path in flow_paths:
+            flow = types.Flow()
+            flow.graph = graph.Graph()
+            flow.dir_path = flow_path
+            stats = self.process_flow(flow, stats)
+
+        return stats
diff --git a/src/dfcx_scrapi/agent_extract/gcs_utils.py b/src/dfcx_scrapi/agent_extract/gcs_utils.py
new file mode 100644
index 00000000..c3daff89
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/gcs_utils.py
@@ -0,0 +1,68 @@
+"""Utils for Cloud Storage and local file manipulation."""
+
+# Copyright 2023 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 zipfile
+from google.cloud import storage
+from google.oauth2 import service_account
+
+
+class GcsUtils:
+    """Utils for Cloud Storage and local file manipulation."""
+
+    def __init__(self, creds_path: str = None, project_id: str = None):
+        if creds_path and project_id:
+            self.creds = service_account.Credentials.from_service_account_file(
+                creds_path
+            )
+            self.gcs_client = storage.Client(
+                credentials=self.creds, project=project_id
+            )
+
+        else:
+            self.gcs_client = storage.Client()
+
+    @staticmethod
+    def unzip(agent_zip_file_path: str, extract_path: str):
+        """Unzip file locally."""
+        with zipfile.ZipFile(agent_zip_file_path, "r") as zip_ref:
+            zip_ref.extractall(extract_path)
+
+    @staticmethod
+    def check_for_gcs_file(file_path: str) -> bool:
+        """Validates GCS path vs. local path."""
+        is_gcs_file = False
+
+        file_prefix = file_path.split("/")[0]
+        if file_prefix == "gs:":
+            is_gcs_file = True
+
+        return is_gcs_file
+
+    def download_gcs(self, gcs_path: str, local_path: str = None):
+        """Downloads the specified GCS file to local machine."""
+        path = gcs_path.split("//")[1]
+        bucket = path.split("/", 1)[0]
+        gcs_object = path.split("/", 1)[1]
+        file_name = gcs_object.split("/")[-1]
+        bucket = self.gcs_client.bucket(bucket)
+        blob = storage.Blob(gcs_object, bucket)
+
+        if local_path:
+            file_name = local_path + "/" + file_name
+
+        blob.download_to_filename(file_name)
+
+        return file_name
diff --git a/src/dfcx_scrapi/agent_extract/graph.py b/src/dfcx_scrapi/agent_extract/graph.py
new file mode 100644
index 00000000..b94217ce
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/graph.py
@@ -0,0 +1,45 @@
+"""Utility class for managing graph structure."""
+
+# Copyright 2023 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 collections
+
+class Graph:
+    """Utility class for manaing graph structure."""
+
+    def __init__(self):
+        self.nodes = set()
+        self.edges = collections.defaultdict(list)
+        self.used_nodes = set()
+
+    def add_node(self, node):
+        """Add node to set of all nodes, regardless of use in graph."""
+        self.nodes.add(node)
+
+    def add_edge(self, node1, node2):
+        self.edges[node1].append(node2)
+
+    def add_used_node(self, node):
+        """Add node to set of active in use nodes for the graph."""
+        self.used_nodes.add(node)
+
+    def remove_node(self, node):
+        self.nodes.remove(node)
+
+    def remove_edge(self, node1, node2):
+        self.edges[node1].remove(node2)
+
+    def __str__(self):
+        return f"Graph({self.nodes}, {self.edges})"
diff --git a/src/dfcx_scrapi/agent_extract/intents.py b/src/dfcx_scrapi/agent_extract/intents.py
new file mode 100644
index 00000000..e8d46dca
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/intents.py
@@ -0,0 +1,166 @@
+"""Intent processing methods and functions."""
+
+# Copyright 2023 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 json
+import os
+
+from dfcx_scrapi.agent_extract import common
+from dfcx_scrapi.agent_extract import types
+
+
+class Intents:
+    """Intent processing methods and functions."""
+
+    def __init__(self):
+        self.common = common.Common()
+
+    @staticmethod
+    def parse_lang_code(lang_code_path: str) -> str:
+        """Extract the language_code from the given file path."""
+
+        first_parse = lang_code_path.split("/")[-1]
+        lang_code = first_parse.split(".")[0]
+
+        return lang_code
+
+    @staticmethod
+    def build_lang_code_paths(intent: types.Intent):
+        """Builds dict of lang codes and file locations.
+
+        The language_codes and paths for each file are stored in a dictionary
+        inside of the Intent dataclass. This dict is access later to process
+        each file and provide reporting based on each language code.
+        """
+        root_dir = intent.dir_path + "/trainingPhrases"
+
+        for lang_file in os.listdir(root_dir):
+            lang_code = lang_file.split(".")[0]
+            lang_code_path = f"{root_dir}/{lang_file}"
+            intent.training_phrases[lang_code] = {"file_path": lang_code_path}
+
+    @staticmethod
+    def build_intent_path_list(agent_local_path: str):
+        """Builds a list of dirs, each representing an Intent directory.
+
+        Ex: /path/to/agent/intents/<intent_dir>
+
+        This dir path can be used to find the next level of information
+        in the directory by appending the appropriate next dir structures like:
+        - <intent_name>.json, for the Intent object metadata
+        - /trainingPhrases, for the Training Phrases dir
+        """
+        root_dir = agent_local_path + "/intents"
+
+        intent_paths = []
+
+        for intent_dir in os.listdir(root_dir):
+            intent_dir_path = f"{root_dir}/{intent_dir}"
+            intent_paths.append(intent_dir_path)
+
+        return intent_paths
+
+    def process_intent_metadata(
+            self, intent: types.Intent):
+        """Process the metadata file for a single Intent."""
+        intent.metadata_file = f"{intent.dir_path}/{intent.display_name}.json"
+
+        try:
+            with open(intent.metadata_file, "r", encoding="UTF-8") as meta_file:
+                intent.data = json.load(meta_file)
+                intent.resource_id = intent.data.get("name", None)
+                intent.labels = intent.data.get("labels", None)
+                intent.description = intent.data.get("description", None)
+                intent.parameters = intent.data.get("parameters", None)
+
+                meta_file.close()
+
+        except FileNotFoundError:
+            pass
+
+    def process_language_codes(
+            self, intent: types.Intent, stats: types.AgentData):
+        """Process all training phrase lang_code files."""
+
+        for lang_code in intent.training_phrases:
+            tp_file = intent.training_phrases[lang_code]["file_path"]
+
+            if not self.common.check_lang_code(lang_code, stats):
+                continue
+
+            with open(tp_file, "r", encoding="UTF-8") as tps:
+                data = json.load(tps)
+                data["name"] = f"{stats.agent_id}/intents/{intent.resource_id}"
+                data["display_name"] = intent.display_name
+                data["labels"] = intent.labels
+                data["description"] = intent.description
+                data["parameters"] = intent.parameters
+                stats.intents.append(data)
+                stats.total_training_phrases += len(data["trainingPhrases"])
+
+                tps.close()
+
+        return stats
+
+    def process_training_phrases(
+            self, intent: types.Intent, stats: types.AgentData):
+        """Process the Training Phrase dir for a single Intent."""
+        if "trainingPhrases" in os.listdir(intent.dir_path):
+            self.build_lang_code_paths(intent)
+            stats = self.process_language_codes(intent, stats)
+
+        return stats
+
+    def process_intent(self, intent: types.Intent, stats: types.AgentData):
+        """Process a single Intent directory and associated files."""
+        intent.display_name = self.common.parse_filepath(
+            intent.dir_path, "intent")
+        intent.display_name = self.common.clean_display_name(
+            intent.display_name)
+
+        self.process_intent_metadata(intent)
+        stats = self.process_training_phrases(intent, stats)
+        stats.total_intents += 1
+
+        return stats
+
+    def process_intents_directory(
+            self, agent_local_path: str, stats: types.AgentData):
+        """Processing the top level Intents Dir in the JSON Package structure.
+
+        The following files/dirs exist under the `intents` dir:
+        - <intent_display_name> Directory
+          - trainingPhrases
+            - <language-code>.json
+          - <intent_display_name> Object
+
+        In Dialogflow CX, the Training Phrases of each Intent are stored in
+        individual .json files by language code under each Intent Display
+        Name. In this method, we will process all Intent dirs, including the
+        training phrase files and metadata objects for each Intent.
+        """
+        # Create a list of all Intent paths to iter through
+        intent_paths = self.build_intent_path_list(agent_local_path)
+        stats.intents = []
+
+        for intent_path in intent_paths:
+            intent = types.Intent()
+            intent.dir_path = intent_path
+
+            stats = self.process_intent(intent, stats)
+            full_intent_id = f"{stats.agent_id}/intents/{intent.resource_id}"
+            stats.intents_map[intent.display_name] = full_intent_id
+
+        return stats
diff --git a/src/dfcx_scrapi/agent_extract/pages.py b/src/dfcx_scrapi/agent_extract/pages.py
new file mode 100644
index 00000000..51a710c6
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/pages.py
@@ -0,0 +1,138 @@
+"""Pages processing methods and functions."""
+
+# Copyright 2023 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 json
+import os
+
+from typing import Dict, Any
+
+from dfcx_scrapi.agent_extract import common
+from dfcx_scrapi.agent_extract import types
+from dfcx_scrapi.agent_extract import routes
+
+
+class Pages:
+    """Pages processing methods and functions."""
+
+    def __init__(self):
+        self.common = common.Common()
+        self.routes = routes.Fulfillments()
+
+    @staticmethod
+    def build_page_path_list(flow_path: str):
+        """Builds a list of files, each representing a Page.
+
+        Ex: /path/to/agent/flows/<flow_dir>/pages/<page_name>.json
+        """
+        pages_path = f"{flow_path}/pages"
+
+        page_paths = []
+
+        for page in os.listdir(pages_path):
+            page_file_path = f"{pages_path}/{page}"
+            page_paths.append(page_file_path)
+
+        return page_paths
+
+    @staticmethod
+    def get_form_parameter_data(param: Dict[str, Any], page: types.Page):
+        fp = types.FormParameter(page=page)
+        fp.display_name = param.get("displayName", None)
+        fp.entity_type = param.get("entityType", None)
+        fp.required = param.get("required", None)
+
+        fp.fill_behavior = param.get("fillBehavior", None)
+
+        if fp.fill_behavior:
+            fp.init_fulfillment = fp.fill_behavior.get(
+                "initialPromptFulfillment", None)
+            fp.reprompt_handlers = fp.fill_behavior.get(
+                "repromptEventHandlers", None)
+
+        fp.advanced_settings = page.form.get("advancedSettings", None)
+
+        if fp.advanced_settings:
+            fp.dtmf_settings = fp.advanced_settings.get("dtmfSettings", None)
+
+        return fp
+
+    def process_form(self, page: types.Page, stats: types.AgentData):
+        """Process the Form and sub-resources within it for the Page."""
+        parameters = page.form.get("parameters", None)
+
+        if parameters:
+            for param in parameters:
+                fp = self.get_form_parameter_data(param, page)
+                stats = self.routes.process_reprompt_handlers(fp, stats)
+
+        return stats
+
+
+    def process_page(self, page: types.Page, stats: types.AgentData):
+        """Process a Single Page file."""
+        page.display_name = self.common.parse_filepath(page.page_file, "page")
+        page.display_name = self.common.clean_display_name(page.display_name)
+
+        stats.graph.add_node(page.display_name)
+        page.flow.graph.add_node(page.display_name)
+
+        page.flow.all_pages.add(page.display_name)
+
+        with open(page.page_file, "r", encoding="UTF-8") as page_file:
+            page.data = json.load(page_file)
+            page.entry = page.data.get("entryFulfillment", None)
+            page.events = page.data.get("eventHandlers", None)
+            page.form = page.data.get("form", None)
+            page.routes = page.data.get("transitionRoutes", None)
+            page.route_groups = page.data.get("transitionRouteGroups", None)
+            page.resource_id = page.data.get("name", None)
+
+            # Order of linting is important here
+            stats = self.routes.process_entry(page, stats)
+            stats = self.routes.process_routes(page, stats)
+            stats = self.routes.process_events(page, stats)
+            stats = self.process_form(page, stats)
+
+            if page.route_groups:
+                page, stats = self.routes.set_route_group_targets(page, stats)
+
+            page_file.close()
+
+        full_flow_id = f"{stats.agent_id}/flows/{page.flow.resource_id}"
+        full_page_id = f"{full_flow_id}/pages/{page.resource_id}"
+        stats.pages[page.flow.display_name].append(page.data)
+        stats.flow_page_map[
+            page.flow.display_name]["pages"][page.display_name] = full_page_id
+
+        return stats
+
+    def process_pages_directory(self, flow: types.Flow, stats: types.AgentData):
+        """Process the Pages dir inside a specific Flow dir.
+
+        Some Flows may not contain Pages, so we check for the existence
+        of the directory before traversing
+        """
+        if "pages" in os.listdir(flow.dir_path):
+            page_paths = self.build_page_path_list(flow.dir_path)
+
+            for page_path in page_paths:
+                page = types.Page(flow=flow)
+                page.agent_id = flow.agent_id
+                page.page_file = page_path
+                stats.total_pages += 1
+                stats = self.process_page(page, stats)
+
+        return stats
diff --git a/src/dfcx_scrapi/agent_extract/route_groups.py b/src/dfcx_scrapi/agent_extract/route_groups.py
new file mode 100644
index 00000000..3a73e50a
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/route_groups.py
@@ -0,0 +1,100 @@
+"""Route Groups processing methods and functions."""
+
+# Copyright 2023 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 os
+import json
+
+from dfcx_scrapi.agent_extract import common
+from dfcx_scrapi.agent_extract import types
+from dfcx_scrapi.agent_extract import routes
+
+
+class RouteGroups:
+    """Route Groups processing methods and functions."""
+
+    def __init__(self):
+        self.special_pages = [
+            "End Session",
+            "End Flow",
+            "Start Page",
+            "Current Page",
+            "Previous Page",
+        ]
+
+        self.common = common.Common()
+        self.routes = routes.Fulfillments()
+
+    @staticmethod
+    def build_route_group_path_list(flow_local_path: str):
+        """Builds a list of files, each representing a Route Group.
+
+        Ex: /path/to/agent/flows/<flow_dir>/transitionRouteGroups/<rg.json>
+        """
+        root_dir = flow_local_path + "/transitionRouteGroups"
+
+        if "transitionRouteGroups" in os.listdir(flow_local_path):
+            rg_paths = []
+
+            for rg_file in os.listdir(root_dir):
+                rg_file_path = f"{root_dir}/{rg_file}"
+                rg_paths.append(rg_file_path)
+
+        return rg_paths
+
+    def process_route_group(self, rg: types.RouteGroup, stats: types.AgentData):
+        """Process a single Route Group."""
+        rg.display_name = self.common.parse_filepath(rg.rg_file, "route_group")
+        rg.display_name = self.common.clean_display_name(rg.display_name)
+
+        with open(rg.rg_file, "r", encoding="UTF-8") as route_group_file:
+            rg.data = json.load(route_group_file)
+            rg.resource_id = rg.data.get("name", None)
+            rg.display_name = rg.data.get("displayName", None)
+            rg.routes = rg.data.get("transitionRoutes", None)
+
+            stats = self.routes.process_routes(rg, stats)
+
+            route_group_file.close()
+
+        full_flow_id = f"{stats.agent_id}/flows/{rg.flow.resource_id}"
+        full_rg_id = f"{full_flow_id}/transitionRouteGroups/{rg.resource_id}"
+        stats.route_groups_map[
+            rg.flow.display_name]["route_groups"][rg.display_name] = full_rg_id
+        stats.route_groups[rg.flow.display_name].append(rg.data)
+
+        return stats
+
+    def process_route_groups_directory(
+            self, flow: types.Flow, stats: types.AgentData):
+        """Process Route Groups dir in the JSON Package structure."""
+        if "transitionRouteGroups" in os.listdir(flow.dir_path):
+            # Create a list of all Route Group paths to iter through
+            rg_paths = self.build_route_group_path_list(flow.dir_path)
+            stats.total_route_groups += len(rg_paths)
+
+            full_flow_id = f"{stats.agent_id}/flows/{flow.resource_id}"
+            stats.route_groups_map[flow.display_name] = {
+                "id": full_flow_id,
+                "route_groups": {}
+            }
+            stats.route_groups[flow.display_name] = []
+
+            for rg_path in rg_paths:
+                rg = types.RouteGroup(flow=flow)
+                rg.rg_file = rg_path
+                stats = self.process_route_group(rg, stats)
+
+        return stats
diff --git a/src/dfcx_scrapi/agent_extract/routes.py b/src/dfcx_scrapi/agent_extract/routes.py
new file mode 100644
index 00000000..f91d61ed
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/routes.py
@@ -0,0 +1,320 @@
+"""Fulfillment routes processing methods and functions."""
+
+# Copyright 2023 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 Dict, Any
+
+from dfcx_scrapi.agent_extract import common
+from dfcx_scrapi.agent_extract import types
+
+
+class Fulfillments:
+    """Fulfillment routes processing methods and functions."""
+
+    def __init__(self):
+        self.common = common.Common()
+        self.route_parameters = {}
+
+    @staticmethod
+    def check_for_webhook(page: types.Page, path: Dict[str, Any]):
+        """Check the current route for existence of webhook."""
+        if "webhook" in path:
+            page.has_webhook = True
+
+    @staticmethod
+    def check_for_webhook_event_handlers(route: types.Fulfillment):
+        """Check for Webhook Error Event Handler on Page.
+
+        In this method, we're interested in the following conditions:
+         - Page is currently flagged w/webhook = True
+         - Page HAS NOT been flagged w/having a webhook error handler
+         - The trigger MATCHES the pattern 'webhook.error'
+
+        If a Page and its Route meet all the criteria, we'll flip the bit.
+        Otherwise, the webhook handler bit will remain False, causing a rule
+        flag."""
+
+        if all(
+            [
+                route.page.has_webhook,
+                not route.page.has_webhook_event_handler,
+                "webhook.error" in route.trigger,
+            ]
+        ):
+            route.page.has_webhook_event_handler = True
+
+    @staticmethod
+    def check_for_intent(route: types.Fulfillment):
+        """Check route data to see if Intent is present."""
+        intent = None
+        if "intent" in route.data:
+            intent = route.data["intent"]
+
+        return intent
+
+    def process_intents_in_routes(
+            self, route: types.Fulfillment, stats: types.AgentData):
+        intent = self.check_for_intent(route)
+        if intent:
+            pair = (intent, route.page.display_name)
+            stats.active_intents[
+                route.page.flow.display_name].append(pair)
+
+        return stats
+
+    def collect_transition_route_trigger(self, route):
+        """Inspect route and return all Intent/Condition info."""
+
+        trigger = []
+
+        if "intent" in route.data:
+            trigger.append("intent")
+
+        if "condition" in route.data:
+            trigger.append("condition")
+
+        if len(trigger) > 0:
+            trigger = "+".join(trigger)
+
+        return trigger
+
+    def get_trigger_info(self, route):
+        """Extract trigger info from route based on primary key."""
+
+        if route.fulfillment_type == "event":
+            trigger = f"event : {route.data.get('event', None)}"
+
+        if route.fulfillment_type == "reprompt_handler":
+            trigger = f"{route.parameter} : event : "\
+                f"{route.data.get('event', None)}"
+
+        if route.fulfillment_type == "transition_route":
+            intent_condition = self.collect_transition_route_trigger(route)
+            trigger = f"route : {intent_condition}"
+
+        return trigger
+
+    def set_route_group_targets(self, page: types.Page, stats: types.AgentData):
+        """Determine Route Targets for Route Group routes."""
+        current_page = page.display_name
+
+        for route_group in page.route_groups:
+            page.flow.graph.add_edge(current_page, route_group)
+            page.flow.graph.add_used_node(route_group)
+
+            stats.graph.add_edge(current_page, route_group)
+            stats.graph.add_used_node(route_group)
+
+        return page, stats
+
+    def set_route_targets(
+            self, route: types.Fulfillment, stats: types.AgentData):
+        """Determine the Route Targets for the specified route.
+
+        Primary function is to build out the graph structure for the
+        Flow based on the current page and where the routes are pointing to.
+        The graph structure can then be traversed later to determine any errors
+        or inconsistencies in design.
+        """
+        current_page = route.page.display_name
+
+        route.target_flow = route.data.get("targetFlow", None)
+        route.target_page = route.data.get("targetPage", None)
+
+        if route.target_page:
+            route.page.flow.graph.add_edge(current_page, route.target_page)
+            route.page.flow.graph.add_used_node(route.target_page)
+
+            stats.graph.add_edge(current_page, route.target_page)
+            stats.graph.add_used_node(route.target_page)
+
+        if route.target_flow:
+            route.page.flow.graph.add_edge(
+                current_page, f"FLOW: {route.target_flow}")
+            route.page.flow.graph.add_used_node(f"FLOW: {route.target_flow}")
+
+            stats.graph.add_edge(
+                current_page, f"FLOW: {route.target_flow}"
+            )
+            stats.graph.add_used_node(f"FLOW: {route.target_flow}")
+
+        return route, stats
+
+    def update_route_parameters(
+            self, route: types.Fulfillment, item: Dict[str, str]):
+        """Update the Route Parameters map based on new info."""
+        flow_name = route.page.flow.display_name
+        page_name = route.page.display_name
+
+        flow_data = self.route_parameters.get(flow_name, None)
+        page_data = None
+
+        if flow_data:
+            page_data = flow_data.get(page_name, None)
+
+        # Flow and Page already exists, append to existing list.
+        if page_data:
+            self.route_parameters[flow_name][page_name].append(item)
+
+        # Flow data exists, but not Page, so only create the Page list.
+        elif flow_data and not page_data:
+            self.route_parameters[flow_name][page_name] = [item]
+
+        # Neither the Flow or Page data exists, so create it all.
+        else:
+            self.route_parameters[flow_name] = {page_name: [item]}
+
+
+    def process_fulfillment_type(
+        self, stats: types.AgentData, route: types.Fulfillment, path: object,
+        key: str):
+        """Parse through specific fulfillment types."""
+        fulfillment_data = path.get(key, None)
+
+        if fulfillment_data:
+            for item in fulfillment_data:
+                # This is where each message type will exist
+                # text, custom payload, etc.
+
+                if "text" in item:
+                    for text in item["text"]["text"]:
+                        route.text = text
+
+                if "parameter" in item:
+                    self.update_route_parameters(route, item)
+
+        return stats
+
+    def process_reprompt_handlers(
+            self, fp: types.FormParameter, stats: types.AgentData):
+        """Processing for Reprompt Event Handlers inside Form parameters.
+
+        While Reprompt Event Handlers are technically Events, they differ from
+        standard Page level Events because they act on the FormParameter data
+        structure, not Fulfillment Route data structure as standard Events do.
+        """
+        if not fp.reprompt_handlers:
+            return stats
+
+        for handler in fp.reprompt_handlers:
+            route = types.Fulfillment(page=fp.page)
+            route.data = handler
+            route.agent_id = fp.page.agent_id
+            route.fulfillment_type = "reprompt_handler"
+            route.parameter = fp.display_name
+            route.trigger = self.get_trigger_info(route)
+            route, stats = self.set_route_targets(route, stats)
+            path = route.data.get("triggerFulfillment", None)
+            event = route.data.get("event", None)
+
+            stats = self.process_intents_in_routes(route, stats)
+
+            if not path and not event:
+                continue
+
+            # Flag for Webhook Handler
+            self.check_for_webhook(fp.page, path)
+
+            stats = self.process_fulfillment_type(
+                stats, route, path, "messages")
+
+        return stats
+
+    def process_events(self, page: types.Page, stats: types.AgentData):
+        """Parse through all Page Event Handlers."""
+        if not page.events:
+            return stats
+
+        for route_data in page.events:
+            route = types.Fulfillment(page=page)
+            route.data = route_data
+            route.agent_id = page.agent_id
+            route.fulfillment_type = "event"
+            route.trigger = self.get_trigger_info(route)
+            route, stats = self.set_route_targets(route, stats)
+            path = route.data.get("triggerFulfillment", None)
+            event = route.data.get("event", None)
+
+            stats = self.process_intents_in_routes(route, stats)
+
+            if not path and not event:
+                continue
+
+            # Flag for Webhook Handler
+            self.check_for_webhook_event_handlers(route)
+
+            stats = self.process_fulfillment_type(
+                stats, route, path, "messages")
+
+        return stats
+
+    def process_routes(self, page: types.Page, stats: types.AgentData):
+        """Parse through all Transition Routes."""
+        tf_key = "triggerFulfillment"
+
+        if not page.routes:
+            return stats
+
+        for route_data in page.routes:
+            route = types.Fulfillment(page=page)
+            route.data = route_data
+            route.agent_id = page.agent_id
+            route.fulfillment_type = "transition_route"
+            route.trigger = self.get_trigger_info(route)
+            route, stats = self.set_route_targets(route, stats)
+
+            stats = self.process_intents_in_routes(route, stats)
+
+            path = route.data.get(tf_key, None)
+
+            if not path:
+                continue
+
+            # Flag for Webhook Handler
+            self.check_for_webhook(page, path)
+
+            stats = self.process_fulfillment_type(
+                stats, route, path, "messages")
+
+            # Preset Params processed here
+            stats = self.process_fulfillment_type(
+                stats, route, path, "setParameterActions"
+            )
+
+        return stats
+
+    def process_entry(self, page: types.Page, stats: types.AgentData):
+        """Process Entry Fulfillment on a single page file.
+
+        The Entry Fulfillment to a Page only has 1 "route" (i.e. itself) so
+        there is no need to loop through multiple routes, as they don't
+        exist for Entry Fulfillment.
+        """
+
+        if not page.entry:
+            return stats
+
+        route = types.Fulfillment(page=page)
+        route.data = page.entry
+        route.agent_id = page.agent_id
+        route.fulfillment_type = "entry"
+        route.trigger = "entry"
+        path = route.data
+
+        self.check_for_webhook(page, path)
+
+        stats = self.process_fulfillment_type(stats, route, path, "messages")
+
+        return stats
diff --git a/src/dfcx_scrapi/agent_extract/types.py b/src/dfcx_scrapi/agent_extract/types.py
new file mode 100644
index 00000000..ac25c34d
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/types.py
@@ -0,0 +1,212 @@
+"""Collection of Type Classes used for offline processing."""
+
+# Copyright 2023 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 Dict, List, Any, Tuple
+from dataclasses import dataclass, field
+
+from dfcx_scrapi.agent_extract import graph as graph_class
+
+@dataclass
+class AgentMetadata:
+    """Used to track the current Agent Metadata attrinbutes."""
+
+    default_language_code: str = None
+    dtmf_settings: bool = False
+    logging_enabled: bool = False
+    speech_adaptation: bool = False
+
+
+@dataclass
+class Flow:
+    """Used to track current Flow Attributes."""
+    agent_id: str = None
+    all_pages: set = field(default_factory=set)
+    active_pages: set = field(default_factory=set)
+    data: Dict[str, Any] = field(default_factory=dict)
+    dangling_pages: set = field(default_factory=set)
+    dir_path: str = None  # Full Directory Path for this Flow
+    display_name: str = None  # Flow Display Name (removed special chars)
+    file_name: str = None  # Original Name of Flow (includes special chars)
+    graph: graph_class.Graph = None
+    resource_id: str = None
+    resource_type: str = "flow"
+    start_page_file: str = None  # File Path Location of START_PAGE
+    unreachable_pages: set = field(default_factory=set)
+    unused_pages: set = field(default_factory=set)
+
+@dataclass
+class Page:
+    """Used to track current Page Attributes."""
+
+    agent_id: str = None
+    data: Dict[str, Any] = None
+    display_name: str = None
+    entry: Dict[str, Any] = None
+    events: List[object] = None
+    flow: Flow = None
+    form: Dict[str, Any] = None
+    has_webhook: bool = False
+    has_webhook_event_handler: bool = False
+    page_file: str = None
+    resource_id: str = None
+    resource_type: str = "page"
+    routes: List[object] = None
+    route_groups: List[str] = None
+
+@dataclass
+class FormParameter:
+    """Tracks Form Paramter attributes within a Page."""
+
+    advanced_settings: str = None
+    agent_id: str = None
+    data: Dict[str, Any] = None
+    display_name: str = None
+    dtmf_settings: str = None
+    entity_type: str = None
+    fill_behavior: Dict[str, Any] = None
+    init_fulfillment: Dict[str, Any] = None
+    page: Page = None
+    reprompt_handlers: Dict[str, Any] = None
+    required: bool = True
+
+
+@dataclass
+class RouteGroup:
+    """Used to track current RouteGroup Attributes."""
+
+    agent_id: str = None
+    data: Dict[str, Any] = None
+    display_name: str = None
+    flow: Flow = None
+    resource_id: str = None
+    resource_type: str = "route_group"
+    rg_file: str = None
+    routes: List[object] = None
+
+@dataclass
+class Fulfillment:
+    """Used to track current Fulfillment Attributes."""
+
+    agent_id: str = None
+    data: Dict[str, Any] = None
+    display_name: str = None  # Inherit from Page easy logging
+    fulfillment_type: str = None  # transition_route | event
+    page: Page = None
+    parameter: str = None # Used for Reprompt Event Handlers
+    target_flow: str = None
+    target_page: str = None
+    text: str = None
+    trigger: str = None
+    resource_type: str = "fulfillment"
+
+@dataclass
+class Intent:
+    """Used to track current Intent Attributes."""
+
+    agent_id: str = None
+    data: Dict[str, Any] = None
+    description: str = None
+    display_name: str = None
+    dir_path: str = None
+    labels: Dict[str, str] = None
+    metadata_file: str = None
+    parameters: List[Dict[str, str]] = field(default_factory=list)
+    resource_id: str = None
+    resource_type: str = "intent"
+    training_phrases: Dict[str, Any] = field(default_factory=dict)
+
+@dataclass
+class EntityType:
+    """Used to track current Flow Attributes."""
+
+    agent_id: str = None
+    auto_expansion: str = None
+    data: Dict[str, Any] = None
+    dir_path: str = None  # Full Directory Path for this Entity Type
+    display_name: str = None  # Entity Type Display Name
+    entities: Dict[str, Any] = field(default_factory=dict)  # Map
+    excluded_phrases: Dict[str, Any] = field(default_factory=dict)  # Map
+    fuzzy_extraction: bool = False
+    kind: str = None  # The kind of Entity Type represented
+    resource_id: str = None
+    resource_type: str = "entity_type"
+
+@dataclass
+class TestCase:
+    """Used to track current Test Case Attributes."""
+
+    associated_intent_data: Dict[str, Any] = None
+    agent_id: str = None
+    agent_path: str = None
+    conversation_turns: List[Any] = None
+    data: Dict[str, Any] = None
+    dir_path: str = None
+    display_name: str = None
+    has_invalid_intent: bool = False
+    intent_data: List[str] = None
+    qualified: bool = False
+    resource_id: str = None
+    resource_type: str = "test_case"
+    tags: List[str] = None
+    test_config: Dict[str, Any] = None
+
+@dataclass
+class Webhook:
+    """Used to track current Webhook attributes."""
+
+    agent_id: str = None
+    agent_path: str = None
+    data: Dict[str, Any] = None
+    dir_path: str = None
+    display_name: str = None
+    resource_id: str = None
+    resource_type: str = "webhook"
+    service_type: str = None
+    timeout: int = 0
+
+@dataclass
+class AgentData:
+    """Used to track agent data for each section processed."""
+    active_intents: Dict[str, List[Tuple[str, str]]] = field(
+        default_factory=dict)
+    active_pages: Dict[str, set] = field(default_factory=dict)
+    agent_id: str = None
+    entity_types: List[Dict[str, Any]] = field(default_factory=list)
+    entity_types_map: Dict[str, Any] = field(default_factory=dict)
+    flow_page_map: Dict[str, Any] = field(default_factory=dict)
+    flows: List[Dict[str, Any]] = field(default_factory=list)
+    flows_map: Dict[str, Any] = field(default_factory=dict)
+    graph: graph_class.Graph = None
+    intents: List[Dict[str, Any]] = field(default_factory=list)
+    intents_map: Dict[str, Any] = field(default_factory=dict)
+    lang_code: str = "en"
+    pages: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict)
+    route_groups: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict)
+    route_groups_map: Dict[str, Any] = field(default_factory=dict)
+    test_cases: List[Dict[str, Any]] = field(default_factory=list)
+    unreachable_pages: Dict[str, set] = field(default_factory=dict)
+    unused_pages: Dict[str, set] = field(default_factory=dict)
+    webhooks: List[Dict[str, Any]] = field(default_factory=list)
+    webhooks_map: Dict[str, Any] = field(default_factory=dict)
+
+    total_flows: int = 0
+    total_pages: int = 0
+    total_intents: int = 0
+    total_training_phrases: int = 0
+    total_entity_types: int = 0
+    total_route_groups: int = 0
+    total_test_cases: int = 0
+    total_webhooks: int = 0
diff --git a/src/dfcx_scrapi/agent_extract/webhooks.py b/src/dfcx_scrapi/agent_extract/webhooks.py
new file mode 100644
index 00000000..38467080
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/webhooks.py
@@ -0,0 +1,95 @@
+"""Webhook processing methods and functions."""
+
+# Copyright 2023 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 json
+import os
+
+from dfcx_scrapi.agent_extract import common
+from dfcx_scrapi.agent_extract import types
+
+class Webhooks:
+    """Webhook linter methods and functions."""
+
+    def __init__(self):
+        self.common = common.Common()
+
+    @staticmethod
+    def build_webhook_path_list(agent_local_path: str):
+        """Builds a list of webhook file locations."""
+        root_dir = agent_local_path + "/webhooks"
+
+        webhook_paths = []
+
+        for webhook_file in os.listdir(root_dir):
+            webhook_file_path = f"{root_dir}/{webhook_file}"
+            webhook_paths.append(webhook_file_path)
+
+        return webhook_paths
+
+    @staticmethod
+    def get_service_type(webhook: types.Webhook) -> str:
+        """Get the type of Webhook Service that is cofigured."""
+        if "genericWebService" in webhook.data:
+            webhook.service_type = "Generic Web Service"
+
+        else:
+            webhook.service_type = "Other"
+
+        return webhook.service_type
+
+    def process_webhook(self, webhook: types.Webhook, stats: types.AgentData
+            ) -> types.AgentData:
+        """Process a single Webhook file."""
+
+        with open(webhook.dir_path, "r", encoding="UTF-8") as webhook_file:
+            webhook.data = json.load(webhook_file)
+            webhook.resource_id = webhook.data.get("name", None)
+            webhook.display_name = webhook.data.get("displayName", None)
+            webhook.service_type = self.get_service_type(webhook)
+
+            timeout_dict = webhook.data.get("timeout", None)
+            if timeout_dict:
+                webhook.timeout = timeout_dict.get("seconds", None)
+
+            webhook_file.close()
+
+        full_webhook_id = f"{stats.agent_id}/webhooks/{webhook.resource_id}"
+        webhook.data["name"] = full_webhook_id
+        stats.webhooks.append(webhook.data)
+        stats.total_webhooks += 1
+
+        return stats
+
+    def process_webhooks_directory(self, agent_local_path: str,
+                                   stats: types.AgentData) -> types.AgentData:
+        """Processing the top level Webhooks Dir in the JSON Package structure.
+
+        The following files exist under the `webhooks` dir:
+        - <webhook-name>.json
+        """
+        # Create a list of all Webhook paths to iter through
+        webhook_paths = self.build_webhook_path_list(agent_local_path)
+
+        for webhook_path in webhook_paths:
+            webhook = types.Webhook()
+            webhook.dir_path = webhook_path
+
+            stats = self.process_webhook(webhook, stats)
+
+            full_webhook_id = f"{stats.agent_id}/webhooks/{webhook.resource_id}"
+            stats.webhooks_map[webhook.display_name] = full_webhook_id
+
+        return stats
diff --git a/src/dfcx_scrapi/builders/agents.py b/src/dfcx_scrapi/builders/agents.py
index acd3ae13..76a9c274 100644
--- a/src/dfcx_scrapi/builders/agents.py
+++ b/src/dfcx_scrapi/builders/agents.py
@@ -1,6 +1,6 @@
 """A set of builder methods to create CX proto resource objects"""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,24 +14,31 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import logging
 from typing import List
 
 from google.cloud.dialogflowcx_v3beta1.types import Agent
 from google.cloud.dialogflowcx_v3beta1.types import SpeechToTextSettings
+from dfcx_scrapi.builders.builders_common import BuildersCommon
 
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
 
-class AgentBuilder:
+
+class AgentBuilder(BuildersCommon):
     """Base Class for CX Agent builder."""
 
-    def __init__(self, obj: Agent = None):
-        self.proto_obj = None
-        if obj:
-            self.load_agent(obj)
+    _proto_type = Agent
+    _proto_type_str = "Agent"
 
 
     def __str__(self) -> str:
         """String representation of the proto_obj."""
-        self._check_agent_exist()
+        self._check_proto_obj_attr_exist()
 
         logs = self.proto_obj.advanced_settings.logging_settings
         speech_2_txt = self.proto_obj.speech_to_text_settings
@@ -54,52 +61,14 @@ def __str__(self) -> str:
             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
+    def show_agent_info(self):
+        """Show the proto_obj information."""
+        self._check_proto_obj_attr_exist()
 
-        return self.proto_obj
+        print(self)
 
 
-    def create_new_agent(
+    def create_new_proto_obj(
         self,
         display_name: str,
         time_zone: str,
@@ -146,7 +115,7 @@ def create_new_agent(
                 "display_name and time_zone should be string."
             )
         if self.proto_obj and not overwrite:
-            raise Exception(
+            raise UserWarning(
                 "proto_obj already contains an Agent."
                 " If you wish to overwrite it, pass overwrite as True."
             )
@@ -185,7 +154,7 @@ def language_and_speech_settings(
         Returns:
           An Agent object stored in proto_obj
         """
-        self._check_agent_exist()
+        self._check_proto_obj_attr_exist()
 
         if isinstance(enable_speech_adaptation, bool):
             self.proto_obj.speech_to_text_settings=SpeechToTextSettings(
@@ -234,7 +203,7 @@ def security_and_logging_settings(
         Returns:
           An Agent object stored in proto_obj
         """
-        self._check_agent_exist()
+        self._check_proto_obj_attr_exist()
 
         logs = self.proto_obj.advanced_settings.logging_settings
         if isinstance(enable_stackdriver_logging, bool):
@@ -247,8 +216,3 @@ def security_and_logging_settings(
         return self.proto_obj
 
 
-    def show_agent_info(self):
-        """Show the proto_obj information."""
-        self._check_agent_exist()
-
-        print(self.__str__())
diff --git a/src/dfcx_scrapi/builders/builders_common.py b/src/dfcx_scrapi/builders/builders_common.py
new file mode 100644
index 00000000..b44e0186
--- /dev/null
+++ b/src/dfcx_scrapi/builders/builders_common.py
@@ -0,0 +1,351 @@
+"""A set of builder methods to create CX proto resource objects"""
+
+# Copyright 2023 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, Union
+
+from google.cloud.dialogflowcx_v3beta1.types import TransitionRoute
+from google.cloud.dialogflowcx_v3beta1.types import EventHandler
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+
+class BuildersCommon:
+    """Base class for other Builder classes"""
+
+    _proto_type = None
+    _proto_type_str = "None"
+
+
+    def __init__(self, obj=None):
+        self.proto_obj = None
+        if obj:
+            self.load_proto_obj(obj)
+
+
+    def _check_proto_obj_attr_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_proto_obj` or `load_proto_obj` to continue."
+            )
+        elif not isinstance(self.proto_obj, self._proto_type):  # pylint: disable=W1116
+            raise ValueError(
+                f"proto_obj is not {self._proto_type_str} type."
+                "\nPlease create or load the correct type to continue."
+            )
+
+
+    def load_proto_obj(self, obj, overwrite: bool = False):
+        """Load an existing object to proto_obj for further uses.
+
+        Args:
+          obj (proto object):
+            An existing proto object.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already
+            contains some object.
+
+        Returns:
+          An object stored in proto_obj
+        """
+        if not isinstance(obj, self._proto_type):  # pylint: disable=W1116
+            raise ValueError(
+                "The object you're trying to load"
+                f" is not {self._proto_type_str}!"
+            )
+        if self.proto_obj and not overwrite:
+            raise UserWarning(
+                f"proto_obj already contains {self._proto_type_str}."
+                " 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 _is_type_or_list_of_types(self, obj, type_, var_name: str = None):
+        """Check whether the `obj` type is `type_` or
+        is a list with elements of `type_` otherwise raise an error.
+
+        Args:
+            obj:
+              The object to check
+            type_:
+              Type of `obj`
+            var_name (str):
+              The variable name to show in the error message.
+
+        Raises:
+            ValueError: If the `obj` type is not `type_` or a list of `type_`s.
+        """
+        default_error_msg = "Incorrect type!!"
+        error_msg_map = {
+            str: (
+                f"`{var_name}` should be either a string or a list of strings."
+            ),
+            EventHandler: (
+                f"`{var_name}` should be either a EventHandler"
+                " or a list of EventHandlers."
+            ),
+            TransitionRoute: (
+                f"`{var_name}` should be either a TransitionRoute"
+                " or a list of TransitionRoutes."
+            ),
+        }
+
+        if not(
+            isinstance(obj, type_) or
+            (isinstance(obj, list) and
+             all(isinstance(item, type_) for item in obj))
+        ):
+            msg = error_msg_map.get(obj, default_error_msg)
+            raise ValueError(msg)
+
+
+    def _match_transition_route(
+        self,
+        transition_route: TransitionRoute,
+        target_route: TransitionRoute = None,
+        intent: str = None,
+        condition: str = None
+    ) -> bool:
+        """Check if transition_route's intent and condition
+        matches with the input.
+
+        At least one of the `target_route`, `intent`, or `condition` should
+        be specfied.
+
+        Args:
+            transition_route (TransitionRoute):
+              The TransitionRoute that input should match with.
+            taget_route (TransitionRoute):
+              The target TransitionRoute that we want to match.
+            intent (str):
+              TransitionRoute's intent that we want to match.
+            condition (str):
+              TransitionRoute's condition that we want to match.
+
+        Returns:
+            True if the `transition_route` matched with the input.
+        """
+        # Type/Error checking
+        if not isinstance(transition_route, TransitionRoute):
+            raise ValueError(
+                "`transition_route` should have the type TransitionRoute."
+            )
+        if not(target_route or intent or condition):
+            raise ValueError(
+                "At least one of `target_route`, `intent`, or `condition`"
+                " must be specified."
+            )
+
+        # Check if the transition route matches
+        is_match = False
+        if target_route:
+            is_match = self._check_transition_route_with_target_route(
+                transition_route, target_route
+            )
+        if intent and condition:
+            is_match = self._check_transition_route_with_intent_and_condition(
+                transition_route, intent, condition
+            )
+        elif intent and not condition:
+            is_match = self._check_transition_route_with_intent(
+                transition_route, intent
+            )
+        elif not intent and condition:
+            is_match = self._check_transition_route_with_condition(
+                transition_route, condition
+            )
+
+        return is_match
+
+
+    def _check_transition_route_with_target_route(
+        self,
+        transition_route: TransitionRoute,
+        target_route: TransitionRoute
+    ) -> bool:
+        """Check if transition_route's intent and condition
+        matches with the target_route's intent and condition.
+
+        Args:
+            transition_route (TransitionRoute):
+              The TransitionRoute that input should match with.
+            taget_route (TransitionRoute):
+              The target TransitionRoute that we want to match.
+
+        Returns:
+            True if the `transition_route` matched with the input.
+        """
+        # Type/Error checking
+        if not isinstance(target_route, TransitionRoute):
+            raise ValueError("`target_route` should be a TransitionRoute.")
+
+        if (
+            transition_route.condition == target_route.condition and
+            transition_route.intent == target_route.intent
+        ):
+            return True
+        return False
+
+
+    def _check_transition_route_with_intent_and_condition(
+        self,
+        transition_route: TransitionRoute,
+        intent: str,
+        condition: str
+    ) -> bool:
+        """Check if transition_route's intent and condition
+        matches with the input.
+
+        Args:
+            transition_route (TransitionRoute):
+              The TransitionRoute that input should match with.
+            intent (str):
+              TransitionRoute's intent that we want to match.
+            condition (str):
+              TransitionRoute's condition that we want to match.
+
+        Returns:
+            True if the `transition_route` matched with the input.
+        """
+        # Type/Error checking
+        if not(isinstance(intent, str) and isinstance(condition, str)):
+            raise ValueError("`intent` and `condition` should be a string.")
+
+        if (
+            transition_route.condition == condition and
+            transition_route.intent == intent
+        ):
+            return True
+        return False
+
+
+    def _check_transition_route_with_intent(
+        self,
+        transition_route: TransitionRoute,
+        intent: str
+    ) -> bool:
+        """Check if transition_route's intent matches with the input.
+
+        Args:
+            transition_route (TransitionRoute):
+              The TransitionRoute that input should match with.
+            intent (str):
+              TransitionRoute's intent that we want to match.
+
+        Returns:
+            True if the `transition_route` matched with the input.
+        """
+        # Type/Error checking
+        if not isinstance(intent, str):
+            raise ValueError("`intent` should be a string.")
+
+        if transition_route.intent == intent:
+            return True
+        return False
+
+
+    def _check_transition_route_with_condition(
+        self,
+        transition_route: TransitionRoute,
+        condition: str
+    ) -> bool:
+        """Check if transition_route's condition matches with the input.
+
+        Args:
+            transition_route (TransitionRoute):
+              The TransitionRoute that input should match with.
+            condition (str):
+              TransitionRoute's condition that we want to match.
+
+        Returns:
+            True if the `transition_route` matched with the input.
+        """
+        # Type/Error checking
+        if not isinstance(condition, str):
+            raise ValueError("`condition` should be a string.")
+
+        if transition_route.condition == condition:
+            return True
+        return False
+
+
+    def _find_unmatched_event_handlers(
+        self, event_handlers: Union[EventHandler, List[EventHandler]]
+    ) -> List[EventHandler]:
+        """Find the EventHandlers of proto_obj which is not present
+        in the `event_handlers`
+
+        Args:
+          event_handlers (EventHandler | List[EventHandler]):
+            A single or list of EventHandler to remove
+              from the existing EventHandlers in proto_obj.
+
+        Returns:
+          A list of EventHandlers
+        """
+        # Type error checking
+        self._is_type_or_list_of_types(
+            event_handlers, EventHandler, "event_handlers"
+        )
+
+        if not isinstance(event_handlers, list):
+            event_handlers = [event_handlers]
+
+        return [
+            eh
+            for eh in self.proto_obj.event_handlers
+            if eh not in event_handlers
+        ]
+
+
+    def _find_unmatched_event_handlers_by_name(
+        self, event_names: Union[str, List[str]]
+    ) -> List[EventHandler]:
+        """Find the EventHandlers of proto_obj which their event names
+        is not present in the `event_names`
+
+        Args:
+          event_names (str | List[str]):
+            A single or list of EventHandler's event names corresponding
+              to the EventHandler to remove from the existing
+              EventHandlers in proto_obj.
+
+        Returns:
+          A list of EventHandlers
+        """
+        # Type error checking
+        self._is_type_or_list_of_types(event_names, str, "event_names")
+
+        if not isinstance(event_names, list):
+            event_names = [event_names]
+
+        return [
+            eh
+            for eh in self.proto_obj.event_handlers
+            if eh.event not in event_names
+        ]
diff --git a/src/dfcx_scrapi/builders/entity_types.py b/src/dfcx_scrapi/builders/entity_types.py
index 618c2214..f28ef6b2 100644
--- a/src/dfcx_scrapi/builders/entity_types.py
+++ b/src/dfcx_scrapi/builders/entity_types.py
@@ -1,6 +1,6 @@
 """A set of builder methods to create CX proto resource objects"""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,23 +14,30 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import logging
 from typing import List, Union
 
 from google.cloud.dialogflowcx_v3beta1.types import EntityType
+from dfcx_scrapi.builders.builders_common import BuildersCommon
 
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
 
-class EntityTypeBuilder:
+
+class EntityTypeBuilder(BuildersCommon):
     """Base Class for CX EntityType builder."""
 
-    def __init__(self, obj: EntityType = None):
-        self.proto_obj = None
-        if obj:
-            self.load_entity_type(obj)
+    _proto_type = EntityType
+    _proto_type_str = "EntityType"
 
 
     def __str__(self) -> str:
         """String representation of the proto_obj."""
-        self._check_entity_type_exist()
+        self._check_proto_obj_attr_exist()
 
         return (
             f"{self._show_entity_type_basic_info()}"
@@ -38,23 +45,9 @@ def __str__(self) -> str:
             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()
+        self._check_proto_obj_attr_exist()
 
         return (
             f"display_name: {self.proto_obj.display_name}"
@@ -64,10 +57,9 @@ def _show_entity_type_basic_info(self) -> str:
             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()
+        self._check_proto_obj_attr_exist()
 
         excluded_phrases = "\n\t".join([
             phrase.value
@@ -76,10 +68,9 @@ def _show_excluded_phrases(self) -> str:
 
         return f"excluded phrases:\n\t{excluded_phrases}"
 
-
     def _show_entities(self) -> str:
         """Shows the entities of proto_obj."""
-        self._check_entity_type_exist()
+        self._check_proto_obj_attr_exist()
 
         entities =  "\n".join([
             f"  {entity.value}:\n\t{', '.join(entity.synonyms)}"
@@ -98,7 +89,7 @@ def show_entity_type(self, mode: str = "whole"):
             Options:
               ['basic', 'entities', 'excluded' or 'excluded phrases', 'whole']
         """
-        self._check_entity_type_exist()
+        self._check_proto_obj_attr_exist()
 
         if mode == "basic":
             print(self._show_entity_type_basic_info())
@@ -107,7 +98,7 @@ def show_entity_type(self, mode: str = "whole"):
         elif mode in ["excluded", "excluded phrases"]:
             print(self._show_excluded_phrases())
         elif mode == "whole":
-            print(self.__str__())
+            print(self)
         else:
             raise ValueError(
                 "mode should be in"
@@ -116,38 +107,7 @@ def show_entity_type(self, mode: str = "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(
+    def create_new_proto_obj(
         self,
         display_name: str,
         kind: int,
@@ -197,7 +157,7 @@ def create_new_entity_type(
                 "kind should be an int between 0 and 3."
             )
         if self.proto_obj and not overwrite:
-            raise Exception(
+            raise UserWarning(
                 "proto_obj already contains an EntityType."
                 " If you wish to overwrite it, pass overwrite as True."
             )
@@ -226,7 +186,7 @@ def add_excluded_phrase(
         Returns:
           An EntityType object stored in proto_obj
         """
-        self._check_entity_type_exist()
+        self._check_proto_obj_attr_exist()
 
         if isinstance(phrase, str):
             self.proto_obj.excluded_phrases.append(
@@ -263,7 +223,7 @@ def remove_excluded_phrase(
         Returns:
           An EntityType object stored in proto_obj
         """
-        self._check_entity_type_exist()
+        self._check_proto_obj_attr_exist()
 
         excl_phrases = {
             excl_phrase.value
@@ -312,7 +272,7 @@ def add_entity(
         Returns:
           An EntityType object stored in proto_obj
         """
-        self._check_entity_type_exist()
+        self._check_proto_obj_attr_exist()
 
         if not isinstance(value, str):
             raise ValueError(
@@ -340,7 +300,7 @@ def add_entity(
                 )
             )
         else:
-            raise Exception(
+            raise ValueError(
                 "Entity type's kind is not correct and should be specified."
             )
 
@@ -371,7 +331,7 @@ def remove_entity(
         Returns:
           An EntityType object stored in proto_obj
         """
-        self._check_entity_type_exist()
+        self._check_proto_obj_attr_exist()
 
         if not isinstance(value, str):
             raise ValueError(
diff --git a/src/dfcx_scrapi/builders/flows.py b/src/dfcx_scrapi/builders/flows.py
new file mode 100644
index 00000000..2f7c702f
--- /dev/null
+++ b/src/dfcx_scrapi/builders/flows.py
@@ -0,0 +1,589 @@
+"""A set of builder methods to create CX proto resource objects"""
+
+# Copyright 2023 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 dataclasses import dataclass
+from typing import List, Union
+
+from google.cloud.dialogflowcx_v3beta1.types import Flow
+from google.cloud.dialogflowcx_v3beta1.types import NluSettings
+from google.cloud.dialogflowcx_v3beta1.types import TransitionRoute
+from google.cloud.dialogflowcx_v3beta1.types import EventHandler
+from dfcx_scrapi.builders.builders_common import BuildersCommon
+from dfcx_scrapi.builders.routes import TransitionRouteBuilder
+from dfcx_scrapi.builders.routes import EventHandlerBuilder
+from dfcx_scrapi.builders.fulfillments import FulfillmentBuilder
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+
+class FlowBuilder(BuildersCommon):
+    """Base Class for CX Flow builder."""
+
+    _proto_type = Flow
+    _proto_type_str = "Flow"
+
+
+    def __str__(self) -> str:
+        """String representation of the proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        return (
+            f"Basic Information:\n{'='*25}\n{self._show_basic_info()}"
+            f"\n\n\nTransitionRoutes:\n{'='*25}"
+            f"\n{self._show_transition_routes()}"
+            f"\n\n\nEventHandlers:\n{'='*25}\n{self._show_event_handlers()}"
+            f"\n\n\nTransitoinRouteGroups:\n{'='*25}"
+            f"\n{self._show_transition_route_groups()}")
+
+
+    def _show_basic_info(self) -> str:
+        """String representation for the basic information of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        nlu_settings_str = (
+            f"\tModel type: {self.proto_obj.nlu_settings.model_type.name}"
+            "\n\tClassification threshold:"
+            f" {self.proto_obj.nlu_settings.classification_threshold}"
+            "\n\tTraining mode:"
+            f" {self.proto_obj.nlu_settings.model_training_mode.name}"
+        )
+
+        return (
+            f"display_name: {self.proto_obj.display_name}"
+            f"\ndescription:\n\t{self.proto_obj.description}"
+            f"\nNLU settings:\n{nlu_settings_str}"
+        )
+
+
+    def _show_transition_routes(self) -> str:
+        """String representation for the transition routes of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        return "\n".join([
+            f"TransitionRoute {i+1}:\n{str(TransitionRouteBuilder(tr))}"
+            f"\n{'*'*20}\n"
+            for i, tr in enumerate(self.proto_obj.transition_routes)
+        ])
+
+
+    def _show_event_handlers(self) -> str:
+        """String representation for the event handlers of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        return "\n".join([
+            f"EventHandler {i+1}:\n{str(EventHandlerBuilder(eh))}\n{'*'*20}\n"
+            for i, eh in enumerate(self.proto_obj.event_handlers)
+        ])
+
+
+    def _show_transition_route_groups(self) -> str:
+        """String representation for the transition route groups of proto_obj"""
+        self._check_proto_obj_attr_exist()
+
+        return "\n".join([
+            f"TransitionRouteGroup {i+1}: {trg_id}"
+            for i, trg_id in enumerate(self.proto_obj.transition_route_groups)
+        ])
+
+
+    def show_flow_info(
+        self, mode: str = "whole"
+    ) -> None:
+        """Show the proto_obj information.
+
+        Args:
+          mode (str):
+            Specifies what part of the page to show.
+              Options:
+              ['basic', 'whole',
+              'routes' or 'transition routes',
+              'route groups' or 'transition route groups',
+              'events' or 'event handlers'
+              ]
+        """
+        self._check_proto_obj_attr_exist()
+
+        if mode == "basic":
+            print(self._show_basic_info())
+        elif mode in ["routes", "transition routes"]:
+            print(self._show_transition_routes())
+        elif mode in ["route groups", "transition route groups"]:
+            print(self._show_transition_route_groups())
+        elif mode in ["events", "event handlers"]:
+            print(self._show_event_handlers())
+        elif mode == "whole":
+            print(self)
+        else:
+            raise ValueError(
+                "mode should be in"
+                "['basic', 'whole',"
+                " 'routes', 'transition routes',"
+                " 'route groups', 'transition route groups',"
+                " 'events', 'event handlers']"
+            )
+
+
+    def show_stats(self) -> None:
+        """Provide some stats about the Page."""
+        self._check_proto_obj_attr_exist()
+
+        stats_instance = FlowStats(self.proto_obj)
+        stats_instance.generate_stats()
+
+
+    def create_new_proto_obj(
+        self,
+        display_name: str,
+        description: str = None,
+        overwrite: bool = False
+    ) -> Flow:
+        """Create a new Flow.
+
+        Args:
+          display_name (str):
+            Required. The human-readable name of the flow.
+          description (str):
+            The description of the flow. The maximum length is 500 characters.
+            If exceeded, the request is rejected.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already contains a Flow.
+
+        Returns:
+          A Flow object stored in proto_obj.
+        """
+        # Types error checking
+        if not(display_name and isinstance(display_name, str)):
+            raise ValueError("`display_name` should be a nonempty string.")
+        if (
+            description
+            and not isinstance(description, str)
+            and len(description) > 500
+        ):
+            raise ValueError(
+                "`description` should be a string and"
+                " it's length should be less than 500 characters."
+            )
+        # `overwrite` parameter error checking
+        if self.proto_obj and not overwrite:
+            raise UserWarning(
+                "proto_obj already contains a Flow."
+                " If you wish to overwrite it, pass `overwrite` as True."
+            )
+        # Create the Flow
+        if overwrite or not self.proto_obj:
+            self.proto_obj = Flow(
+                display_name=display_name,
+                description=description
+            )
+        # Set the NLU settings to default
+        self.nlu_settings()
+
+        return self.proto_obj
+
+
+    def nlu_settings(
+        self,
+        model_type: int = 1,
+        classification_threshold: float = 0.3,
+        model_training_mode: int = 1
+    ) -> Flow:
+        """NLU related settings of the flow.
+
+        Args:
+          model_type (int):
+            Indicates the type of NLU model:
+              1 = MODEL_TYPE_STANDARD, 3 = MODEL_TYPE_ADVANCED
+          classification_threshold (float):
+            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 (int):
+            Indicates NLU model training mode:
+              1 = MODEL_TRAINING_MODE_AUTOMATIC
+              2 = MODEL_TRAINING_MODE_MANUAL
+
+        Returns:
+          A Flow object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Type error checking
+        if model_type not in [1, 3]:
+            raise ValueError(
+                "`model_type` should be in [1, 3]."
+                "\n1: MODEL_TYPE_STANDARD"
+                "\n3: MODEL_TYPE_ADVANCED"
+            )
+        if model_training_mode not in [1, 2]:
+            raise ValueError(
+                "`model_training_mode` should be in [1, 2]."
+                "\n1: MODEL_TRAINING_MODE_AUTOMATIC"
+                "\n2: MODEL_TRAINING_MODE_MANUAL"
+            )
+        if not(
+            isinstance(classification_threshold, float)
+            and (0 < classification_threshold < 1)
+        ):
+            raise ValueError(
+                "`classification_threshold` should be a float"
+                " range from 0.0 to 1.0."
+            )
+
+        the_nlu_settings = NluSettings(
+            model_type=model_type,
+            model_training_mode=model_training_mode,
+            classification_threshold=classification_threshold
+        )
+        self.proto_obj.nlu_settings = the_nlu_settings
+
+        return self.proto_obj
+
+
+    def add_transition_route(
+        self,
+        transition_routes: Union[TransitionRoute, List[TransitionRoute]]
+    ) -> Flow:
+        """Add single or multiple TransitionRoutes to the Flow.
+
+        Args:
+          transition_routes (TransitionRoute | List[TransitionRoute]):
+            A single or list of TransitionRoutes to add
+            to the Flow existing in proto_obj.
+
+        Returns:
+          A Flow object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Type error checking
+        self._is_type_or_list_of_types(
+            transition_routes, TransitionRoute, "transition_routes"
+        )
+
+        if not isinstance(transition_routes, list):
+            transition_routes = [transition_routes]
+        self.proto_obj.transition_routes.extend(transition_routes)
+
+        return self.proto_obj
+
+
+    def add_event_handler(
+        self,
+        event_handlers: Union[EventHandler, List[EventHandler]]
+    ) -> Flow:
+        """Add single or multiple EventHandlers to the Flow.
+
+        Args:
+          event_handlers (EventHandler | List[EventHandler]):
+            A single or list of EventHandler to add
+            to the Flow existing in proto_obj.
+
+        Returns:
+          A Flow object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Type error checking
+        self._is_type_or_list_of_types(
+            event_handlers, EventHandler, "event_handlers"
+        )
+
+        if not isinstance(event_handlers, list):
+            event_handlers = [event_handlers]
+        self.proto_obj.event_handlers.extend(event_handlers)
+
+        return self.proto_obj
+
+
+    def add_transition_route_group(
+        self,
+        transition_route_groups: Union[str, List[str]]
+    ) -> Flow:
+        """Add single or multiple TransitionRouteGroups to the Flow.
+
+        Args:
+          transition_route_groups (str | List[str]):
+            A single or list of TransitionRouteGroup's id to add
+            to the Flow existed in proto_obj. Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              flows/<Flow ID>/transitionRouteGroups/<TransitionRouteGroup ID>``.
+
+        Returns:
+          A Flow object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Type error checking
+        self._is_type_or_list_of_types(
+            transition_route_groups, str, "transition_route_groups"
+        )
+
+        if not isinstance(transition_route_groups, list):
+            transition_route_groups = [transition_route_groups]
+        self.proto_obj.transition_route_groups.extend(transition_route_groups)
+
+        return self.proto_obj
+
+
+    def remove_transition_route(
+        self,
+        transition_route: TransitionRoute = None,
+        intent: str = None,
+        condition: str = None
+    ) -> Flow:
+        """Remove a transition route from the Flow.
+
+        At least one of the `transition_route`, `intent`, or `condition` should
+        be specfied.
+
+        Args:
+            transition_route (TransitionRoute):
+              The TransitionRoute to remove from the Flow.
+            intent (str):
+              TransitionRoute's intent that should be removed from the Flow.
+            condition (str):
+              TransitionRoute's condition that should be removed from the Flow.
+
+        Returns:
+          A Flow object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        new_routes = []
+        for tr in self.proto_obj.transition_routes:
+            if self._match_transition_route(
+                transition_route=tr, target_route=transition_route,
+                intent=intent, condition=condition
+            ):
+                continue
+            new_routes.append(tr)
+        self.proto_obj.transition_routes = new_routes
+
+        return self.proto_obj
+
+
+    def remove_event_handler(
+        self,
+        event_handlers: Union[EventHandler, List[EventHandler]] = None,
+        event_names: Union[str, List[str]] = None
+    ) -> Flow:
+        """Remove single or multiple EventHandlers from the Flow.
+
+        Args:
+          event_handlers (EventHandler | List[EventHandler]):
+            A single or list of EventHandler to remove
+              from the Flow existing in proto_obj.
+            Only one of the `event_handlers` and
+              `event_names` should be specified.
+          event_names (str | List[str]):
+            A single or list of EventHandler's event names corresponding to the
+              EventHandler to remove from the Flow existing in proto_obj.
+            Only one of the `event_handlers` and
+              `event_names` should be specified.
+
+        Returns:
+          A Flow object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        if event_handlers and event_names:
+            raise UserWarning(
+                "Only one of the `event_handlers` and "
+                "`event_names` should be specified."
+            )
+        if event_handlers:
+            new_ehs = self._find_unmatched_event_handlers(event_handlers)
+        elif event_names:
+            new_ehs = self._find_unmatched_event_handlers_by_name(event_names)
+        else:
+            raise UserWarning(
+                "At least one of the `event_handlers` and "
+                "`event_names` should be specified."
+            )
+
+        self.proto_obj.event_handlers = new_ehs
+
+        return self.proto_obj
+
+
+    def remove_transition_route_group(
+        self,
+        transition_route_groups: Union[str, List[str]]
+    ) -> Flow:
+        """Remove single or multiple TransitionRouteGroups from the Flow.
+
+        Args:
+          transition_route_groups (str | List[str]):
+            A single or list of TransitionRouteGroup's id to remove
+            from the Flow existing in proto_obj. Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              flows/<Flow ID>/transitionRouteGroups/<TransitionRouteGroup ID>``.
+
+        Returns:
+          A Flow object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Type error checking
+        self._is_type_or_list_of_types(
+            transition_route_groups, str, "transition_route_groups"
+        )
+
+        if not isinstance(transition_route_groups, list):
+            transition_route_groups = [transition_route_groups]
+
+        new_trgs = [
+            trg
+            for trg in self.proto_obj.transition_route_groups
+            if trg not in transition_route_groups
+        ]
+        self.proto_obj.transition_route_groups = new_trgs
+
+        return self.proto_obj
+
+
+
+@dataclass
+class FlowStats():
+    """A class for tracking the stats of CX Flow object."""
+    flow_proto_obj: Flow
+
+    # Transition Routes
+    transition_routes_count: int = 0
+    routes_with_fulfill_count: int = 0
+    routes_with_webhook_fulfill_count: int = 0
+    intent_routes_count: int = 0
+    cond_routes_count: int = 0
+    intent_and_cond_routes_count: int = 0
+
+    # Event Handlers
+    event_handlers_count: int = 0
+    events_with_fulfill_count: int = 0
+    events_with_webhook_fulfill_count: int = 0
+
+    # Transition Route Groups
+    transition_route_groups_count: int = 0
+
+
+    def calc_transition_route_stats(self):
+        """Calculating TransitionRoute related stats"""
+        self.transition_routes_count = len(
+            self.flow_proto_obj.transition_routes
+        )
+        for tr in self.flow_proto_obj.transition_routes:
+            if tr.trigger_fulfillment:
+                self.routes_with_fulfill_count += 1
+                fb = FulfillmentBuilder(tr.trigger_fulfillment)
+                if fb.has_webhook():
+                    self.routes_with_webhook_fulfill_count += 1
+            if tr.intent and tr.condition:
+                self.intent_and_cond_routes_count += 1
+            elif tr.intent and not tr.condition:
+                self.intent_routes_count += 1
+            elif not tr.intent and tr.condition:
+                self.cond_routes_count += 1
+
+    def create_transition_route_str(self) -> str:
+        """String representation of TransitionRoutes stats."""
+        transition_routes_str = (
+            f"# of Transition Routes: {self.transition_routes_count}"
+        )
+        routes_with_fulfill_str = (
+            f"# of routes with fulfillment: {self.routes_with_fulfill_count}"
+        )
+        routes_with_webhook_fulfill_str = (
+            "# of routes uses webhook for fulfillment:"
+            f" {self.routes_with_webhook_fulfill_count}"
+        )
+        intent_routes_str = f"# of intent routes: {self.intent_routes_count}"
+        cond_routes_str = f"# of condition routes: {self.cond_routes_count}"
+        intent_and_cond_routes_str = (
+            "# of intent and condition routes:"
+            f" {self.intent_and_cond_routes_count}"
+        )
+
+        return (
+            f"{transition_routes_str}\n\t{intent_routes_str}"
+            f"\n\t{cond_routes_str}\n\t{intent_and_cond_routes_str}"
+            f"\n\t{routes_with_fulfill_str}"
+            f"\n\t{routes_with_webhook_fulfill_str}"
+        )
+
+
+    def calc_event_handler_stats(self):
+        """Calculating EventHandler related stats."""
+        self.event_handlers_count = len(self.flow_proto_obj.event_handlers)
+        for eh in self.flow_proto_obj.event_handlers:
+            fb = FulfillmentBuilder(eh.trigger_fulfillment)
+            if fb.has_webhook():
+                self.events_with_webhook_fulfill_count += 1
+            if eh.trigger_fulfillment:
+                self.events_with_fulfill_count += 1
+
+    def create_event_handler_str(self) -> str:
+        """String representation of EventHandlers stats."""
+        event_handlers_str = f"# of Event Handlers: {self.event_handlers_count}"
+        events_with_fulfill_str = (
+            "# of Event Handlers with fulfillment:"
+            f" {self.events_with_fulfill_count}"
+        )
+        events_with_webhook_fulfill_str = (
+            "# of Event Handlers uses webhook for fulfillment:"
+            f" {self.events_with_webhook_fulfill_count}"
+        )
+
+        return (
+            f"{event_handlers_str}\n\t{events_with_fulfill_str}"
+            f"\n\t{events_with_webhook_fulfill_str}"
+        )
+
+
+    def create_transition_route_group_str(self) -> str:
+        """String representation of TransitionRouteGroup stats."""
+        self.transition_route_groups_count = len(
+            self.flow_proto_obj.transition_route_groups
+        )
+        return (
+            "# of Transition Route Groups:"
+            f" {self.transition_route_groups_count}"
+        )
+
+
+    def generate_stats(self):
+        """Generate stats for the Flow."""
+        self.calc_transition_route_stats()
+        self.calc_event_handler_stats()
+
+        routes_stats_str = self.create_transition_route_str()
+        events_stats_str = self.create_event_handler_str()
+        route_groups_stats_str = self.create_transition_route_group_str()
+
+        out = (
+            f"{routes_stats_str}\n{events_stats_str}\n{route_groups_stats_str}"
+        )
+        print(out)
diff --git a/src/dfcx_scrapi/builders/fulfillments.py b/src/dfcx_scrapi/builders/fulfillments.py
new file mode 100644
index 00000000..f3dea89f
--- /dev/null
+++ b/src/dfcx_scrapi/builders/fulfillments.py
@@ -0,0 +1,305 @@
+"""A set of builder methods to create CX proto resource objects"""
+
+# Copyright 2023 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
+
+from google.cloud.dialogflowcx_v3beta1.types import Fulfillment
+from google.cloud.dialogflowcx_v3beta1.types import ResponseMessage
+from dfcx_scrapi.builders.builders_common import BuildersCommon
+from dfcx_scrapi.builders.response_messages import ResponseMessageBuilder
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+
+class FulfillmentBuilder(BuildersCommon):
+    """Base Class for CX Fulfillment builder."""
+    # TODO: ConditionalCases: def add_conditional_case(self) -> Fulfillment:
+
+    _proto_type = Fulfillment
+    _proto_type_str = "Fulfillment"
+
+
+    def __str__(self) -> str:
+        """String representation of the proto_obj."""
+        try:
+            self._check_proto_obj_attr_exist()
+        except ValueError:
+            return ""
+
+        basic_info_str = self._show_basic_info()
+        resp_msgs_str = self._show_response_messages()
+        params_str = self._show_parameters()
+
+        return (
+            f"Fulfillment Basic Information:\n{'-'*20}\n{basic_info_str}"
+            f"\n\n\nFulfillment ResponseMessages:\n{'-'*20}\n{resp_msgs_str}"
+            f"\n\n\nFulfillment Parameters:\n{'-'*20}\n{params_str}"
+        )
+
+
+    def _show_basic_info(self) -> str:
+        """String representation for the basic information of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        partial_resp = self.proto_obj.return_partial_responses
+        return (
+            f"webhook: {self.proto_obj.webhook}"
+            f"\ntag: {self.proto_obj.tag}"
+            f"\nreturn_partial_responses: {partial_resp}"
+        )
+
+
+    def _show_parameters(self) -> str:
+        """String representation for the parameters presets of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        return "\n".join([
+            f"{param.parameter}: {param.value if param.value else 'null'}"
+            for param in self.proto_obj.set_parameter_actions
+        ])
+
+
+    def _show_response_messages(self) -> str:
+        """String representation of response messages in proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        return "\n".join([
+            f"ResponseMessage {i+1}:\n{str(ResponseMessageBuilder(msg))}"
+            for i, msg in enumerate(self.proto_obj.messages)
+        ])
+
+
+    def show_fulfillment(self, mode: str = "whole"):
+        """Show the proto_obj information.
+        Args:
+          mode (str):
+            Specifies what part of the fulfillment to show.
+            Options:
+              ['basic', 'parameters',
+                'messages' or 'response messages', 'whole']
+        """
+        self._check_proto_obj_attr_exist()
+
+        if mode == "basic":
+            print(self._show_basic_info())
+        elif mode == "parameters":
+            print(self._show_parameters())
+        elif mode in ["messages", "response messages"]:
+            print(self._show_response_messages())
+        elif mode == "whole":
+            print(self)
+        else:
+            raise ValueError(
+                "mode should be in"
+                "['basic', 'parameters',"
+                " 'messages' or 'response messages', 'whole']"
+            )
+
+
+    def create_new_proto_obj(
+        self,
+        webhook: str = None,
+        tag: str = None,
+        return_partial_responses: bool = False,
+        overwrite: bool = False
+    ) -> Fulfillment:
+        """Create a new Fulfillment.
+
+        Args:
+          webhook (str):
+            The webhook to call. Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents
+              /<Agent ID>/webhooks/<Webhook ID>``.
+          tag (str):
+            The tag is typically used by
+            the webhook service to identify which fulfillment is being
+            called, but it could be used for other purposes. This field
+            is required if ``webhook`` is specified.
+          return_partial_responses (bool):
+            Whether Dialogflow should return currently
+            queued fulfillment response messages in
+            streaming APIs. If a webhook is specified, it
+            happens before Dialogflow invokes webhook.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already
+            contains a Fulfillment.
+
+        Returns:
+            A Fulfillment object stored in proto_obj.
+        """
+        # Types error checking
+        if (return_partial_responses and
+            not isinstance(return_partial_responses, bool)):
+            raise ValueError(
+                "return_partial_responses should be bool."
+            )
+        if ((webhook and not isinstance(webhook, str)) or
+            (tag and not isinstance(tag, str))):
+            raise ValueError(
+                "webhook and tag should be string."
+            )
+        # webhook with tag presence check
+        if webhook and not tag:
+            raise ValueError(
+                "tag is required when webhook is specified."
+            )
+        # `overwrite` parameter error checking
+        if self.proto_obj and not overwrite:
+            raise UserWarning(
+                "proto_obj already contains a Fulfillment."
+                " If you wish to overwrite it, pass overwrite as True."
+            )
+        # Create the fulfillment
+        if overwrite or not self.proto_obj:
+            self.proto_obj = Fulfillment(
+                webhook=webhook,
+                return_partial_responses=return_partial_responses,
+                tag=tag
+            )
+
+        return self.proto_obj
+
+
+    def add_response_message(
+        self,
+        response_message: ResponseMessage
+    ) -> Fulfillment:
+        """Add a rich message response to present to the user.
+
+        Args:
+          response_message (ResponseMessage):
+            The ResponseMessage to add to the Fulfillment.
+            Refer to `builders.response_message.ResponseMessageBuilder`
+              to build one.
+
+        Returns:
+          A Fulfillment object stored in proto_obj
+        """
+        self._check_proto_obj_attr_exist()
+
+        if not isinstance(response_message, ResponseMessage):
+            raise ValueError(
+                "`response_message` type should be ResponseMessage."
+            )
+
+        self.proto_obj.messages.append(response_message)
+
+        return self.proto_obj
+
+
+    def add_parameter_presets(
+        self,
+        parameter_map: Dict[str, str]
+    ) -> Fulfillment:
+        """Set parameter values before executing the webhook.
+
+        Args:
+          parameter_map (Dict[str, str]):
+            A dictionary that represents parameters as keys
+            and the parameter values as it's values.
+            A `None` value clears the parameter.
+
+        Returns:
+          A Fulfillment object stored in proto_obj
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Type error checking
+        if isinstance(parameter_map, dict):
+            if not all((
+                isinstance(key, str) and isinstance(val, str)
+                for key, val in parameter_map.items()
+            )):
+                raise ValueError(
+                    "Only strings are allowed as"
+                    " dictionary keys and values in parameter_map."
+                )
+            for param, val in parameter_map.items():
+                self.proto_obj.set_parameter_actions.append(
+                    Fulfillment.SetParameterAction(parameter=param, value=val)
+                )
+
+            return self.proto_obj
+        else:
+            raise ValueError(
+                "parameter_map should be a dictionary."
+            )
+
+
+    def remove_parameter_presets(
+        self,
+        parameter_map: Dict[str, str]
+    ) -> Fulfillment:
+        """Remove parameter values from the fulfillment.
+
+        Args:
+          parameter_map (Dict[str, str]):
+            A dictionary that represents parameters as keys
+            and the parameter values as it's values.
+            A `None` value clears the parameter.
+
+        Returns:
+          A Fulfillment object stored in proto_obj
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Type error checking
+        if isinstance(parameter_map, dict):
+            if not all((
+                isinstance(key, str) and isinstance(val, str)
+                for key, val in parameter_map.items()
+            )):
+                raise ValueError(
+                    "Only strings are allowed as"
+                    " dictionary keys and values in parameter_map."
+                )
+
+            new_params = []
+            for param in self.proto_obj.set_parameter_actions:
+                if (
+                    param.parameter in parameter_map and
+                    param.value == parameter_map[param.parameter]
+                ):
+                    continue
+
+                new_params.append(param)
+
+            self.proto_obj.set_parameter_actions = new_params
+            return self.proto_obj
+        else:
+            raise ValueError(
+                "parameter_map should be a dictionary."
+            )
+
+
+    def has_webhook(self) -> bool:
+        """Check whether the Fulfillment in proto_obj uses a Webhook.
+
+        Returns:
+          True if proto_obj uses a Webhook and False otherwise
+        """
+        try:
+            self._check_proto_obj_attr_exist()
+        except ValueError:
+            return False
+
+        return bool(self.proto_obj.webhook)
diff --git a/src/dfcx_scrapi/builders/intents.py b/src/dfcx_scrapi/builders/intents.py
index ecea5123..0a99f3bc 100644
--- a/src/dfcx_scrapi/builders/intents.py
+++ b/src/dfcx_scrapi/builders/intents.py
@@ -1,6 +1,6 @@
 """A set of builder methods to create CX proto resource objects"""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -18,9 +18,12 @@
 from string import ascii_lowercase
 from string import digits
 from collections import defaultdict
+from dataclasses import dataclass
+from dataclasses import field
 from typing import List, Dict, Union
 
 from google.cloud.dialogflowcx_v3beta1.types import Intent
+from dfcx_scrapi.builders.builders_common import BuildersCommon
 
 # logging config
 logging.basicConfig(
@@ -30,38 +33,22 @@
 )
 
 
-class IntentBuilder:
+class IntentBuilder(BuildersCommon):
     """Base Class for CX Intent builder."""
 
-    def __init__(self, obj: Intent = None):
-        self.proto_obj = None
-        if obj:
-            self.load_intent(obj)
+    _proto_type = Intent
+    _proto_type_str = "Intent"
 
 
     def __str__(self) -> str:
         """String representation of the proto_obj."""
-        self._check_intent_exist()
+        self._check_proto_obj_attr_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.
@@ -73,6 +60,7 @@ def _include_spaces_to_phrase(self, phrase: List[str], annots: List[str]):
             A list of strings that represents
               parameter_id of each part in phrase.
         """
+        chars_to_ignore_at_beginning = ["'", ",", ".", "?", "!"]
         i = 0
         while True:
             p_curr, a_curr = phrase[i], annots[i]
@@ -86,7 +74,13 @@ def _include_spaces_to_phrase(self, phrase: List[str], annots: List[str]):
                 annots.insert(i+1, "")
                 i += 2
             elif a_curr and not a_next:
-                phrase[i+1] = " " + p_next
+                flag = any(
+                    ch
+                    for ch in chars_to_ignore_at_beginning
+                    if p_next.startswith(ch)
+                )
+                if not flag:
+                    phrase[i+1] = " " + p_next
                 i += 1
             elif not a_curr and a_next:
                 phrase[i] = p_curr + " "
@@ -137,7 +131,7 @@ def _label_constraints_check(self, key: str, value: str):
 
     def _show_basic_info(self) -> str:
         """String representation for the basic information of proto_obj."""
-        self._check_intent_exist()
+        self._check_proto_obj_attr_exist()
 
         labels = [
             str(key) if key == val else f"{key}: {val}"
@@ -152,7 +146,7 @@ def _show_basic_info(self) -> str:
 
     def _show_parameters(self) -> str:
         """String representation for the parameters of proto_obj."""
-        self._check_intent_exist()
+        self._check_proto_obj_attr_exist()
 
         return "\n".join([
             (f"parameter_id: {str(param.id)}"
@@ -165,7 +159,7 @@ def _show_parameters(self) -> str:
 
     def _show_training_phrases(self, repeat_count: int = None) -> str:
         """String representation for the training phrases of proto_obj."""
-        self._check_intent_exist()
+        self._check_proto_obj_attr_exist()
 
         phrases = []
         for tp in self.proto_obj.training_phrases:
@@ -200,7 +194,7 @@ def show_intent(
             Indicates how many times the training phrases
             was added to the intent.
         """
-        self._check_intent_exist()
+        self._check_proto_obj_attr_exist()
 
         self.parameter_checking()
 
@@ -211,7 +205,7 @@ def show_intent(
         elif mode in ["phrases", "training phrases"]:
             print(self._show_training_phrases(repeat_count=repeat_count))
         elif mode == "whole":
-            print(self.__str__())
+            print(self)
         else:
             raise ValueError(
                 "mode should be in"
@@ -222,50 +216,10 @@ def show_intent(
 
     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())
-        ])
+        self._check_proto_obj_attr_exist()
 
-        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)
+        stats_instance = IntentStats(self.proto_obj)
+        stats_instance.generate_stats()
 
 
     def parameter_checking(self, raise_error: bool = False) -> bool:
@@ -279,6 +233,8 @@ def parameter_checking(self, raise_error: bool = False) -> bool:
         Returns:
           True if annotated parameters are the same as parameters in proto_obj
         """
+        self._check_proto_obj_attr_exist()
+
         tp_params_set = set()
         for tp in self.proto_obj.training_phrases:
             for part in tp.parts:
@@ -302,45 +258,14 @@ def parameter_checking(self, raise_error: bool = False) -> bool:
                     "\nPlease add it using add_parameter method to continue."
                 )
                 if raise_error:
-                    raise Exception(msg)
+                    raise UserWarning(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(
+    def create_new_proto_obj(
         self,
         display_name: str,
         priority: int = 500000,
@@ -385,7 +310,7 @@ def create_new_intent(
           An Intent object stored in proto_obj
         """
         if self.proto_obj and not overwrite:
-            raise Exception(
+            raise UserWarning(
                 "proto_obj already contains an Intent."
                 " If you wish to overwrite it, pass overwrite as True."
             )
@@ -450,8 +375,7 @@ def add_training_phrase(
         Returns:
           An Intent object stored in proto_obj
         """
-
-        self._check_intent_exist()
+        self._check_proto_obj_attr_exist()
 
         # Add simple training phrase
         if isinstance(phrase, str):
@@ -540,7 +464,7 @@ def add_parameter(
         Returns:
           An Intent object stored in proto_obj
         """
-        self._check_intent_exist()
+        self._check_proto_obj_attr_exist()
 
         if not (isinstance(parameter_id, str) or isinstance(entity_type, str)):
             raise ValueError(
@@ -583,7 +507,7 @@ def add_label(
         Returns:
           An Intent object stored in proto_obj
         """
-        self._check_intent_exist()
+        self._check_proto_obj_attr_exist()
 
         if isinstance(label, str):
             self._label_constraints_check(key=label, value=label)
@@ -610,7 +534,7 @@ def remove_training_phrase(self, phrase: str) -> Intent:
         Returns:
           An Intent object stored in proto_obj
         """
-        self._check_intent_exist()
+        self._check_proto_obj_attr_exist()
 
         if not isinstance(phrase, str):
             raise ValueError("phrase should be a string.")
@@ -635,7 +559,7 @@ def remove_parameter(self, parameter_id: str) -> Intent:
         Returns:
           An Intent object stored in proto_obj
         """
-        self._check_intent_exist()
+        self._check_proto_obj_attr_exist()
 
         if not isinstance(parameter_id, str):
             raise ValueError("parameter_id should be a string.")
@@ -658,7 +582,7 @@ def remove_label(self, label: Union[Dict[str, str], str]) -> Intent:
         Returns:
           An Intent object stored in proto_obj
         """
-        self._check_intent_exist()
+        self._check_proto_obj_attr_exist()
 
         if isinstance(label, str):
             if self.proto_obj.labels.get(label) == label:
@@ -680,3 +604,92 @@ def remove_label(self, label: Union[Dict[str, str], str]) -> Intent:
             )
 
         return self.proto_obj
+
+
+
+@dataclass
+class IntentStats():
+    """A class for tracking the stats of CX Intent object."""
+    intent_proto_obj: Intent
+
+    # Training Phrases
+    repeat_count_dict: Dict[int, int] = field(
+        default_factory=lambda: defaultdict(int)
+    )
+    annotated_count: int = 0
+    phrases_count: int = 0
+    annotated_pct: int = 0
+    uniques_count: int = 0
+    uniques_pct: int = 0
+    non_uniques_count: int = 0
+    non_uniques_pct: int = 0
+
+    # Parameters
+    params_count: int = 0
+
+
+    def calc_tps_stats(self):
+        """Calculating stats related to training phrases."""
+        for tp in self.intent_proto_obj.training_phrases:
+            self.repeat_count_dict[tp.repeat_count] += 1
+
+            for part in tp.parts:
+                if part.parameter_id:
+                    self.annotated_count += 1
+                    break
+
+
+        self.phrases_count = len(self.intent_proto_obj.training_phrases)
+        self.uniques_count = self.repeat_count_dict[1]
+        self.non_uniques_count = self.phrases_count - self.uniques_count
+
+        annot_pct = 100 * (self.annotated_count / self.phrases_count)
+        uniq_pct = 100 * (self.uniques_count / self.phrases_count)
+        non_uniq_pct = 100 * (self.non_uniques_count / self.phrases_count)
+        self.annotated_pct = round(annot_pct, 1)
+        self.uniques_pct = round(uniq_pct, 1)
+        self.non_uniques_pct = round(non_uniq_pct, 1)
+
+    def create_tps_str(self) -> str:
+        """String representation of stats related to training phrases."""
+        phrases_str = f"# of training phrases: {self.phrases_count}"
+        annotated_str = (
+            "Annotated training phrases:"
+            f" {self.annotated_pct}% ({self.annotated_count})"
+        )
+
+        uniques_str = (
+            "Unique training phrases:"
+            f" {self.uniques_pct}% ({self.uniques_count})"
+        )
+        non_uniques_str = (
+            "Non-unique training phrases:"
+            f" {self.non_uniques_pct}% ({self.non_uniques_count})"
+        )
+
+        repeat_count_srt = "\n\t".join([
+            f"with repeat count {i}: {self.repeat_count_dict[i]}"
+            for i in sorted(self.repeat_count_dict.keys())
+        ])
+
+        return (
+            f"{phrases_str}\n{annotated_str}\n"
+            f"\n{uniques_str}\n{non_uniques_str}\n\t{repeat_count_srt}"
+        )
+
+
+    def create_parameter_str(self) -> str:
+        """String representation Intent's parameters.."""
+        self.params_count = len(self.intent_proto_obj.parameters)
+        return f"# of parameters: {self.params_count}"
+
+
+    def generate_stats(self):
+        """Generate stats for the Intent."""
+        self.calc_tps_stats()
+
+        tps_str = self.create_tps_str()
+        params_str = self.create_parameter_str()
+
+        out = f"{params_str}\n{tps_str}"
+        print(out)
diff --git a/src/dfcx_scrapi/builders/pages.py b/src/dfcx_scrapi/builders/pages.py
new file mode 100644
index 00000000..c9093683
--- /dev/null
+++ b/src/dfcx_scrapi/builders/pages.py
@@ -0,0 +1,745 @@
+"""A set of builder methods to create CX proto resource objects"""
+
+# Copyright 2023 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 dataclasses import dataclass
+from typing import List, Union
+
+from google.cloud.dialogflowcx_v3beta1.types import Page
+from google.cloud.dialogflowcx_v3beta1.types import Form
+from google.cloud.dialogflowcx_v3beta1.types import Fulfillment
+from google.cloud.dialogflowcx_v3beta1.types import TransitionRoute
+from google.cloud.dialogflowcx_v3beta1.types import EventHandler
+from dfcx_scrapi.builders.builders_common import BuildersCommon
+from dfcx_scrapi.builders.routes import TransitionRouteBuilder
+from dfcx_scrapi.builders.routes import EventHandlerBuilder
+from dfcx_scrapi.builders.fulfillments import FulfillmentBuilder
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+
+class PageBuilder(BuildersCommon):
+    """Base Class for CX Page builder."""
+
+    _proto_type = Page
+    _proto_type_str = "Page"
+
+
+    def __str__(self) -> str:
+        """String representation of the proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        return (
+            f"Basic Information:\n{'='*25}\n{self._show_basic_info()}"
+            f"\n\n\nParameters:\n{'='*25}\n{self._show_parameters()}"
+            f"\n\n\nTransitionRoutes:\n{'='*25}"
+            f"\n{self._show_transition_routes()}"
+            f"\n\n\nEventHandlers:\n{'='*25}\n{self._show_event_handlers()}"
+            f"\n\n\nTransitoinRouteGroups:\n{'='*25}"
+            f"\n{self._show_transition_route_groups()}")
+
+
+    def _show_basic_info(self) -> str:
+        """String representation for the basic information of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        entry_fulfillment_str = str(
+            FulfillmentBuilder(self.proto_obj.entry_fulfillment)
+        )
+        return (
+            f"display_name: {self.proto_obj.display_name}"
+            f"\nentry_fulfillment:\n\n{entry_fulfillment_str}"
+        )
+
+
+    def _show_parameters(self) -> str:
+        """String representation for the parameters of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        return "\n".join([
+            (
+                f"display_name: {param.display_name}"
+                f"\n\tentity_type: {param.entity_type}"
+                f"\n\trequired: {param.required}"
+                f"\n\tis_list: {param.is_list}"
+                f"\n\treadct: {param.redact}"
+                f"\n\tdefault_value: {param.default_value}"
+            )
+            for param in self.proto_obj.form.parameters
+        ])
+
+
+    def _show_transition_routes(self) -> str:
+        """String representation for the transition routes of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        return "\n".join([
+            f"TransitionRoute {i+1}:\n{str(TransitionRouteBuilder(tr))}"
+            f"\n{'*'*20}\n"
+            for i, tr in enumerate(self.proto_obj.transition_routes)
+        ])
+
+
+    def _show_event_handlers(self) -> str:
+        """String representation for the event handlers of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        return "\n".join([
+            f"EventHandler {i+1}:\n{str(EventHandlerBuilder(eh))}\n{'*'*20}\n"
+            for i, eh in enumerate(self.proto_obj.event_handlers)
+        ])
+
+
+    def _show_transition_route_groups(self) -> str:
+        """String representation for the transition route groups of proto_obj"""
+        self._check_proto_obj_attr_exist()
+
+        return "\n".join([
+            f"TransitionRouteGroup {i+1}: {trg_id}"
+            for i, trg_id in enumerate(self.proto_obj.transition_route_groups)
+        ])
+
+
+    def show_page_info(
+        self, mode: str = "whole"
+    ) -> None:
+        """Show the proto_obj information.
+
+        Args:
+          mode (str):
+            Specifies what part of the page to show.
+              Options:
+              ['basic', 'whole', 'parameters',
+              'routes' or 'transition routes',
+              'route groups' or 'transition route groups',
+              'events' or 'event handlers'
+              ]
+        """
+        self._check_proto_obj_attr_exist()
+
+        if mode == "basic":
+            print(self._show_basic_info())
+        elif mode == "parameters":
+            print(self._show_parameters())
+        elif mode in ["routes", "transition routes"]:
+            print(self._show_transition_routes())
+        elif mode in ["route groups", "transition route groups"]:
+            print(self._show_transition_route_groups())
+        elif mode in ["events", "event handlers"]:
+            print(self._show_event_handlers())
+        elif mode == "whole":
+            print(self)
+        else:
+            raise ValueError(
+                "mode should be in"
+                "['basic', 'whole', 'parameters',"
+                " 'routes', 'transition routes',"
+                " 'route groups', 'transition route groups',"
+                " 'events', 'event handlers']"
+            )
+
+
+    def show_stats(self) -> None:
+        """Provide some stats about the Page."""
+        self._check_proto_obj_attr_exist()
+
+        stats_instance = PageStats(self.proto_obj)
+        stats_instance.generate_stats()
+
+
+    def create_new_proto_obj(
+        self,
+        display_name: str,
+        entry_fulfillment: Fulfillment = None,
+        overwrite: bool = False
+    ) -> Page:
+        """Create a new Page.
+
+        Args:
+          display_name (str):
+            Required. The human-readable name of the
+            page, unique within the flow.
+          entry_fulfillment (Fulfillment):
+            The fulfillment to call when the session is entering the page.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already contains a Page.
+
+        Returns:
+          A Page object stored in proto_obj.
+        """
+        # Types error checking
+        if not (display_name and isinstance(display_name, str)):
+            raise ValueError("`display_name` should be a nonempty string.")
+        if (entry_fulfillment and
+            not isinstance(entry_fulfillment, Fulfillment)):
+            raise ValueError(
+                "The type of `entry_fulfillment` should be a Fulfillment."
+            )
+        # `overwrite` parameter error checking
+        if self.proto_obj and not overwrite:
+            raise UserWarning(
+                "proto_obj already contains a Page."
+                " If you wish to overwrite it, pass `overwrite` as True."
+            )
+        # Create the Page
+        if overwrite or not self.proto_obj:
+            if not entry_fulfillment:
+                entry_fulfillment = Fulfillment()
+
+            self.proto_obj = Page(
+                display_name=display_name,
+                entry_fulfillment=entry_fulfillment
+            )
+
+        return self.proto_obj
+
+
+    def add_parameter(
+        self,
+        display_name: str,
+        entity_type: str,
+        initial_prompt_fulfillment: Fulfillment,
+        required: bool = True,
+        default_value: str = None,
+        is_list: bool = False,
+        redact: bool = False,
+        reprompt_event_handlers: Union[EventHandler, List[EventHandler]] = None
+    ) -> Page:
+        """Add a parameter to collect from the user.
+
+        Args:
+          display_name (str):
+            Required. The human-readable name of the
+            parameter, unique within the form.
+          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.
+          initial_prompt_fulfillment (Fulfillment):
+            Required. The fulfillment to provide the initial prompt that
+            the agent can present to the user in order to fill the parameter.
+          required (bool):
+            Indicates whether the parameter is required.
+            Optional parameters will not trigger prompts;
+            however, they are filled if the user specifies
+            them. Required parameters must be filled before
+            form filling concludes.
+          default_value (str):
+            The default value of an optional parameter.
+            If the parameter is required, the default value
+            will be ignored.
+          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.
+          reprompt_event_handlers (EventHandler | List[EventHandler]):
+            The handlers for parameter-level events, used to provide reprompt
+            for the parameter or transition to a different page/flow.
+            The supported events are:
+              - ``sys.no-match-<N>``, where N can be from 1 to 6
+              - ``sys.no-match-default``
+              - ``sys.no-input-<N>``, where N can be from 1 to 6
+              - ``sys.no-input-default``
+              - ``sys.invalid-parameter``
+
+            ``initial_prompt_fulfillment`` provides the first prompt for
+            the parameter.
+
+            If the event handler for the corresponding event can't be found on
+            the parameter, ``initial_prompt_fulfillment`` will be re-prompted.
+
+        Returns:
+          A Page object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Types error checking
+        if not (display_name and isinstance(display_name, str)):
+            raise ValueError("`display_name` should be a nonempty string.")
+        if not (entity_type and isinstance(entity_type, str)):
+            raise ValueError("`entity_type` should be a valid entity type id.")
+        if not (initial_prompt_fulfillment and
+            isinstance(initial_prompt_fulfillment, Fulfillment)):
+            raise ValueError(
+                "`initial_prompt_fulfillment` should be a Fulfillment."
+            )
+        if not(
+            isinstance(required, bool) and
+            isinstance(is_list, bool) and
+            isinstance(redact, bool)
+        ):
+            raise ValueError(
+                "`is_list`, `required`, and `redact` should be bool."
+            )
+        if reprompt_event_handlers:
+            self._is_type_or_list_of_types(
+                reprompt_event_handlers, EventHandler, "reprompt_event_handlers"
+            )
+            if not isinstance(reprompt_event_handlers, list):
+                reprompt_event_handlers = [reprompt_event_handlers]
+
+        if required:
+            the_param = Form.Parameter(
+                display_name=display_name,
+                required=required,
+                entity_type=entity_type,
+                is_list=is_list,
+                redact=redact
+            )
+        else:
+            if not isinstance(default_value, str):
+                raise ValueError("`default_value` should be a string.")
+            the_param = Form.Parameter(
+                display_name=display_name,
+                required=required,
+                entity_type=entity_type,
+                is_list=is_list,
+                redact=redact,
+                default_value=default_value
+            )
+
+        the_param.fill_behavior = Form.Parameter.FillBehavior(
+            initial_prompt_fulfillment=initial_prompt_fulfillment,
+            reprompt_event_handlers=reprompt_event_handlers
+        )
+        self.proto_obj.form.parameters.append(the_param)
+
+        return self.proto_obj
+
+
+    def add_transition_route(
+        self,
+        transition_routes: Union[TransitionRoute, List[TransitionRoute]]
+    ) -> Page:
+        """Add single or multiple TransitionRoutes to the Page.
+
+        Args:
+          transition_routes (TransitionRoute | List[TransitionRoute]):
+            A single or list of TransitionRoutes to add
+            to the Page existing in proto_obj.
+        Returns:
+          A Page object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Type/Error checking
+        self._is_type_or_list_of_types(
+            transition_routes, TransitionRoute, "transition_routes"
+        )
+
+        if not isinstance(transition_routes, list):
+            transition_routes = [transition_routes]
+        self.proto_obj.transition_routes.extend(transition_routes)
+
+        return self.proto_obj
+
+
+    def add_event_handler(
+        self,
+        event_handlers: Union[EventHandler, List[EventHandler]]
+    ) -> Page:
+        """Add single or multiple EventHandlers to the Page.
+
+        Args:
+          event_handlers (EventHandler | List[EventHandler]):
+            A single or list of EventHandler to add
+            to the Page existing in proto_obj.
+        Returns:
+          A Page object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Type/Error checking
+        self._is_type_or_list_of_types(
+            event_handlers, EventHandler, "event_handlers"
+        )
+
+        if not isinstance(event_handlers, list):
+            event_handlers = [event_handlers]
+        self.proto_obj.event_handlers.extend(event_handlers)
+
+        return self.proto_obj
+
+
+    def add_transition_route_group(
+        self,
+        transition_route_groups: Union[str, List[str]]
+    ) -> Page:
+        """Add single or multiple TransitionRouteGroups to the Page.
+
+        Args:
+          transition_route_groups (str | List[str]):
+            A single or list of TransitionRouteGroup's id to add
+            to the Page existing in proto_obj. Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              flows/<Flow ID>/transitionRouteGroups/<TransitionRouteGroup ID>``.
+        Returns:
+          A Page object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Type/Error checking
+        self._is_type_or_list_of_types(
+            transition_route_groups, str, "transition_route_groups"
+        )
+
+        if not isinstance(transition_route_groups, list):
+            transition_route_groups = [transition_route_groups]
+        self.proto_obj.transition_route_groups.extend(transition_route_groups)
+
+        return self.proto_obj
+
+
+    def remove_parameter(
+        self,
+        display_name: Union[str, List[str]]
+    ) -> Page:
+        """Remove single or multiple parameters from the Page.
+
+        Args:
+          display_name (str | List[str]):
+            A string or a list of strings corresponding to
+            the name of the parameter(s).
+
+        Returns:
+          A Page object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Types error checking
+        if not display_name:
+            raise ValueError("`display_name` should not be empty.")
+        self._is_type_or_list_of_types(display_name, str, "display_name")
+
+        if not isinstance(display_name, list):
+            display_name = [display_name]
+
+        new_params = [
+            param
+            for param in self.proto_obj.form.parameters
+            if param.display_name not in display_name
+        ]
+        self.proto_obj.form.parameters = new_params
+
+        return self.proto_obj
+
+
+    def remove_transition_route(
+        self,
+        transition_route: TransitionRoute = None,
+        intent: str = None,
+        condition: str = None
+    ) -> Page:
+        """Remove a transition route from the Page.
+
+        At least one of the `transition_route`, `intent`, or `condition` should
+        be specfied.
+
+        Args:
+            transition_route (TransitionRoute):
+              The TransitionRoute to remove from the Page.
+            intent (str):
+              TransitionRoute's intent that should be removed from the Page.
+            condition (str):
+              TransitionRoute's condition that should be removed from the Page.
+
+        Returns:
+          A Page object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        new_routes = []
+        for tr in self.proto_obj.transition_routes:
+            if self._match_transition_route(
+                transition_route=tr, target_route=transition_route,
+                intent=intent, condition=condition
+            ):
+                continue
+            new_routes.append(tr)
+        self.proto_obj.transition_routes = new_routes
+
+        return self.proto_obj
+
+
+    def remove_event_handler(
+        self,
+        event_handlers: Union[EventHandler, List[EventHandler]] = None,
+        event_names: Union[str, List[str]] = None
+    ) -> Page:
+        """Remove single or multiple EventHandlers from the Page.
+
+        Args:
+          event_handlers (EventHandler | List[EventHandler]):
+            A single or list of EventHandler to remove
+              from the Page existing in proto_obj.
+            Only one of the `event_handlers` and
+              `event_names` should be specified.
+          event_names (str | List[str]):
+            A single or list of EventHandler's event names corresponding to the
+              EventHandler to remove from the Page existing in proto_obj.
+            Only one of the `event_handlers` and
+              `event_names` should be specified.
+
+        Returns:
+          A Page object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        if event_handlers and event_names:
+            raise UserWarning(
+                "Only one of the `event_handlers` and "
+                "`event_names` should be specified."
+            )
+        if event_handlers:
+            new_ehs = self._find_unmatched_event_handlers(event_handlers)
+        elif event_names:
+            new_ehs = self._find_unmatched_event_handlers_by_name(event_names)
+        else:
+            raise UserWarning(
+                "At least one of the `event_handlers` and "
+                "`event_names` should be specified."
+            )
+
+        self.proto_obj.event_handlers = new_ehs
+
+        return self.proto_obj
+
+
+    def remove_transition_route_group(
+        self,
+        transition_route_groups: Union[str, List[str]]
+    ) -> Page:
+        """Remove single or multiple TransitionRouteGroups from the Page.
+
+        Args:
+          transition_route_groups (str | List[str]):
+            A single or list of TransitionRouteGroup's id to remove
+            from the Page existing in proto_obj. Format:
+            ``projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>/
+              flows/<Flow ID>/transitionRouteGroups/<TransitionRouteGroup ID>``.
+
+        Returns:
+          A Page object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        # Type error checking
+        self._is_type_or_list_of_types(
+            transition_route_groups, str, "transition_route_groups"
+        )
+
+        if not isinstance(transition_route_groups, list):
+            transition_route_groups = [transition_route_groups]
+
+        new_trgs = [
+            trg
+            for trg in self.proto_obj.transition_route_groups
+            if trg not in transition_route_groups
+        ]
+        self.proto_obj.transition_route_groups = new_trgs
+
+        return self.proto_obj
+
+
+
+@dataclass
+class PageStats():
+    """A class for tracking the stats of CX Page object."""
+    page_proto_obj: Page
+
+    # Entry Fulfillment
+    has_entry_fulfill: bool = False
+
+    # Parameters
+    parameters_count: int = 0
+    parameters_with_event_handler_count: int = 0
+    parameters_with_webhook_fulfill_count: int = 0
+    parameters_ratio: int = 0
+
+    # Transition Routes
+    transition_routes_count: int = 0
+    routes_with_fulfill_count: int = 0
+    routes_with_webhook_fulfill_count: int = 0
+    intent_routes_count: int = 0
+    cond_routes_count: int = 0
+    intent_and_cond_routes_count: int = 0
+
+    # Event Handlers
+    event_handlers_count: int = 0
+    events_with_fulfill_count: int = 0
+    events_with_webhook_fulfill_count: int = 0
+
+    # Transition Route Groups
+    transition_route_groups_count: int = 0
+
+
+    def calc_transition_route_stats(self):
+        """Calculating TransitionRoute related stats."""
+        self.transition_routes_count = len(
+            self.page_proto_obj.transition_routes
+        )
+        for tr in self.page_proto_obj.transition_routes:
+            if tr.trigger_fulfillment:
+                self.routes_with_fulfill_count += 1
+                fb = FulfillmentBuilder(tr.trigger_fulfillment)
+                if fb.has_webhook():
+                    self.routes_with_webhook_fulfill_count += 1
+            if tr.intent and tr.condition:
+                self.intent_and_cond_routes_count += 1
+            elif tr.intent and not tr.condition:
+                self.intent_routes_count += 1
+            elif not tr.intent and tr.condition:
+                self.cond_routes_count += 1
+
+    def create_transition_route_str(self) -> str:
+        """String representation of TransitionRoutes stats."""
+        transition_routes_str = (
+            f"# of Transition Routes: {self.transition_routes_count}"
+        )
+        routes_with_fulfill_str = (
+            f"# of routes with fulfillment: {self.routes_with_fulfill_count}"
+        )
+        routes_with_webhook_fulfill_str = (
+            "# of routes uses webhook for fulfillment:"
+            f" {self.routes_with_webhook_fulfill_count}"
+        )
+        intent_routes_str = f"# of intent routes: {self.intent_routes_count}"
+        cond_routes_str = f"# of condition routes: {self.cond_routes_count}"
+        intent_and_cond_routes_str = (
+            "# of intent and condition routes:"
+            f" {self.intent_and_cond_routes_count}"
+        )
+
+        return (
+            f"{transition_routes_str}\n\t{intent_routes_str}"
+            f"\n\t{cond_routes_str}\n\t{intent_and_cond_routes_str}"
+            f"\n\t{routes_with_fulfill_str}"
+            f"\n\t{routes_with_webhook_fulfill_str}"
+        )
+
+
+    def calc_event_handler_stats(self):
+        """Calculating EventHandler related stats."""
+        self.event_handlers_count = len(self.page_proto_obj.event_handlers)
+        for eh in self.page_proto_obj.event_handlers:
+            fb = FulfillmentBuilder(eh.trigger_fulfillment)
+            if fb.has_webhook():
+                self.events_with_webhook_fulfill_count += 1
+            if eh.trigger_fulfillment:
+                self.events_with_fulfill_count += 1
+
+    def create_event_handler_str(self) -> str:
+        """String representation of EventHandlers stats."""
+        event_handlers_str = f"# of Event Handlers: {self.event_handlers_count}"
+        events_with_fulfill_str = (
+            "# of Event Handlers with fulfillment:"
+            f" {self.events_with_fulfill_count}"
+        )
+        events_with_webhook_fulfill_str = (
+            "# of Event Handlers uses webhook for fulfillment:"
+            f" {self.events_with_webhook_fulfill_count}"
+        )
+
+        return (
+            f"{event_handlers_str}\n\t{events_with_fulfill_str}"
+            f"\n\t{events_with_webhook_fulfill_str}"
+        )
+
+
+    def calc_parameter_stats(self):
+        """Calculating Parameter related stats."""
+        self.parameters_count = len(self.page_proto_obj.form.parameters)
+        for param in self.page_proto_obj.form.parameters:
+            fb = FulfillmentBuilder(
+                param.fill_behavior.initial_prompt_fulfillment
+            )
+            if fb.has_webhook():
+                self.parameters_with_webhook_fulfill_count += 1
+            if param.fill_behavior.reprompt_event_handlers:
+                self.parameters_with_event_handler_count += 1
+
+        if self.parameters_count != 0:
+            self.parameters_ratio = (
+                self.parameters_with_event_handler_count/self.parameters_count
+            )
+
+    def create_parameter_str(self) -> str:
+        """String representation of Page's parameters stats."""
+        parameters_str = f"# of Parameters: {self.parameters_count}"
+        parameters_with_event_handler_str = (
+            "# of Parameters with Event Handlers:"
+            f" {self.parameters_with_event_handler_count}"
+            f" (Ratio: {self.parameters_ratio})"
+        )
+        parameters_with_webhook_fulfill_str = (
+            "# of Parameters uses webhook for fulfillment:"
+            f" {self.parameters_with_webhook_fulfill_count}"
+        )
+
+        return (
+            f"{parameters_str}\n\t{parameters_with_event_handler_str}"
+            f"\n\t{parameters_with_webhook_fulfill_str}"
+        )
+
+
+    def create_entry_fulfillment_str(self) -> str:
+        """String representation for Page's Entry Fulfillment."""
+        self.has_entry_fulfill = bool(self.page_proto_obj.entry_fulfillment)
+        return f"Has entry fulfillment: {self.has_entry_fulfill}"
+
+
+    def create_transition_route_group_str(self) -> str:
+        """String representation of TransitionRouteGroup stats."""
+        self.transition_route_groups_count = len(
+            self.page_proto_obj.transition_route_groups
+        )
+        return (
+            "# of Transition Route Groups:"
+            f" {self.transition_route_groups_count}"
+        )
+
+
+    def generate_stats(self):
+        """Generate stats for the Page."""
+        self.calc_parameter_stats()
+        self.calc_transition_route_stats()
+        self.calc_event_handler_stats()
+
+        has_entry_fulfill_str = self.create_entry_fulfillment_str()
+        params_stats_str = self.create_parameter_str()
+        routes_stats_str = self.create_transition_route_str()
+        events_stats_str = self.create_event_handler_str()
+        route_groups_stats_str = self.create_transition_route_group_str()
+
+        out = (
+            f"{has_entry_fulfill_str}\n{params_stats_str}\n{routes_stats_str}"
+            f"\n{events_stats_str}\n{route_groups_stats_str}"
+        )
+        print(out)
diff --git a/src/dfcx_scrapi/builders/response_messages.py b/src/dfcx_scrapi/builders/response_messages.py
new file mode 100644
index 00000000..0b836477
--- /dev/null
+++ b/src/dfcx_scrapi/builders/response_messages.py
@@ -0,0 +1,250 @@
+"""A set of builder methods to create CX proto resource objects"""
+
+# Copyright 2023 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 re
+import logging
+from typing import List, Dict, Union, Any
+
+from google.cloud.dialogflowcx_v3beta1.types import ResponseMessage
+from google.protobuf import struct_pb2
+from dfcx_scrapi.builders.builders_common import BuildersCommon
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+
+class ResponseMessageBuilder(BuildersCommon):
+    """Base Class for CX ResponseMessage builder."""
+
+    _proto_type = ResponseMessage
+    _proto_type_str = "ResponseMessage"
+
+
+    def __str__(self) -> str:
+        """String representation of the proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        if self.proto_obj.text:
+            resp_type = "text"
+            resp_msg = self.proto_obj.text.text
+        elif self.proto_obj.payload:
+            resp_type = "payload"
+            proto_struct = self.proto_obj.payload
+            resp_msg = "\n\t".join([
+                f"{k}: {v}" for k, v in proto_struct.items()
+            ])
+        elif self.proto_obj.conversation_success:
+            resp_type = "conversation_success"
+            proto_struct = self.proto_obj.conversation_success.metadata
+            resp_msg = "\n\t".join([
+                f"{k}: {v}" for k, v in proto_struct.items()
+            ])
+        elif self.proto_obj.output_audio_text:
+            if self.proto_obj.output_audio_text.text:
+                resp_type = "output_audio_text - text"
+                resp_msg = self.proto_obj.output_audio_text.text
+            elif self.proto_obj.output_audio_text.ssml:
+                resp_type = "output_audio_text - ssml"
+                resp_msg = self.proto_obj.output_audio_text.ssml
+        elif self.proto_obj.live_agent_handoff:
+            resp_type = "live_agent_handoff"
+            proto_struct = self.proto_obj.live_agent_handoff.metadata
+            resp_msg = "\n\t".join([
+                f"{k}: {v}" for k, v in proto_struct.items()
+            ])
+        elif self.proto_obj.play_audio:
+            resp_type = "play_audio"
+            resp_msg = self.proto_obj.play_audio.audio_uri
+        elif self.proto_obj.telephony_transfer_call:
+            resp_type = "telephony_transfer_call"
+            resp_msg = self.proto_obj.telephony_transfer_call.phone_number
+
+        return (
+            f"Response Type: {resp_type}\nMessage:\n\t{resp_msg}"
+        )
+
+
+    def show_response_message(self):
+        """Show the proto_obj information."""
+        self._check_proto_obj_attr_exist()
+        print(self)
+
+
+    def create_new_proto_obj(
+        self,
+        response_type: str,
+        message: Union[str, List[str], Dict[str, Any]],
+        mode: str = None
+    ) -> ResponseMessage:
+        """Create a ResponseMessage that can be returned by a
+        conversational agent.
+        ResponseMessages are also used for output audio synthesis.
+
+        Args:
+          response_type (str):
+            Type of the response message. It should be one of the following:
+            'text', 'payload', 'conversation_success', 'output_audio_text',
+            'live_agent_handoff', 'play_audio', 'telephony_transfer_call'
+          message (str | List[str] | Dict[str, str]):
+            The output message. For each response_type
+            it should be formatted like the following:
+              text --> str | List[str]
+                A single message as a string or
+                multiple messages as a list of strings
+              payload --> Dict[str, Any]
+                Any dictionary which its keys are string.
+                Dialogflow doesn't impose any structure on the values.
+              conversation_success --> Dict[str, Any]
+                Any dictionary which its keys are string.
+                Dialogflow doesn't impose any structure on the values.
+              output_audio_text --> str
+                A text or ssml response as a string.
+              live_agent_handoff --> Dict[str, Any]
+                Any dictionary which its keys are string.
+                Dialogflow doesn't impose any structure on the values.
+              play_audio --> str
+                URI of the audio clip.
+                Dialogflow does not impose any validation on this value.
+              telephony_transfer_call --> str
+                A phone number in E.164 format as a string.
+                `<https://en.wikipedia.org/wiki/E.164>`
+          mode (str):
+            This argument is only applicable for `output_audio_text`.
+            It should be one of the following: 'text', 'ssml'
+
+        Returns:
+          A ResponseMessage object
+        """
+        if response_type == "text":
+            if isinstance(message, str):
+                message = [message]
+            elif isinstance(message, list):
+                if not all((isinstance(msg, str) for msg in message)):
+                    raise ValueError(
+                        "Only strings are allowed in message list for `text`."
+                    )
+            else:
+                raise ValueError(
+                    "For 'text' message should be"
+                    " either a string or a list of strings."
+                )
+
+            response_message = ResponseMessage(
+                text=ResponseMessage.Text(text=message)
+            )
+        elif response_type == "payload":
+            if not(
+                isinstance(message, dict)
+                and all((isinstance(key, str) for key in message.keys()))
+            ):
+                raise ValueError(
+                    "For `payload`, message should be"
+                    " a dictionary and its keys should be strings."
+                )
+
+            proto_struct = struct_pb2.Struct()
+            proto_struct.update(message)
+            response_message = ResponseMessage(payload=proto_struct)
+        elif response_type == "conversation_success":
+            proto_struct = struct_pb2.Struct()
+            proto_struct.update(message)
+            convo_success = ResponseMessage.ConversationSuccess(
+                metadata=proto_struct
+            )
+            response_message = ResponseMessage(
+                conversation_success=convo_success
+            )
+        elif response_type == "output_audio_text":
+            if not isinstance(message, str):
+                raise ValueError(
+                    "For 'output_audio_text', message should be a string."
+                )
+
+            if mode == "text":
+                output_audio_text = ResponseMessage.OutputAudioText(
+                    text=message
+                )
+            elif mode == "ssml":
+                output_audio_text = ResponseMessage.OutputAudioText(
+                    ssml=message
+                )
+            else:
+                raise ValueError(
+                    "mode should be either 'text' or 'ssml'"
+                    " for `output_audio_text`."
+                )
+
+            response_message = ResponseMessage(
+                output_audio_text=output_audio_text
+            )
+        elif response_type == "live_agent_handoff":
+            if not(
+                isinstance(message, dict)
+                and all((isinstance(key, str) for key in message.keys()))
+            ):
+                raise ValueError(
+                    "For `live_agent_handoff`, message should be"
+                    " a dictionary and its keys should be strings."
+                )
+
+            proto_struct = struct_pb2.Struct()
+            proto_struct.update(message)
+            live_agent_handoff = ResponseMessage.LiveAgentHandoff(
+                metadata=proto_struct
+            )
+            response_message = ResponseMessage(
+                live_agent_handoff=live_agent_handoff
+            )
+        elif response_type == "play_audio":
+            if not isinstance(message, str):
+                raise ValueError(
+                    "For 'play_audio', message should be a valid URI string."
+                )
+
+            # TODO: Validate the URI here
+            response_message = ResponseMessage(
+                play_audio=ResponseMessage.PlayAudio(audio_uri=message)
+            )
+        elif response_type == "telephony_transfer_call":
+            e_164_re_pattern = r"^\+[1-9]\d{1,14}$"
+            if not (
+                isinstance(message, str)
+                and re.search(e_164_re_pattern, message)
+            ):
+                raise ValueError(
+                    "For 'telephony_transfer_call',"
+                    " message should be a valid E.164 format phone number."
+                )
+            transfer_call_obj = ResponseMessage.TelephonyTransferCall(
+                phone_number=message
+            )
+            response_message = ResponseMessage(
+                telephony_transfer_call=transfer_call_obj
+            )
+        else:
+            raise ValueError(
+                "response_type should be one of the following:"
+                "\n['text', 'payload', 'conversation_success',"
+                " 'output_audio_text', 'live_agent_handoff', 'play_audio',"
+                " 'telephony_transfer_call']"
+            )
+
+        self.proto_obj = response_message
+        return self.proto_obj
diff --git a/src/dfcx_scrapi/builders/routes.py b/src/dfcx_scrapi/builders/routes.py
new file mode 100644
index 00000000..735bee26
--- /dev/null
+++ b/src/dfcx_scrapi/builders/routes.py
@@ -0,0 +1,378 @@
+"""A set of builder methods to create CX proto resource objects"""
+
+# Copyright 2023 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 google.cloud.dialogflowcx_v3beta1.types import Fulfillment
+from google.cloud.dialogflowcx_v3beta1.types import TransitionRoute
+from google.cloud.dialogflowcx_v3beta1.types import EventHandler
+from dfcx_scrapi.builders.builders_common import BuildersCommon
+from dfcx_scrapi.builders.fulfillments import FulfillmentBuilder
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+
+class TransitionRouteBuilder(BuildersCommon):
+    """Base Class for CX TransitionRoute builder."""
+
+    _proto_type = TransitionRoute
+    _proto_type_str = "TransitionRoute"
+
+
+    def __str__(self) -> str:
+        """String representation of the proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        target_str = self._show_target()
+        transition_criteria = self._show_transition_criteria()
+        fulfillment_str = self._show_fulfillment()
+
+        return (
+            f"{target_str}"
+            f"\n{transition_criteria}"
+            f"\nFulfillment:\n\n{fulfillment_str}"
+        )
+
+
+    def _show_transition_criteria(self) -> str:
+        """String representation for the transition criteria of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        intent_str, cond_str = "Not Specified", "Not Specified"
+        if self.proto_obj.intent:
+            intent_str = self.proto_obj.intent
+        if self.proto_obj.condition:
+            cond_str = self.proto_obj.condition
+        return (
+            "Transition criteria:"
+            f"\n\tIntent: {intent_str}"
+            f"\n\tCondition: {cond_str}"
+        )
+
+
+    def _show_target(self) -> str:
+        """String representation for the target of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        if self.proto_obj.target_page:
+            target_type = "Page"
+            target_id = self.proto_obj.target_page
+        elif self.proto_obj.target_flow:
+            target_type = "Flow"
+            target_id = self.proto_obj.target_flow
+        else:
+            target_type = "Not Specified"
+            target_id = "None"
+        return f"Target: {target_type}\nTarget ID: {target_id}"
+
+
+    def _show_fulfillment(self) -> str:
+        """String representation for the fulfillment of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        fulfillment_str = ""
+        if self.proto_obj.trigger_fulfillment:
+            fulfillment_str = str(
+                FulfillmentBuilder(self.proto_obj.trigger_fulfillment)
+            )
+
+        return fulfillment_str
+
+
+    def show_transition_route(self, mode: str = "whole"):
+        """Show the proto_obj information.
+        Args:
+          mode (str):
+            Specifies what part of the TransitionRoute to show.
+            Options:
+              ['target', 'fulfillment',
+               'transition criteria' or 'conditions', 'whole']
+        """
+        self._check_proto_obj_attr_exist()
+
+        if mode == "target":
+            print(self._show_target())
+        elif mode in ["transition criteria", "conditions"]:
+            print(self._show_transition_criteria())
+        elif mode == "fulfillment":
+            print(self._show_fulfillment())
+        elif mode == "whole":
+            print(self)
+        else:
+            raise ValueError(
+                "mode should be in"
+                " ['target', 'fulfillment',"
+                " 'transition criteria' or 'conditions', 'whole']"
+            )
+
+
+    def create_new_proto_obj(
+        self,
+        intent: str = None,
+        condition: str = None,
+        trigger_fulfillment: Fulfillment = None,
+        target_page: str = None,
+        target_flow: str = None,
+        overwrite: bool = False
+    ) -> TransitionRoute:
+        """Create a new TransitionRoute.
+
+        Args:
+          intent (str):
+            Indicates that the transition can only happen when the given
+            intent is matched.
+            Format:
+            ``projects/<Project ID>/locations/<Location ID>/
+              agents/<Agent ID>/intents/<Intent ID>``.
+            At least one of ``intent`` or ``condition`` must be specified.
+            When both ``intent`` and ``condition`` are specified,
+            the transition can only happen when both are fulfilled.
+          condition (str):
+            The condition to evaluate.
+            See the conditions reference:
+            https://cloud.google.com/dialogflow/cx/docs/reference/condition
+            At least one of ``intent`` or ``condition`` must be specified.
+            When both ``intent`` and ``condition`` are specified,
+            the transition can only happen when both are fulfilled.
+          trigger_fulfillment (Fulfillment):
+            The fulfillment to call when the condition is satisfied.
+            When ``trigger_fulfillment`` and ``target`` are defined,
+            ``trigger_fulfillment`` is executed first.
+          target_page (str):
+            The target page to transition to. Format:
+            ``projects/<Project ID>/locations/<Location ID>/
+              agents/<Agent ID>/flows/<Flow ID>/pages/<Page ID>``.
+            At most one of ``target_page`` and ``target_flow``
+            can be specified at the same time.
+          target_flow (str):
+            The target flow to transition to. Format:
+            ``projects/<Project ID>/locations/<Location ID>/
+              agents/<Agent ID>/flows/<Flow ID>``.
+            At most one of ``target_page`` and ``target_flow``
+            can be specified at the same time.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already
+            contains a TransitionRoute.
+
+        Returns:
+          A TransitionRoute object stored in proto_obj.
+        """
+        # Types error checking
+        if ((intent and not isinstance(intent, str)) or
+            (condition and not isinstance(condition, str)) or
+            (target_page and not isinstance(target_page, str)) or
+            (target_flow and not isinstance(target_flow, str))):
+            raise ValueError(
+                "intent, condition, target_page, and target_flow"
+                " if existed should be a string."
+            )
+        if (trigger_fulfillment and
+            not isinstance(trigger_fulfillment, Fulfillment)):
+            raise ValueError(
+                "The type of trigger_fulfillment should be a Fulfillment."
+            )
+        # Minimum requirement error checking
+        if not(intent or condition):
+            raise RuntimeError(
+                "At least one of `intent` or `condition` must be specified."
+            )
+        # Oneof error checking
+        if target_page and target_flow:
+            raise RuntimeError(
+                "At most one of `target_page` and `target_flow`"
+                " can be specified at the same time."
+            )
+        # `overwrite` parameter error checking
+        if self.proto_obj and not overwrite:
+            raise UserWarning(
+                "proto_obj already contains a TransitionRoute."
+                " If you wish to overwrite it, pass overwrite as True."
+            )
+
+        # Create the TransitionRoute
+        if overwrite or not self.proto_obj:
+            # Create empty Fulfillment in case user didn't pass any
+            if not trigger_fulfillment:
+                trigger_fulfillment = Fulfillment()
+
+            self.proto_obj = TransitionRoute(
+                intent=intent,
+                condition=condition,
+                trigger_fulfillment=trigger_fulfillment,
+                target_page=target_page,
+                target_flow=target_flow
+            )
+
+        return self.proto_obj
+
+
+
+
+class EventHandlerBuilder(BuildersCommon):
+    """Base Class for CX EventHandler builder."""
+
+    _proto_type = EventHandler
+    _proto_type_str = "EventHandler"
+
+
+    def __str__(self) -> str:
+        """String representation of the proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        event_and_target_str = self._show_event_and_target()
+        fulfillment_str = self._show_fulfillment()
+
+        return (
+            f"{event_and_target_str}"
+            f"\nFulfillment:\n\n{fulfillment_str}"
+        )
+
+
+    def _show_event_and_target(self) -> str:
+        """String representation for the target of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        # Event str
+        event_str = f"Event: {self.proto_obj.event}"
+        # Target str
+        if self.proto_obj.target_page:
+            target_type = "Page"
+            target_id = self.proto_obj.target_page
+        elif self.proto_obj.target_flow:
+            target_type = "Flow"
+            target_id = self.proto_obj.target_flow
+        else:
+            target_type = "Not Specified"
+            target_id = "None"
+        return (
+            f"{event_str}"
+            f"\nTarget: {target_type}"
+            f"\nTarget ID: {target_id}"
+        )
+
+
+    def _show_fulfillment(self) -> str:
+        """String representation for the fulfillment of proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        fulfillment_str = ""
+        if self.proto_obj.trigger_fulfillment:
+            fulfillment_str = str(
+                FulfillmentBuilder(self.proto_obj.trigger_fulfillment)
+            )
+
+        return fulfillment_str
+
+
+    def show_event_handler(self, mode: str = "whole"):
+        """Show the proto_obj information.
+        Args:
+          mode (str):
+            Specifies what part of the EventHandler to show.
+            Options:
+              ['basic' or 'target' or 'event', 'fulfillment', 'whole']
+        """
+        self._check_proto_obj_attr_exist()
+
+        if mode in ["basic", "target", "event"]:
+            print(self._show_event_and_target())
+        elif mode == "fulfillment":
+            print(self._show_fulfillment())
+        elif mode == "whole":
+            print(self)
+        else:
+            raise ValueError(
+                "mode should be in"
+                " ['basic' or 'target' or 'event', 'fulfillment', 'whole']"
+            )
+
+
+    def create_new_proto_obj(
+        self,
+        event: str,
+        trigger_fulfillment: Fulfillment = None,
+        target_page: str = None,
+        target_flow: str = None,
+        overwrite: bool = False
+    ) -> EventHandler:
+        """Create a new EventHandler.
+
+        Args:
+          event (str):
+            Required. The name of the event to handle.
+          trigger_fulfillment (Fulfillment):
+            The fulfillment to call when the event occurs.
+            Handling webhook errors with a fulfillment enabled with webhook
+            could cause infinite loop. It is invalid to specify
+            such fulfillment for a handler handling webhooks.
+          target_page (str):
+            The target page to transition to. Format:
+            ``projects/<Project ID>/locations/<Location ID>/
+              agents/<Agent ID>/flows/<Flow ID>/pages/<Page ID>``.
+            At most one of ``target_page`` and ``target_flow``
+            can be specified at the same time.
+          target_flow (str):
+            The target flow to transition to. Format:
+            ``projects/<Project ID>/locations/<Location ID>/
+              agents/<Agent ID>/flows/<Flow ID>``.
+            At most one of ``target_page`` and ``target_flow``
+            can be specified at the same time.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already
+            contains a EventHandler.
+
+        Returns:
+          An EventHandler object stored in proto_obj.
+        """
+        # Types error checking
+        if event and not isinstance(event, str):
+            raise ValueError("event should be a string.")
+        if (trigger_fulfillment and
+            not isinstance(trigger_fulfillment, Fulfillment)):
+            raise ValueError(
+                "The type of trigger_fulfillment should be a Fulfillment."
+            )
+        # Oneof error checking
+        if target_page and target_flow:
+            raise RuntimeError(
+                "At most one of `target_page` and `target_flow`"
+                " can be specified at the same time."
+            )
+        # `overwrite` parameter error checking
+        if self.proto_obj and not overwrite:
+            raise UserWarning(
+                "proto_obj already contains an EventHandler."
+                " If you wish to overwrite it, pass overwrite as True."
+            )
+        # Create the EventHandler
+        if overwrite or not self.proto_obj:
+            # Create empty Fulfillment in case user didn't pass any
+            if not trigger_fulfillment:
+                trigger_fulfillment = Fulfillment()
+
+            self.proto_obj = EventHandler(
+                event=event,
+                trigger_fulfillment=trigger_fulfillment,
+                target_page=target_page,
+                target_flow=target_flow
+            )
+
+        return self.proto_obj
diff --git a/src/dfcx_scrapi/builders/transition_route_groups.py b/src/dfcx_scrapi/builders/transition_route_groups.py
new file mode 100644
index 00000000..dd3a882a
--- /dev/null
+++ b/src/dfcx_scrapi/builders/transition_route_groups.py
@@ -0,0 +1,179 @@
+"""A set of builder methods to create CX proto resource objects"""
+
+# Copyright 2023 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, Union
+
+from google.cloud.dialogflowcx_v3beta1.types import TransitionRoute
+from google.cloud.dialogflowcx_v3beta1.types import TransitionRouteGroup
+from dfcx_scrapi.builders.builders_common import BuildersCommon
+from dfcx_scrapi.builders.routes import TransitionRouteBuilder
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+
+class TransitionRouteGroupBuilder(BuildersCommon):
+    """Base Class for CX TransitionRouteGroup builder."""
+
+    _proto_type = TransitionRouteGroup
+    _proto_type_str = "TransitionRouteGroup"
+
+
+    def __str__(self) -> str:
+        """String representation of the proto_obj."""
+        self._check_proto_obj_attr_exist()
+
+        transition_routes_str = "\n".join([
+            f"\n\n - Transition Route{i+1}:\n{str(TransitionRouteBuilder(tr))}"
+            for i, tr in enumerate(self.proto_obj.transition_routes)
+        ])
+
+        return (
+            f"display_name: {self.proto_obj.display_name}"
+            f"\nTransitionRoutes:\n{'-'*20}\n{transition_routes_str}"
+        )
+
+
+    def show_transition_route_group(self):
+        """Show the proto_obj information."""
+        self._check_proto_obj_attr_exist()
+
+        print(self)
+
+
+    def create_new_proto_obj(
+        self,
+        display_name: str,
+        transition_routes: Union[TransitionRoute, List[TransitionRoute]] = None,
+        overwrite: bool = False
+    ) -> TransitionRouteGroup:
+        """Create a new TransitionRouteGroup.
+
+        Args:
+          display_name (str):
+            Required. The human-readable name of the
+            transition route group, unique within the flow.
+            The display name can be no longer than 30 characters.
+          transition_routes (TransitionRoute | List[TransitionRoute]):
+            Transition routes associated with this TransitionRouteGroup.
+            Refer to `builders.routes.TransitionRouteBuilder` to build one.
+          overwrite (bool)
+            Overwrite the new proto_obj if proto_obj already
+            contains a TransitionRouteGroup.
+
+        Returns:
+          A TransitionRouteGroup object stored in proto_obj.
+        """
+        # Types error checking
+        if not (display_name and isinstance(display_name, str)):
+            raise ValueError("display_name should be a nonempty string.")
+        if transition_routes and not (
+            isinstance(transition_routes, TransitionRoute) or
+            (isinstance(transition_routes, list) and all(
+                isinstance(tr, TransitionRoute) for tr in transition_routes))
+        ):
+            raise ValueError(
+                "transition_routes should be either a TransitionRoute or"
+                " a list of TransitionRoutes."
+            )
+        # `overwrite` parameter error checking
+        if self.proto_obj and not overwrite:
+            raise UserWarning(
+                "proto_obj already contains a TransitionRouteGroup."
+                " If you wish to overwrite it, pass overwrite as True."
+            )
+        # Create the TransitionRouteGroup
+        if overwrite or not self.proto_obj:
+            if not transition_routes:
+                transition_routes = []
+            if not isinstance(transition_routes, list):
+                transition_routes = [transition_routes]
+            self.proto_obj = TransitionRouteGroup(
+                display_name=display_name,
+                transition_routes=transition_routes
+            )
+
+        return self.proto_obj
+
+
+    def add_transition_route(
+        self,
+        transition_routes: Union[TransitionRoute, List[TransitionRoute]]
+    ) -> TransitionRouteGroup:
+        """Add single or multiple TransitionRoutes to the TransitionRouteGroup.
+
+        Args:
+          transition_routes (TransitionRoute | List[TransitionRoute]):
+            A single or list of TransitionRoutes to add
+            to the TransitionRouteGroup existed in proto_obj.
+        Returns:
+          A TransitionRouteGroup object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        self._is_type_or_list_of_types(
+            transition_routes, TransitionRoute, "transition_routes"
+        )
+
+        if not isinstance(transition_routes, list):
+            transition_routes = [transition_routes]
+        self.proto_obj.transition_routes.extend(transition_routes)
+
+        return self.proto_obj
+
+
+    def remove_transition_route(
+        self,
+        transition_route: TransitionRoute = None,
+        intent: str = None,
+        condition: str = None
+    ) -> TransitionRouteGroup:
+        """Remove a transition route from the TransitionRouteGroup.
+
+        At least one of the `transition_route`, `intent`, or `condition` should
+        be specfied.
+
+        Args:
+            transition_route (TransitionRoute):
+              The TransitionRoute to remove from the TransitionRouteGroup.
+            intent (str):
+              TransitionRoute's intent that should be removed from
+              the TransitionRouteGroup.
+            condition (str):
+              TransitionRoute's condition that should be removed from
+              the TransitionRouteGroup.
+
+        Returns:
+          A TransitionRouteGroup object stored in proto_obj.
+        """
+        self._check_proto_obj_attr_exist()
+
+        new_routes = []
+        for tr in self.proto_obj.transition_routes:
+            if self._match_transition_route(
+                transition_route=tr, target_route=transition_route,
+                intent=intent, condition=condition
+            ):
+                continue
+            new_routes.append(tr)
+        self.proto_obj.transition_routes = new_routes
+
+        return self.proto_obj
diff --git a/src/dfcx_scrapi/core/agents.py b/src/dfcx_scrapi/core/agents.py
index ec99ee88..7e441528 100644
--- a/src/dfcx_scrapi/core/agents.py
+++ b/src/dfcx_scrapi/core/agents.py
@@ -1,6 +1,6 @@
 """Agent Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -53,8 +53,18 @@ def __init__(
             self.agent_id = agent_id
             self.client_options = self._set_region(agent_id)
 
-    def _build_list_agents_client_request(self, location_id):
-        """Builds the List Agents Request object."""
+    @scrapi_base.api_call_counter_decorator
+    def _list_agents_client_request(self, location_id) -> List[
+        types.agent.Agent
+    ]:
+        """Builds the List Agents Request object.
+
+        Args:
+          location_id: The GCP Location ID in the following format:
+            `projects/<project_id>/locations/<location>`
+
+        Returns:
+          List of types.agent.Agent"""
 
         request = types.agent.ListAgentsRequest()
         request.parent = location_id
@@ -64,31 +74,34 @@ def _build_list_agents_client_request(self, location_id):
             credentials=self.creds, client_options=client_options
         )
 
-        return client, request
+        response = client.list_agents(request)
+
+        agents = []
+        for page in response.pages:
+            for agent in page.agents:
+                agents.append(agent)
+
+        return agents
 
     def list_agents(
         self,
-        location_id: str = None,
-        project_id: str = None) -> List[types.Agent]:
+        project_id: str,
+        location_id: str = None) -> List[types.Agent]:
         """Get list of all CX agents in a given GCP Region or Project.
 
-        This method allows you to provide a specific Location ID consisting of
-        a GCP Project ID and Location ID (i.e. GCP Region Name) to retrieve all
-        of the CX agents associated with that Project/Region. Optionally, you
-        can provide just the Project ID and the funciton will traverse ALL
-        available GCP regions to list ALL agents across the regions.
+        This method allows you to provide a GCP Project ID to retrieve all of
+        the CX agents across ALL available GCP region. If the optional location
+        ID is provided, the method will only pull the agents for that region.
 
         Args:
-          location_id: The GCP Project/Location ID in the following format
-              `projects/<GCP PROJECT ID>/locations/<LOCATION ID>`
-              `projects/my-gcp-project/locations/us-central1`
-          project_id: The GCP Project ID as a string
+          project_id: The GCP Project ID. Ex: `my-cool-gcp-project`
+          location_id: The GCP Location ID. Ex: `global`, `us-central1`, etc.
 
         Returns:
           List of Agent objects
         """
 
-        if project_id:
+        if not location_id:
             region_list = [
                 "global",
                 "us-central1",
@@ -105,26 +118,15 @@ def list_agents(
             agents = []
             for region in region_list:
                 location_path = f"projects/{project_id}/locations/{region}"
-                client, request = self._build_list_agents_client_request(
-                    location_path
-                )
-
-                agents += self.list_agents(location_id=location_path)
+                agents += self._list_agents_client_request(location_path)
 
         else:
-            client, request = self._build_list_agents_client_request(
-                location_id
-            )
-
-            response = client.list_agents(request)
-
-            agents = []
-            for page in response.pages:
-                for agent in page.agents:
-                    agents.append(agent)
+            location_path = f"projects/{project_id}/locations/{location_id}"
+            agents = self._list_agents_client_request(location_path)
 
         return agents
 
+    @scrapi_base.api_call_counter_decorator
     def get_agent(self, agent_id: str) -> types.Agent:
         """Retrieves a single CX Agent resource object.
 
@@ -178,14 +180,12 @@ def get_agent_by_display_name(
         """
 
         if location_id:
-            agent_list = self.list_agents(
-                location_id=location_id
-                )
+            agent_list = self._list_agents_client_request(location_id)
 
         elif region:
-            agent_list = self.list_agents(
-                location_id=f"projects/{project_id}/locations/{region}"
-            )
+            agent_list = self._list_agents_client_request(
+                f"projects/{project_id}/locations/{region}"
+                )
         else:
             agent_list = self.list_agents(project_id=project_id)
 
@@ -216,6 +216,7 @@ def get_agent_by_display_name(
 
         return matched_agent
 
+    @scrapi_base.api_call_counter_decorator
     def create_agent(
         self,
         project_id: str,
@@ -237,38 +238,47 @@ def create_agent(
           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:
           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_obj = obj
+            agent_obj.name = ""
 
         else:
-            agent = types.agent.Agent()
-            parent = f"projects/{project_id}/locations/{gcp_region}"
-            agent.display_name = display_name
-            agent.default_language_code = "en"
-            agent.time_zone = "America/Chicago"
+            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"
+            )
 
             # set optional args as agent attributes
             for key, value in kwargs.items():
-                setattr(agent, key, value)
+                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
 
 
+    @scrapi_base.api_call_counter_decorator
     def validate_agent(
         self,
-        agent_id: str = None,
+        agent_id: str,
+        language_code: str = "en",
         timeout: float = None) -> Dict:
         """Initiates the Validation of the CX Agent or Flow.
 
@@ -289,6 +299,7 @@ def validate_agent(
 
         request = types.agent.ValidateAgentRequest()
         request.name = agent_id
+        request.language_code = language_code
 
         client_options = self._set_region(agent_id)
         client = services.agents.AgentsClient(
@@ -302,6 +313,7 @@ def validate_agent(
         return val_dict
 
 
+    @scrapi_base.api_call_counter_decorator
     def get_validation_result(
         self,
         agent_id: str = None,
@@ -347,11 +359,16 @@ def get_validation_result(
         return val_results_dict
 
 
+    @scrapi_base.api_call_counter_decorator
     def export_agent(
         self,
         agent_id: str,
         gcs_bucket_uri: str,
-        environment_display_name: str = None
+        environment_display_name: str = None,
+        data_format: str = "BLOB",
+        git_branch: str = None,
+        git_commit_message: str = None,
+        include_bq_export_settings: bool = False
     ) -> str:
         """Exports the specified CX agent to Google Cloud Storage bucket.
 
@@ -361,17 +378,39 @@ def export_agent(
           gcs_bucket_uri: The Google Cloud Storage bucket/filepath to export the
             agent to in the following format:
               `gs://<bucket-name>/<object-name>`
-          environment_display_name: (Optional) CX Agent environment display name
+          environment_display_name: CX Agent environment display name
             as string. If not set, DRAFT environment is assumed.
+          data_format: Optional. The data format of the exported agent. If not
+            specified, ``BLOB`` is assumed.
+          git_branch: Optional. The Git branch to commit the exported agent to.
+          git_commit_message: Optional. The Git Commit message to send. Only
+            applicable if using `git_branch` arg.
+          include_bigquery_export_settings: Will exclude or included the BQ
+            settings on export.
 
         Returns:
           A Long Running Operation (LRO) ID that can be used to
             check the status of the export using
               dfcx_scrapi.core.operations->get_lro()
         """
+        blob_format = types.agent.ExportAgentRequest.DataFormat(1)
+        json_format = types.agent.ExportAgentRequest.DataFormat(4)
+
         request = types.agent.ExportAgentRequest()
         request.name = agent_id
         request.agent_uri = gcs_bucket_uri
+        request.include_bigquery_export_settings = include_bq_export_settings
+
+        if data_format in ["JSON", "ZIP", "JSON_PACKAGE"]:
+            request.data_format = json_format
+        else:
+            request.data_format = blob_format
+
+        if git_branch:
+            git_settings = types.agent.ExportAgentRequest.GitDestination()
+            git_settings.tracking_branch = git_branch
+            git_settings.commit_message = git_commit_message
+            request.git_destination = git_settings
 
         if environment_display_name:
             self._environments = environments.Environments(creds=self.creds)
@@ -395,6 +434,7 @@ def export_agent(
         return response.operation.name
 
 
+    @scrapi_base.api_call_counter_decorator
     def restore_agent(self, agent_id: str, gcs_bucket_uri: str) -> str:
         """Restores a CX agent from a gcs_bucket location.
 
@@ -404,8 +444,8 @@ def restore_agent(self, agent_id: str, gcs_bucket_uri: str) -> str:
         Args:
           agent_id: CX Agent ID string in the following format
             projects/<PROJECT ID>/locations/<LOCATION ID>/agents/<AGENT ID>
-          gcs_bucket_uri: The Google Cloud Storage bucket/filepath to export the
-            agent to in the following format:
+          gcs_bucket_uri: The Google Cloud Storage bucket/filepath to restore
+            the agent from in the following format:
               `gs://<bucket-name>/<object-name>`
 
         Returns:
@@ -426,6 +466,7 @@ def restore_agent(self, agent_id: str, gcs_bucket_uri: str) -> str:
 
         return response.operation.name
 
+    @scrapi_base.api_call_counter_decorator
     def update_agent(
         self, agent_id: str, obj: types.Agent = None, **kwargs
     ) -> types.Agent:
@@ -463,6 +504,7 @@ def update_agent(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def delete_agent(self, agent_id: str) -> str:
         """Deletes the specified Dialogflow CX Agent.
 
diff --git a/src/dfcx_scrapi/core/changelogs.py b/src/dfcx_scrapi/core/changelogs.py
index 24cc6020..db7ccf55 100644
--- a/src/dfcx_scrapi/core/changelogs.py
+++ b/src/dfcx_scrapi/core/changelogs.py
@@ -1,6 +1,6 @@
 """A collection of Methods to support the Change History feature in DFCX."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -23,7 +23,7 @@
 
 from google.cloud.dialogflowcx_v3beta1 import services
 from google.cloud.dialogflowcx_v3beta1 import types
-from dfcx_scrapi.core.scrapi_base import ScrapiBase
+from dfcx_scrapi.core import scrapi_base
 
 # logging config
 logging.basicConfig(
@@ -33,7 +33,7 @@
 )
 
 
-class Changelogs(ScrapiBase):
+class Changelogs(scrapi_base.ScrapiBase):
     """Tools class that contains methods to support Change History feature."""
 
     def __init__(
@@ -76,6 +76,7 @@ def _validate_epoch_time(create_time_epoch_seconds: str):
         else:
             return True
 
+    @scrapi_base.api_call_counter_decorator
     def list_changelogs(self, agent_id: str = None, **kwargs):
         """Lists all Change History logs for a CX Agent.
 
@@ -147,6 +148,7 @@ def list_changelogs(self, agent_id: str = None, **kwargs):
 
         return changelogs
 
+    @scrapi_base.api_call_counter_decorator
     def get_changelog(self, changelog_id: str):
         """Get a single changelog resource object.
 
@@ -223,7 +225,7 @@ def changelogs_to_dataframe(
                 ],
             )
 
-            df = df.append(log_data)
+            df = pd.concat([df, log_data], ignore_index=True)
 
         df = df.reset_index(drop=True)
 
diff --git a/src/dfcx_scrapi/core/conversation.py b/src/dfcx_scrapi/core/conversation.py
index 0a17a2a7..ce354e7b 100644
--- a/src/dfcx_scrapi/core/conversation.py
+++ b/src/dfcx_scrapi/core/conversation.py
@@ -1,6 +1,6 @@
 """DFCX End to End Conversation Functions"""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -19,7 +19,7 @@
 import traceback
 import uuid
 
-from typing import Dict
+from typing import Dict, Any
 from operator import attrgetter
 from threading import Thread
 
@@ -64,16 +64,12 @@ def __init__(
             agent_id=agent_id,
         )
 
-        logging.info(
-            f"create conversation with creds_path: \
-                {creds_path} | agent_id: {agent_id}",
-        )
-
-        if agent_id or config["agent_path"]:
-            self.agent_id = agent_id or config["agent_path"]
-
-        self.language_code = language_code or config["language_code"]
+        logging.debug(
+            "create conversation with creds_path: %s | agent_id: %s",
+            creds_path, agent_id)
 
+        self.agent_id = self._set_agent_id(agent_id, config)
+        self.language_code = self._set_language_code(language_code, config)
         self.start_time = None
         self.query_result = None
         self.session_id = None
@@ -83,6 +79,46 @@ def __init__(
         self.flows = flows.Flows(creds=self.creds)
         self.pages = pages.Pages(creds=self.creds)
 
+    @staticmethod
+    def _set_language_code(language_code: str, config: Dict[str, Any]) -> str:
+        """Determines how to set the language_code based on user inputs.
+
+        We implement this for backwards compatability.
+        """
+        # Config will take precedence if provided
+        if config:
+            config_lang_code = config.get("language_code", None)
+
+            # We'll only return if it exist in the config on the off chance that
+            # some users have provided the langauge_code as a top level arg in
+            # addition to providing the config
+            if config_lang_code:
+                return config_lang_code
+
+        return language_code
+
+    @staticmethod
+    def _set_agent_id(input_agent_id: str, config: Dict[str, Any]) -> str:
+        """Determines how to set the agent_id based on user inputs.
+
+        We implement this for backwards compatability.
+        """
+
+        # Config will take precedence if provided
+        if config:
+            config_agent_path = config.get("agent_path", None)
+
+            # We'll only return if it exist in the config on the off chance that
+            # some users have provided the agent_id as a top level arg in
+            # addition to providing the config
+            if config_agent_path:
+                return config_agent_path
+
+        elif input_agent_id:
+            return input_agent_id
+
+        return None
+
     @staticmethod
     def _get_match_type_from_map(match_type: int):
         """Translates the match_type enum int value into a more descriptive
@@ -96,6 +132,8 @@ def _get_match_type_from_map(match_type: int):
             4: "NO_MATCH",
             5: "NO_INPUT",
             6: "EVENT",
+            8: "KNOWLEDGE_CONNECTOR",
+            9: "LLM"
         }
 
         return match_type_map[match_type]
@@ -109,7 +147,7 @@ def _validate_test_set_input(test_set: pd.DataFrame):
         invalid_pages = set(test_set.page_display_name[mask].to_list())
 
         if invalid_pages:
-            raise Exception(
+            raise UserWarning(
                 "The following Pages are invalid and missing Page "
                 f"IDs: \n{invalid_pages}\n\nPlease ensure that your Page "
                 "Display Names do not contain typos.\nFor Default Start Page "
@@ -122,10 +160,9 @@ def progress_bar(current, total, bar_length=50, type_="Progress"):
         percent = float(current) * 100 / total
         arrow = "-" * int(percent / 100 * bar_length - 1) + ">"
         spaces = " " * (bar_length - len(arrow))
-        print(
-            f"{type_}({current}/{total})" + f"[{arrow}{spaces}] {percent:.2f}%",
-            end="\r",
-        )
+        logging.info(
+            f"{type_}({current}/{total})" + f"[{arrow}{spaces}] {percent:.2f}%"
+            )
 
     @staticmethod
     def _build_query_params_object(parameters, current_page, disable_webhook):
@@ -257,19 +294,6 @@ def _page_id_mapper(self):
             flow_mapped["page_id"] = flow_mapped.index
 
             flow_mapped = flow_mapped.rename(columns={0: "page_display_name"})
-
-            # add start page
-            start_page_id = flow_id + "/pages/START_PAGE"
-            flow_mapped = pd.concat(
-                [
-                    flow_mapped,
-                    pd.DataFrame(
-                        columns=["page_display_name", "page_id"],
-                        data=[["START_PAGE", start_page_id]],
-                    ),
-                ]
-            )
-
             flow_mapped.insert(0, "flow_display_name", flow_map[flow_id])
             agent_pages_map = pd.concat([agent_pages_map, flow_mapped])
 
@@ -325,8 +349,8 @@ 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["target_page"] = results["target_page"]
         test_set_mapped["match"] = results["match"]
@@ -347,7 +371,7 @@ def set_agent_env(self, param, value):
         self.agent_env[param] = value
 
     def checkpoint(self, msg=None, start=False):
-        """Print a checkpoint to time progress and debug bottleneck"""
+        """Log a checkpoint to time progress and debug bottleneck"""
         if start:
             start_time = time.perf_counter()
             self.start_time = start_time
@@ -356,8 +380,9 @@ def checkpoint(self, msg=None, start=False):
         duration = round((time.perf_counter() - start_time), 2)
         if duration > 2:
             if msg:
-                print(f"{duration:0.2f}s {msg}")
+                logging.info(f"{duration:0.2f}s {msg}")
 
+    @scrapi_base.api_call_counter_decorator
     def reply(
         self,
         send_obj: Dict[str, str],
@@ -385,7 +410,7 @@ def reply(
         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.
+            intent_name, match_type, match, and params.
         """
         text = send_obj.get("text")
         send_params = send_obj.get("params")
@@ -505,35 +530,12 @@ def reply(
             query_result.match.match_type
         )
         reply["match"] = query_result.match
-        reply["other_intents"] = self.format_other_intents(query_result)
         reply["params"] = params
 
         logging.debug("reply %s", reply)
 
         return reply
 
-    def format_other_intents(self, query_result):
-        """Unwind protobufs into more friendly dict"""
-        other_intents = query_result.diagnostic_info.get(
-            "Alternative Matched Intents"
-        )
-        items = []
-        rank = 0
-        for alt in other_intents:
-            items.append(
-                {
-                    "name": alt.get("DisplayName"),
-                    "score": alt.get("Score"),
-                    "rank": rank,
-                }
-            )
-            rank += 1
-
-        if self:
-            return items
-
-        return None
-
     def getpath(self, obj, xpath, default=None):
         """Get data at a pathed location out of object internals"""
         elem = obj
diff --git a/src/dfcx_scrapi/core/entity_types.py b/src/dfcx_scrapi/core/entity_types.py
index 3c8d42e8..526d840c 100644
--- a/src/dfcx_scrapi/core/entity_types.py
+++ b/src/dfcx_scrapi/core/entity_types.py
@@ -1,6 +1,6 @@
 """Entity Types Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -22,7 +22,7 @@
 from google.cloud.dialogflowcx_v3beta1 import types
 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(
@@ -32,7 +32,7 @@
 )
 
 
-class EntityTypes(ScrapiBase):
+class EntityTypes(scrapi_base.ScrapiBase):
     """Core Class for CX Entity Type Resource functions."""
 
     def __init__(
@@ -43,6 +43,7 @@ def __init__(
         scope=False,
         entity_id: str = None,
         agent_id: str = None,
+        language_code: str = "en"
     ):
         super().__init__(
             creds_path=creds_path,
@@ -53,6 +54,7 @@ def __init__(
 
         self.entity_id = entity_id
         self.agent_id = agent_id
+        self.language_code = language_code
 
 
     @staticmethod
@@ -243,11 +245,13 @@ def get_entities_map(self, agent_id: str = None, reverse=False):
 
         return entities_dict
 
-    def list_entity_types(self, agent_id: str = None):
+    @scrapi_base.api_call_counter_decorator
+    def list_entity_types(self, agent_id: str, language_code: str = "en"):
         """Returns a list of Entity Type objects.
 
         Args:
           agent_id: the formatted CX Agent ID to use
+          language_code: Specifies the language of the Entity Types listed
 
         Returns:
           List of Entity Type objects
@@ -257,6 +261,7 @@ def list_entity_types(self, agent_id: str = None):
 
         request = types.entity_type.ListEntityTypesRequest()
         request.parent = agent_id
+        request.language_code = language_code
 
         client_options = self._set_region(agent_id)
         client = services.entity_types.EntityTypesClient(
@@ -272,11 +277,13 @@ def list_entity_types(self, agent_id: str = None):
 
         return entities
 
-    def get_entity_type(self, entity_id: str = None):
+    @scrapi_base.api_call_counter_decorator
+    def get_entity_type(self, entity_id: str = None, language_code: str = "en"):
         """Returns a single Entity Type object.
 
         Args:
           entity_id: the formatted CX Entity ID to get
+          language_code: Specifies the language of the Entity Types listed
 
         Returns:
           The single Entity Type object
@@ -288,21 +295,32 @@ def get_entity_type(self, entity_id: str = None):
         client = services.entity_types.EntityTypesClient(
             credentials=self.creds, client_options=client_options
         )
-        response = client.get_entity_type(name=entity_id)
+        request = types.entity_type.GetEntityTypeRequest()
+        request.name = entity_id
+        request.language_code = language_code
+
+        response = client.get_entity_type(request=request)
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     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.
+          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:
           A copy of the Entity Type object created
@@ -312,14 +330,21 @@ def create_entity_type(
 
         # 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(
@@ -329,7 +354,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
@@ -340,6 +365,7 @@ def create_entity_type(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def update_entity_type(
         self,
         entity_type_id: str = None,
@@ -394,8 +420,9 @@ def update_entity_type(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def delete_entity_type(self, entity_id: str = None, obj=None) -> None:
-        """Deletes a single Entity Type resouce object.
+        """Deletes a single Entity Type resource object.
 
         Args:
           entity_id: the formatted CX Entity ID to delete
diff --git a/src/dfcx_scrapi/core/environments.py b/src/dfcx_scrapi/core/environments.py
index 047ad52f..6e516cb2 100644
--- a/src/dfcx_scrapi/core/environments.py
+++ b/src/dfcx_scrapi/core/environments.py
@@ -1,6 +1,6 @@
 """CX Environments Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -38,7 +38,7 @@ class Environments(scrapi_base.ScrapiBase):
     def __init__(
         self,
         creds_path: str = None,
-        creds_dict: Dict[str,str] = None,
+        creds_dict: Dict[str, str] = None,
         creds: service_account.Credentials = None,
         agent_id: str = None,
     ):
@@ -129,7 +129,8 @@ def get_environments_map(
 
         return environments_dict
 
-    def list_environments(self, agent_id:str=None):
+    @scrapi_base.api_call_counter_decorator
+    def list_environments(self, agent_id: str = None):
         """List all Versions for a given Flow"""
 
         if not agent_id:
@@ -152,9 +153,10 @@ def list_environments(self, agent_id:str=None):
 
         return environments
 
+    @scrapi_base.api_call_counter_decorator
     def get_environment(
         self,
-        environment_id:str) -> types.environment.Environment:
+        environment_id: str) -> types.environment.Environment:
         """Get Environment object for specified environment ID.
 
         Args:
@@ -178,8 +180,8 @@ def get_environment(
 
     def get_environment_by_display_name(
         self,
-        display_name:str,
-        agent_id:str) -> types.environment.Environment:
+        display_name: str,
+        agent_id: str) -> types.environment.Environment:
         """Get Environment object for specific environment by its display name.
 
         Args:
@@ -201,10 +203,11 @@ def get_environment_by_display_name(
 
         return result
 
+    @scrapi_base.api_call_counter_decorator
     def create_environment(
         self,
-        environment:types.environment.Environment,
-        agent_id:str=None):
+        environment: types.environment.Environment,
+        agent_id: str = None):
         """Create a new environment for a specified agent.
         Args:
           environment: The environment to create.
@@ -235,10 +238,10 @@ def create_environment(
 
     def create_environment_by_display_name(
         self,
-        display_name:str,
-        version_configs:List[Tuple[str,str]],
-        description:str=None,
-        agent_id:str=None):
+        display_name: str,
+        version_configs: List[Tuple[str, str]],
+        description: str = None,
+        agent_id: str = None):
         """Create a new environment for a specified agent.
         Args:
           display_name: The display name of the Environment to create
@@ -286,10 +289,11 @@ def create_environment_by_display_name(
         return response
 
 
+    @scrapi_base.api_call_counter_decorator
     def update_environment(
         self,
         environment_id: str,
-        environment_obj:types.Environment = None,
+        environment_obj: types.Environment = None,
         **kwargs):
         """Update an existing environment for a specified agent.
 
@@ -330,7 +334,8 @@ def update_environment(
         return response
 
 
-    def delete_environment(self, environment_id:str):
+    @scrapi_base.api_call_counter_decorator
+    def delete_environment(self, environment_id: str):
         """Delete a specified environment.
 
         Args:
@@ -350,10 +355,11 @@ def delete_environment(self, environment_id:str):
         client.delete_environment(request)
 
 
+    @scrapi_base.api_call_counter_decorator
     def deploy_flow_to_environment(
         self,
-        environment_id:str,
-        flow_version:str):
+        environment_id: str,
+        flow_version: str):
         """Deploys a flow to the specified environment.
 
          Args:
@@ -382,9 +388,10 @@ def deploy_flow_to_environment(
         return response
 
 
+    @scrapi_base.api_call_counter_decorator
     def lookup_environment_history(
         self,
-        environment_id:str) -> List[types.Environment]:
+        environment_id: str) -> List[types.Environment]:
         """Looks up the history of the specified environment.
 
         Args:
@@ -413,7 +420,8 @@ def lookup_environment_history(
 
         return history
 
-    def list_continuous_test_results(self, environment_id:str):
+    @scrapi_base.api_call_counter_decorator
+    def list_continuous_test_results(self, environment_id: str):
         """Fetches a list of continuous test results for a given environment.
 
         Args:
diff --git a/src/dfcx_scrapi/core/experiments.py b/src/dfcx_scrapi/core/experiments.py
index 8e2ba592..eac0c84e 100644
--- a/src/dfcx_scrapi/core/experiments.py
+++ b/src/dfcx_scrapi/core/experiments.py
@@ -1,6 +1,6 @@
 """CX Experiment Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -19,7 +19,7 @@
 from typing import Dict
 from google.cloud.dialogflowcx_v3beta1 import services
 from google.cloud.dialogflowcx_v3beta1 import types
-from dfcx_scrapi.core.scrapi_base import ScrapiBase
+from dfcx_scrapi.core import scrapi_base
 
 # logging config
 logging.basicConfig(
@@ -34,7 +34,7 @@
 ]
 
 
-class ScrapiExperiments(ScrapiBase):
+class ScrapiExperiments(scrapi_base.ScrapiBase):
     """Wrapper for working with Experiments"""
 
     def __init__(
@@ -55,7 +55,8 @@ def __init__(
 
         logging.info("created %s", self.agent_id)
 
-    def list_experiments(self, environment_id=None):
+    @scrapi_base.api_call_counter_decorator
+    def list_experiments(self, environment_id: str = None):
         """List out experiments.
 
         Args:
@@ -75,7 +76,7 @@ def list_experiments(self, environment_id=None):
             client_options=client_options, credentials=self.creds
         )
         response = client.list_experiments(request)
-        blob = ScrapiBase.cx_object_to_json(response)
+        blob = scrapi_base.ScrapiBase.cx_object_to_json(response)
 
         if len(blob) < 1:
             logging.warning(
diff --git a/src/dfcx_scrapi/core/flows.py b/src/dfcx_scrapi/core/flows.py
index 858bde48..da236136 100644
--- a/src/dfcx_scrapi/core/flows.py
+++ b/src/dfcx_scrapi/core/flows.py
@@ -1,6 +1,6 @@
 """Flow Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -15,11 +15,13 @@
 # limitations under the License.
 
 import logging
+import time
 from typing import Dict, List
 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
+from dfcx_scrapi.core import pages
 
 # logging config
 logging.basicConfig(
@@ -28,6 +30,8 @@
     datefmt="%Y-%m-%d %H:%M:%S",
 )
 
+logger = logging.getLogger(__name__)
+
 
 class Flows(scrapi_base.ScrapiBase):
     """Core Class for CX Flow Resource functions."""
@@ -52,12 +56,58 @@ def __init__(
             self.flow_id = flow_id
 
         self.agent_id = agent_id
-
-    def get_flows_map(
-        self,
-        agent_id: str,
-        reverse=False
+        self.pages = pages.Pages(creds=self.creds)
+
+    # 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:
@@ -82,6 +132,39 @@ def get_flows_map(
 
         return flows_dict
 
+    def get_flow_page_map(
+            self, agent_id: str, rate_limit: float = 1.0
+            ) -> Dict[str, Dict[str, str]]:
+        """Exports a user friendly dict containing Flows, Pages, and IDs
+        This method builds on top of `get_flows_map` and builds out a nested
+        dictionary containing all of the Page Display Names and UUIDs contained
+        within each Flow. Output Format:
+          {
+            <FLOW_DISPLAY_NAME>: {
+                'id': <FLOW_UUID>
+                'pages': { <PAGE_DISPLAY_NAME> : <PAGE_UUID> }
+            }
+          }
+
+        Args:
+          agent_id: the formatted CX Agent ID to use
+
+        Returns:
+          Dictionary containing Flow Names/UUIDs and Page Names/UUIDs
+        """
+        flow_page_map = {}
+
+        flows_map = self.get_flows_map(agent_id, reverse=True)
+
+        for flow in flows_map:
+            pages_map = self.pages.get_pages_map(
+                flows_map[flow], reverse=True)
+            flow_page_map[flow] = {"id": flows_map[flow], "pages": pages_map}
+            time.sleep(rate_limit)
+
+        return flow_page_map
+
+    @scrapi_base.api_call_counter_decorator
     def train_flow(self, flow_id: str) -> str:
         """Trains the specified flow.
 
@@ -101,16 +184,15 @@ 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]:
+    @scrapi_base.api_call_counter_decorator
+    def list_flows(self, agent_id: str) -> List[types.Flow]:
         """Get a List of all Flows in the current Agent.
 
         Args:
@@ -137,9 +219,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.
 
@@ -151,16 +231,13 @@ def get_flow_by_display_name(
           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."
             )
 
@@ -168,6 +245,7 @@ def get_flow_by_display_name(
 
         return flow
 
+    @scrapi_base.api_call_counter_decorator
     def get_flow(self, flow_id: str) -> types.Flow:
         """Get a single CX Flow object.
 
@@ -186,11 +264,58 @@ def get_flow(self, flow_id: str) -> types.Flow:
 
         return response
 
-    def update_flow(
+    @scrapi_base.api_call_counter_decorator
+    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
+
+    @scrapi_base.api_call_counter_decorator
+    def update_flow(
+        self, flow_id: str, obj: types.Flow = None, **kwargs
     ) -> types.Flow:
         """Update a single specific CX Flow object.
 
@@ -222,7 +347,7 @@ def update_flow(
 
         return response
 
-    def update_nlu_settings(self, flow_id:str, **kwargs):
+    def update_nlu_settings(self, flow_id: str, **kwargs):
         """Updates flow to new NLU setting.
 
         Args:
@@ -240,11 +365,9 @@ def update_nlu_settings(self, flow_id:str, **kwargs):
             setattr(current_settings, key, value)
         self.update_flow(flow_id=flow_id, nlu_settings=current_settings)
 
+    @scrapi_base.api_call_counter_decorator
     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.
 
@@ -273,11 +396,8 @@ def export_flow(
 
         return response.result()
 
-    def export_flow_inline(
-        self,
-        flow_id: str,
-        ref_flows: bool = True
-    ) -> bytes:
+    @scrapi_base.api_call_counter_decorator
+    def export_flow_inline(self, flow_id: str, ref_flows: bool = True) -> bytes:
         """Export a Flow, returning uncompressed raw byte content for flow.
 
         Args:
@@ -291,7 +411,6 @@ def export_flow_inline(
         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
@@ -300,12 +419,13 @@ def export_flow_inline(
 
         return (response.result()).flow_content
 
+    @scrapi_base.api_call_counter_decorator
     def import_flow(
         self,
         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.
@@ -348,11 +468,8 @@ def import_flow(
 
         return response
 
-    def delete_flow(
-        self,
-        flow_id: str,
-        force: bool = False
-    ):
+    @scrapi_base.api_call_counter_decorator
+    def delete_flow(self, flow_id: str, force: bool = False):
         """Deletes a single CX Flow Object resource.
 
         Args:
diff --git a/src/dfcx_scrapi/core/intents.py b/src/dfcx_scrapi/core/intents.py
index 45176bb9..74e93701 100644
--- a/src/dfcx_scrapi/core/intents.py
+++ b/src/dfcx_scrapi/core/intents.py
@@ -1,6 +1,6 @@
 """Intent Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -15,7 +15,6 @@
 # limitations under the License.
 
 from collections import defaultdict
-import json
 import logging
 from typing import Dict, List, Tuple
 import pandas as pd
@@ -24,7 +23,7 @@
 from google.cloud.dialogflowcx_v3beta1 import types
 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(
@@ -34,7 +33,7 @@
 )
 
 
-class Intents(ScrapiBase):
+class Intents(scrapi_base.ScrapiBase):
     """Core Class for CX Intent Resource functions."""
 
     def __init__(
@@ -60,122 +59,112 @@ def __init__(
         if agent_id:
             self.agent_id = agent_id
 
+    @staticmethod
+    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)
+
+        return intent_df
 
     @staticmethod
-    def intent_proto_to_dataframe(
-        obj: types.Intent, mode: str = "basic"
-    ) -> pd.DataFrame:
-        """Converts an intent protobuf object to a Pandas DataFrame.
+    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
+            })
 
-        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.
+        if part.parameter_id:
+            intent_dict.update(params_dict[part.parameter_id])
+        elif intent_dict.get("entity_type"):
 
-        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.")
+            # Remove existing parameter_id if exist
+            key_to_remove = [
+                "parameter_id", "entity_type",
+                "is_list", "redact",
+                ]
 
-        if mode == "basic":
-            df = pd.DataFrame(columns=["display_name", "training_phrase"])
+            for key in key_to_remove:
+                intent_dict.pop(key)
 
-            intent_dict = {"display_name": str(obj.display_name)}
+        return intent_dict
 
-            if not obj.training_phrases:
-                row = pd.DataFrame.from_dict(
-                    intent_dict, orient="index"
-                ).transpose()
-                df = pd.concat([df, row], ignore_index=True)
-            else:
-                for tp in obj.training_phrases:
-                    parts_list = [part.text for part in tp.parts]
-                    intent_dict.update({"training_phrase": "".join(parts_list)})
+    def process_basic_mode_proto(self, obj: types.Intent):
+        """Process Intent Proto in basic mode."""
+        intent_df = pd.DataFrame(columns=["display_name", "training_phrase"])
 
-                    row = pd.DataFrame.from_dict(
-                        intent_dict, orient="index"
-                    ).transpose()
-                    df = pd.concat([df, row], ignore_index=True)
+        intent_dict = {"display_name": str(obj.display_name)}
 
-            return df
+        if not obj.training_phrases:
+            intent_df = self.concat_dict_and_df(intent_df, intent_dict)
 
-        elif mode == "advanced":
-            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",
-            ])
+        else:
+            for phrase in obj.training_phrases:
+                parts_list = [part.text for part in phrase.parts]
+                intent_dict.update({"training_phrase": "".join(parts_list)})
 
-            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),
-            }
+                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."""
 
-            # labels
-            intent_dict["labels"] = ",".join([
-                key if key == val else f"{key}:{val}"
-                for key, val in obj.labels.items()
+        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",
             ])
-            # 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
+
+        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),
             }
-            # training phrases
-            if not obj.training_phrases:
-                row = pd.DataFrame.from_dict(
-                    intent_dict, orient="index"
-                ).transpose()
-                df = pd.concat([df, row], ignore_index=True)
-            else:
-                for tp_count, tp in enumerate(obj.training_phrases):
-                    intent_dict.update({
-                        "id": str(tp.id),
-                        "repeat_count": int(tp.repeat_count),
-                        "training_phrase_idx": tp_count,
-                    })
-                    for part_count, part in enumerate(tp.parts):
-                        intent_dict.update({
-                            "text": part.text,
-                            "text_idx": part_count
-                        })
-                        if part.parameter_id:
-                            intent_dict.update(params_dict[part.parameter_id])
-                        elif intent_dict.get("entity_type"):
-                            # Remove existing parameter_id if exist
-                            key_to_remove = [
-                                "parameter_id", "entity_type",
-                                "is_list", "redact",
-                            ]
-                            for key in key_to_remove:
-                                intent_dict.pop(key)
-                        # Add to Dataframe
-                        row = pd.DataFrame.from_dict(
-                            intent_dict, orient="index"
-                        ).transpose()
-                        df = pd.concat([df, row], ignore_index=True)
-            return df
+            for param in obj.parameters
+        }
+        # training phrases
+        if not obj.training_phrases:
+            intent_df = self.concat_dict_and_df(intent_df, intent_dict)
+
         else:
-            raise ValueError("Mode types: [basic, advanced]")
+            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(
@@ -356,6 +345,43 @@ 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.
 
@@ -383,6 +409,7 @@ def get_intents_map(self, agent_id: str = None, reverse: bool = False):
 
         return intents_dict
 
+    @scrapi_base.api_call_counter_decorator
     def list_intents(
         self,
         agent_id: str = None,
@@ -419,6 +446,7 @@ def list_intents(
 
         return intents
 
+    @scrapi_base.api_call_counter_decorator
     def get_intent(
         self,
         intent_id: str = None,
@@ -451,67 +479,43 @@ def get_intent(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     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
-                }
-            ]
-        }
+          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()
 
@@ -519,7 +523,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(
@@ -530,6 +534,7 @@ def create_intent(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def update_intent(
         self,
         intent_id: str = None,
@@ -581,6 +586,7 @@ def update_intent(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def delete_intent(self, intent_id: str, obj: types.Intent = None) -> None:
         """Deletes an intent by Intent ID.
 
diff --git a/src/dfcx_scrapi/core/operations.py b/src/dfcx_scrapi/core/operations.py
index 8b76ee5c..05f2577f 100644
--- a/src/dfcx_scrapi/core/operations.py
+++ b/src/dfcx_scrapi/core/operations.py
@@ -1,6 +1,6 @@
 """Operations Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -16,8 +16,10 @@
 
 import logging
 from typing import Dict
+
 from google.api_core import operations_v1, grpc_helpers
-from dfcx_scrapi.core.scrapi_base import ScrapiBase
+
+from dfcx_scrapi.core import scrapi_base
 
 # logging config
 logging.basicConfig(
@@ -26,7 +28,7 @@
     datefmt="%Y-%m-%d %H:%M:%S",
 )
 
-class Operations(ScrapiBase):
+class Operations(scrapi_base.ScrapiBase):
     """Core class for Operations functions, primarily used to
     extract LRO information on long running jobs for CX.
     """
@@ -45,6 +47,7 @@ def __init__(
             scope=scope
         )
 
+    @scrapi_base.api_call_counter_decorator
     def get_lro(self, lro: str):
         """Used to retrieve the status of LROs for Dialogflow CX.
 
diff --git a/src/dfcx_scrapi/core/pages.py b/src/dfcx_scrapi/core/pages.py
index 30ace676..8c98ec9f 100644
--- a/src/dfcx_scrapi/core/pages.py
+++ b/src/dfcx_scrapi/core/pages.py
@@ -1,6 +1,6 @@
 """Page Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -112,17 +112,26 @@ def get_pages_map(
 
         return pages_dict
 
-    def list_pages(self, flow_id: str = None) -> List[gcdc_page.Page]:
+    @scrapi_base.api_call_counter_decorator
+    def list_pages(
+        self,
+        flow_id: str = None,
+        language_code: str = "en") -> List[gcdc_page.Page]:
         """Get a List of all pages for the specified Flow ID.
 
         Args:
           flow_id: the properly formatted Flow ID string
+          language_code: Specifies the language of the Pages listed. While the
+            majority of contents of a Page is language agnostic, the contents
+            in the "Agent Says" and similar parts of a Page are affected by
+            language code.
 
         Returns:
           A List of CX Page objects for the specific Flow ID
         """
         request = gcdc_page.ListPagesRequest()
         request.parent = flow_id
+        request.language_code = language_code
 
         client_options = self._set_region(flow_id)
         client = pages.PagesClient(
@@ -137,6 +146,7 @@ def list_pages(self, flow_id: str = None) -> List[gcdc_page.Page]:
 
         return cx_pages
 
+    @scrapi_base.api_call_counter_decorator
     def get_page(self, page_id: str = None) -> gcdc_page.Page:
         """Get a single CX Page object based on the provided Page ID.
 
@@ -158,6 +168,7 @@ def get_page(self, page_id: str = None) -> gcdc_page.Page:
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def create_page(
         self, flow_id: str = None, obj: gcdc_page.Page = None, **kwargs
     ) -> gcdc_page.Page:
@@ -191,6 +202,7 @@ def create_page(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def update_page(
         self, page_id: str = None, obj: gcdc_page.Page = None, **kwargs
     ) -> gcdc_page.Page:
@@ -225,13 +237,17 @@ def update_page(
 
         return response
 
-    def delete_page(self, page_id: str = None) -> str:
+    @scrapi_base.api_call_counter_decorator
+    def delete_page(self, page_id: str = None, force: bool = False) -> 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>``
+          force: (Optional) This field has no effect for pages with no incoming
+            transitions. If set to True, Dialogflow will remove the page,
+            as well as any transitions to the page.
 
         Returns:
           String "Page `{page_id}` successfully deleted."
@@ -240,6 +256,7 @@ def delete_page(self, page_id: str = None) -> str:
         client = pages.PagesClient(
             credentials=self.creds, client_options=client_options
         )
-        client.delete_page(name=page_id)
+        req = gcdc_page.DeletePageRequest(name=page_id, force=force)
+        client.delete_page(request=req)
 
         return f"Page `{page_id}` successfully deleted."
diff --git a/src/dfcx_scrapi/core/project.py b/src/dfcx_scrapi/core/project.py
index 46a760a6..544b426b 100644
--- a/src/dfcx_scrapi/core/project.py
+++ b/src/dfcx_scrapi/core/project.py
@@ -1,6 +1,6 @@
 """Project level cross-region functions for CX."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -18,7 +18,7 @@
 import time
 from typing import Dict
 
-from dfcx_scrapi.core.scrapi_base import ScrapiBase
+from dfcx_scrapi.core import scrapi_base
 from dfcx_scrapi.core.agents import Agents
 
 # logging config
@@ -29,7 +29,7 @@
 )
 
 
-class Project(ScrapiBase):
+class Project(scrapi_base.ScrapiBase):
     """Top Level class representing the Project level resources
     when working on a Dialogflow CX project. This Class will allow you to
     extract information about your GCP project as a whole in relation to
diff --git a/src/dfcx_scrapi/core/scrapi_base.py b/src/dfcx_scrapi/core/scrapi_base.py
index 0dac2409..71399a84 100644
--- a/src/dfcx_scrapi/core/scrapi_base.py
+++ b/src/dfcx_scrapi/core/scrapi_base.py
@@ -1,6 +1,6 @@
 """Base for other SCRAPI classes."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -17,8 +17,10 @@
 import logging
 import json
 import re
-
+import functools
+from collections import defaultdict
 from typing import Dict
+
 from google.oauth2 import service_account
 from google.auth.transport.requests import Request
 from google.protobuf import json_format  # type: ignore
@@ -71,8 +73,10 @@ def __init__(
         if agent_id:
             self.agent_id = agent_id
 
+        self.api_calls_dict = defaultdict(int)
+
     @staticmethod
-    def _set_region(item_id):
+    def _set_region(resource_id: str):
         """Different regions have different API endpoints
 
         Args:
@@ -85,18 +89,27 @@ def _set_region(item_id):
           if the location is "global"
         """
         try:
-            location = item_id.split("/")[3]
+            location = resource_id.split("/")[3]
         except IndexError as err:
-            logging.error("IndexError - path too short? %s", item_id)
+            logging.error("IndexError - path too short? %s", resource_id)
             raise err
 
+        project_id = resource_id.split("/")[1]
+
         if location != "global":
             api_endpoint = f"{location}-dialogflow.googleapis.com:443"
-            client_options = {"api_endpoint": api_endpoint}
+            client_options = {
+                "api_endpoint": api_endpoint,
+                "quota_project_id": project_id}
             return client_options
 
         else:
-            return None  # explicit None return when not required
+            api_endpoint = "dialogflow.googleapis.com:443"
+            client_options = {
+                "api_endpoint": api_endpoint,
+                "quota_project_id": project_id}
+
+            return client_options
 
     @staticmethod
     def pbuf_to_dict(pbuf):
@@ -266,3 +279,48 @@ def recurse_proto_marshal_to_dict(self, marshal_object):
             new_dict[k] = v
 
         return new_dict
+
+    def get_api_calls_details(self) -> Dict[str, int]:
+        """The number of API calls corresponding to each method.
+
+        Returns:
+          A dictionary with keys as the method names
+          and values as the number of calls.
+        """
+        this_class_methods, sub_class_apis_dict = {}, {}
+
+        for attr_name in dir(self):
+            attr = getattr(self, attr_name)
+            if callable(attr) and hasattr(attr, "calls_api"):
+                this_class_methods[attr_name] = 0
+            if any(
+                isinstance(attr, sub_class)
+                for sub_class in ScrapiBase.__subclasses__()
+            ):
+                sub_class_apis_dict.update(attr.get_api_calls_details())
+
+        if hasattr(self, "api_calls_dict"):
+            this_class_methods.update(getattr(self, "api_calls_dict"))
+
+        return {**this_class_methods, **sub_class_apis_dict}
+
+    def get_api_calls_count(self) -> int:
+        """Show the total number of API calls for this resource.
+
+        Returns:
+          Total calls to the API so far as an int.
+        """
+        return sum(self.get_api_calls_details().values())
+
+
+def api_call_counter_decorator(func):
+    """Counts the number of API calls for the function `func`."""
+
+    @functools.wraps(func)
+    def wrapper(self, *args, **kwargs):
+        self.api_calls_dict[func.__name__] += 1
+        return func(self, *args, **kwargs)
+
+    wrapper.calls_api = True
+
+    return wrapper
diff --git a/src/dfcx_scrapi/core/security_settings.py b/src/dfcx_scrapi/core/security_settings.py
index 9ea2f6cc..0617478f 100644
--- a/src/dfcx_scrapi/core/security_settings.py
+++ b/src/dfcx_scrapi/core/security_settings.py
@@ -1,6 +1,6 @@
 """CCAI Security Settings Methods"""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -54,6 +54,7 @@ def __init__(
         self.ss_service = services.security_settings_service
         self.ss_types = types.security_settings
 
+    @scrapi_base.api_call_counter_decorator
     def list_security_settings(self, location_id: str):
         """List Security Settings for a given Project and Region.
 
@@ -83,6 +84,7 @@ def list_security_settings(self, location_id: str):
         return security_settings
 
 
+    @scrapi_base.api_call_counter_decorator
     def get_security_settings(self, security_setting_id: str):
         """Get specified CCAI Security Setting.
 
@@ -107,6 +109,7 @@ def get_security_settings(self, security_setting_id: str):
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def create_security_settings(
         self,
         location_id: str,
@@ -158,6 +161,7 @@ def create_security_settings(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def update_security_settings(self, security_setting_id: str, **kwargs):
         """Update specified CCAI Security Setting.
 
@@ -192,6 +196,7 @@ def update_security_settings(self, security_setting_id: str, **kwargs):
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def delete_security_settings(self, security_setting_id: str):
         """Delete the specified CCAI Security Setting.
 
diff --git a/src/dfcx_scrapi/core/session_entity_types.py b/src/dfcx_scrapi/core/session_entity_types.py
index 49c61f53..62cbf215 100644
--- a/src/dfcx_scrapi/core/session_entity_types.py
+++ b/src/dfcx_scrapi/core/session_entity_types.py
@@ -1,6 +1,6 @@
 """Session Entity Types Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -193,6 +193,7 @@ def build_session_entity_type(
 
         return st
 
+    @scrapi_base.api_call_counter_decorator
     def list_session_entity_types(
         self, session_id: str, environment_id: str = None
     ) -> List[types.SessionEntityType]:
@@ -236,6 +237,7 @@ def list_session_entity_types(
 
         return session_entities
 
+    @scrapi_base.api_call_counter_decorator
     def get_session_entity_type(
         self, session_entity_type_id: str, environment_id: str = None
     ) -> types.SessionEntityType:
@@ -272,6 +274,7 @@ def get_session_entity_type(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def create_session_entity_type(
         self, session_id: str, session_entity_type: types.SessionEntityType
     ) -> types.SessionEntityType:
@@ -299,6 +302,7 @@ def create_session_entity_type(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def update_session_entity_type(
         self,
         session_entity_type_id: str,
@@ -361,6 +365,7 @@ def update_session_entity_type(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def delete_session_entity_type(
         self, session_entity_type_id: str, environment_id: str = None
     ) -> str:
diff --git a/src/dfcx_scrapi/core/sessions.py b/src/dfcx_scrapi/core/sessions.py
index 10ee35b7..12bab554 100644
--- a/src/dfcx_scrapi/core/sessions.py
+++ b/src/dfcx_scrapi/core/sessions.py
@@ -1,6 +1,6 @@
 """CX Session Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -19,8 +19,9 @@
 from typing import Dict, List
 from google.cloud.dialogflowcx_v3beta1 import services
 from google.cloud.dialogflowcx_v3beta1 import types
+from google.protobuf.json_format import MessageToDict
 
-from dfcx_scrapi.core.scrapi_base import ScrapiBase
+from dfcx_scrapi.core import scrapi_base
 
 # logging config
 logging.basicConfig(
@@ -29,7 +30,7 @@
     datefmt="%Y-%m-%d %H:%M:%S",
 )
 
-class Sessions(ScrapiBase):
+class Sessions(scrapi_base.ScrapiBase):
     """Core Class for CX Session Resource functions."""
 
     def __init__(
@@ -285,3 +286,17 @@ def preset_parameters(
         response = session_client.detect_intent(request=request)
 
         return response
+
+    def get_agent_answer(self, user_query: str) -> str:
+        """Extract the answer/citation from a Vertex Conversation response."""
+
+        session_id = self.build_session_id(self.agent_id)
+        res = MessageToDict(self.detect_intent( # pylint: disable=W0212
+            self.agent_id, session_id, user_query)._pb)
+
+        answer_text = res["responseMessages"][0]["text"]["text"][0]
+        answer_link = res["responseMessages"][1]["payload"][
+            "richContent"][0][0]["actionLink"] if len(
+                res["responseMessages"]) > 1 else ""
+
+        return f"{answer_text} ({answer_link})"
diff --git a/src/dfcx_scrapi/core/transition_route_groups.py b/src/dfcx_scrapi/core/transition_route_groups.py
index 30cdcd37..8c2c8984 100644
--- a/src/dfcx_scrapi/core/transition_route_groups.py
+++ b/src/dfcx_scrapi/core/transition_route_groups.py
@@ -1,6 +1,6 @@
 """CX Transition Route Group Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -134,6 +134,7 @@ def get_route_groups_map(self, flow_id: str = None, reverse=False):
 
         return pages_dict
 
+    @scrapi_base.api_call_counter_decorator
     def list_transition_route_groups(self, flow_id: str = None):
         """Exports List of all Route Groups in the specified CX Flow ID.
 
@@ -164,6 +165,7 @@ def list_transition_route_groups(self, flow_id: str = None):
 
         return cx_route_groups
 
+    @scrapi_base.api_call_counter_decorator
     def get_transition_route_group(self, route_group_id):
         """Get a single Transition Route Group object.
 
@@ -183,6 +185,7 @@ def get_transition_route_group(self, route_group_id):
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def create_transition_route_group(
         self,
         flow_id: str = None,
@@ -222,6 +225,7 @@ def create_transition_route_group(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def update_transition_route_group(
         self,
         route_group_id: str = None,
@@ -320,9 +324,11 @@ def route_groups_to_dataframe(
                 temp_dict.update({"route_group_name": route_group.display_name})
 
                 if route.target_page:
-                    temp_dict.update(
-                        {"target_page": all_pages_map[route.target_page]}
-                    )
+                    t_p = all_pages_map.get(route.target_page)
+                    if not t_p:
+                        t_p = str(route.target_page).rsplit("/", maxsplit=1)[-1]
+
+                    temp_dict.update({"target_page": t_p})
 
                 if route.intent:
                     temp_dict.update({"intent": intents_map[route.intent]})
diff --git a/src/dfcx_scrapi/core/versions.py b/src/dfcx_scrapi/core/versions.py
index 8d581e42..cc94fd19 100644
--- a/src/dfcx_scrapi/core/versions.py
+++ b/src/dfcx_scrapi/core/versions.py
@@ -1,6 +1,6 @@
 """CX Versions Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -48,6 +48,7 @@ def __init__(
         if flow_id:
             self.flow_id = flow_id
 
+    @scrapi_base.api_call_counter_decorator
     def list_versions(self, flow_id:str):
         """List all Versions for a given Flow.
 
@@ -79,6 +80,7 @@ def list_versions(self, flow_id:str):
 
         return versions
 
+    @scrapi_base.api_call_counter_decorator
     def get_version(
         self,
         version_id:str=None,
@@ -140,6 +142,7 @@ def get_version_by_display_name(self, display_name:str, flow_id:str):
 
         return None
 
+    @scrapi_base.api_call_counter_decorator
     def load_version(
         self,
         version:types.version.Version,
@@ -176,6 +179,7 @@ def load_version(
         response = client.load_version(request)
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def create_version(
         self,
         flow_id:str,
@@ -212,6 +216,7 @@ def create_version(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def delete_version(self, version_id:str):
         """Delete a specified Version.
 
@@ -229,6 +234,7 @@ def delete_version(self, version_id:str):
 
         return client.delete_version(request)
 
+    @scrapi_base.api_call_counter_decorator
     def compare_versions(
         self,
         base_version_id:str,
diff --git a/src/dfcx_scrapi/core/webhooks.py b/src/dfcx_scrapi/core/webhooks.py
index 9460a434..3a10b4c2 100644
--- a/src/dfcx_scrapi/core/webhooks.py
+++ b/src/dfcx_scrapi/core/webhooks.py
@@ -1,6 +1,6 @@
 """Webhook Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -88,6 +88,7 @@ def get_webhooks_map(
         return webhooks_dict
 
 
+    @scrapi_base.api_call_counter_decorator
     def list_webhooks(self, agent_id: str = None):
         """List all Webhooks in the specified CX Agent.
 
@@ -116,6 +117,7 @@ def list_webhooks(self, agent_id: str = None):
 
         return cx_webhooks
 
+    @scrapi_base.api_call_counter_decorator
     def create_webhook(
         self,
         agent_id: str,
@@ -149,6 +151,7 @@ def create_webhook(
         return response
 
 
+    @scrapi_base.api_call_counter_decorator
     def get_webhook(self, webhook_id:str):
         """Retrieves the specified webhook.
 
@@ -202,6 +205,7 @@ def get_webhook_by_display_name(
         return webhook_obj
 
 
+    @scrapi_base.api_call_counter_decorator
     def update_webhook(
         self,
         webhook_id:str,
diff --git a/src/dfcx_scrapi/core_ml/utterance_generator.py b/src/dfcx_scrapi/core_ml/utterance_generator.py
index e6555079..450a9ce1 100644
--- a/src/dfcx_scrapi/core_ml/utterance_generator.py
+++ b/src/dfcx_scrapi/core_ml/utterance_generator.py
@@ -1,6 +1,6 @@
 """Utility file for generating synthetic phrases from input phrases"""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/src/dfcx_scrapi/tools/agent_checker_util.py b/src/dfcx_scrapi/tools/agent_checker_util.py
new file mode 100644
index 00000000..ff6e331a
--- /dev/null
+++ b/src/dfcx_scrapi/tools/agent_checker_util.py
@@ -0,0 +1,203 @@
+"""A set of Utility methods to check resources stats on DFCX Agents."""
+
+# Copyright 2023 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
+import time
+from typing import Dict, List
+import pandas as pd
+
+from dfcx_scrapi.core import scrapi_base
+from dfcx_scrapi.agent_extract import agents
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+class AgentCheckerUtil(scrapi_base.ScrapiBase):
+    """Utility class for checking DFCX Agents."""
+
+    def __init__(
+        self,
+        agent_id: str,
+        gcs_bucket_uri: str,
+        creds_path: str = None,
+        creds_dict: Dict = None,
+        creds=None,
+        scope=False,
+    ):
+        super().__init__(
+            creds_path=creds_path,
+            creds_dict=creds_dict,
+            creds=creds,
+            scope=scope,
+        )
+        self.agent_id = agent_id
+        self.special_pages = [
+            "End Session",
+            "End Flow",
+            "Start Page",
+            "Current Page",
+            "Previous Page",
+        ]
+
+        startup_time = time.time()
+        self.extract = agents.Agents(agent_id)
+        processing_time = time.time()
+        logging.debug(f"STARTUP: {processing_time - startup_time}")
+
+        self.data = self.extract.process_agent(agent_id, gcs_bucket_uri)
+        logging.debug(f"TOTAL PROCESSING: {time.time() - processing_time}")
+
+        self.active_intents_df = self.active_intents_to_dataframe()
+
+    def _filter_special_pages(self, page: str, filter_special_pages: bool):
+        """Recursion helper to check for special page match."""
+        if filter_special_pages and page in self.special_pages:
+            return True
+
+        return False
+
+    def _recurse_edges(self, edges: Dict[str, List[str]], page: str,
+                      visited: set, depth: int, max_depth: int,
+                      filter_special_pages: bool):
+        """Recursion method used to traverse the agent graph for page data.
+
+        Args:
+          edges: The set of graph edges collected from the agent.
+          page: The current Page Display Name
+          visited: A set of visited Page nodes
+          depth: The current recursion depth
+          max_depth: The max recursion depth
+          filter_special_pages: Will discard all self.special_pages from output
+            if set to False.
+        """
+        if depth == max_depth:
+            return visited
+
+        if page in edges:
+            for inner_page in edges[page]:
+                if self._filter_special_pages(inner_page, filter_special_pages):
+                    return visited
+
+                if inner_page not in visited:
+                    visited.add(inner_page)
+                    visited = self._recurse_edges(
+                        edges, inner_page, visited, depth+1, max_depth,
+                        filter_special_pages)
+
+        return visited
+
+    def _mark_unreachable_pages(self, df: pd.DataFrame) -> pd.DataFrame:
+        """Mark dataframe rows True if the page is unreachable in graph."""
+        for idx, row in df.iterrows():
+            for page in self.data.unreachable_pages[row["flow"]]:
+                if row["page"] == page:
+                    df.loc[idx, "unreachable"] = True
+
+        return df
+
+    def get_reachable_pages(
+            self,
+            flow_display_name: str,
+            page_display_name: str = "Start Page",
+            max_depth: int = 1,
+            filter_special_pages = True) -> List[str]:
+        """Get all pages in the graph that are reachable via transition routes,
+        starting from a given Flow and Page.
+
+        Args:
+          flow_display_name: The display name of the flow
+          page_display_name: The display name of the page. Defaults to
+            "Start Page"
+          max_depth: The max recursion depth to search the graph from the
+            provided starting point. For example, a max_depth of 2 would produce
+            all reachable Pages that are 2 transition routes away from the
+            starting Flow/Page. Defaults to 1.
+          filter_special_pages: Will filter out all self.special_pages. Defaults
+            to True.
+          """
+        if page_display_name in ["START", "START_PAGE", "Start", "Start Page"]:
+            page_display_name = "Start Page"
+            page_display_name = f"{flow_display_name}: {page_display_name}"
+
+        visited = self._recurse_edges(
+            self.data.graph.edges, page_display_name, set(), 0, max_depth,
+            filter_special_pages)
+
+        return list(visited)
+
+    def active_intents_to_dataframe(self) -> pd.DataFrame:
+        """Gets all intents referenced in the agent, across all flows and pages,
+        and produces a dataframe listing which flows/pages reference each
+        intent.
+
+        Returns:
+            A dataframe with columns
+            intent - the intent display name
+            flow - the Flow Display Name where the intent resides
+            page - the Page Display Name where the intent resides
+            unreachable - Denotes whether the Flow/Page/Intent combination is
+              unreachable in the graph.
+        """
+        df = pd.DataFrame({
+            "intent": pd.Series(dtype="str"),
+            "flow": pd.Series(dtype="str"),
+            "page": pd.Series(dtype="str"),
+            "unreachable": pd.Series(dtype="bool")
+            })
+
+        # Loop over active_intents, create temp dataframe, then concat with the
+        # main dataframe to build out the complete Flow/Page/Intent dataset.
+        for flow in self.data.active_intents:
+            for pair in self.data.active_intents[flow]:
+                intent = pair[0]
+                page = pair[1]
+                temp = pd.DataFrame({
+                    "intent": [intent],
+                    "flow": [flow],
+                    "page": [page],
+                    "unreachable": [False]})
+                df = pd.concat([df, temp])
+
+        df = df.reset_index(drop=True)
+
+        # Finally, determine what rows are unreachable.
+        self.active_intents_df = self._mark_unreachable_pages(df)
+
+        return self.active_intents_df
+
+    def get_unused_intents(self) -> List:
+        """Get all unused Intents across the agent."""
+        if self.active_intents_df.empty:
+            self.active_intents_df = self.active_intents_to_dataframe()
+        active_intents_set = set(self.active_intents_df.intent.to_list())
+        all_intents_set = set(self.data.intents_map.keys())
+
+        return list(all_intents_set.difference(active_intents_set))
+
+    def get_unreachable_intents(self) -> pd.DataFrame:
+        """Get all unreachable Intents across the agent.
+
+        An Intent is unreachable if it resides on a page that is also
+        unreachable.
+        """
+        if self.active_intents_df.empty:
+            self.active_intents_df = self.active_intents_to_dataframe()
+
+        return self.active_intents_df[self.active_intents_df["unreachable"]]
diff --git a/src/dfcx_scrapi/tools/copy_util.py b/src/dfcx_scrapi/tools/copy_util.py
index 2472bcbe..65498cd8 100644
--- a/src/dfcx_scrapi/tools/copy_util.py
+++ b/src/dfcx_scrapi/tools/copy_util.py
@@ -1,6 +1,6 @@
 """A set of Utility methods to copy DFCX Resources."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -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,24 @@ 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
+    ):
+        special_pages = [
+            "END_FLOW", "END_SESSION",
+            "CURRENT_PAGE", "PREVIOUS_PAGE", "START_PAGE"
+        ]
+
+        if convert_type == "source":
+            last_part = trans_route.target_page.split("/")[-1]
+            if last_part in special_pages:
+                trans_route.target_page = last_part
             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':
-                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/CURRENT_PAGE')
-
+        elif convert_type == "destination":
+            if trans_route.target_page in special_pages:
+                new_page = f"{flows_map[flow]}/pages/{trans_route.target_page}"
+                trans_route.target_page = new_page
             else:
                 trans_route.target_page = pages_map[trans_route.target_page]
 
@@ -173,45 +157,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 +211,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 +239,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 +278,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 +302,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 +312,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 +335,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,12 +348,9 @@ 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.
@@ -360,78 +364,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)
@@ -439,18 +438,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)
@@ -462,59 +460,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
                         ]
@@ -522,29 +527,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)
 
@@ -555,10 +562,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:
@@ -573,32 +579,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,9 +614,13 @@ 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)
+            self.entities.create_entity_type(
+                agent_id=destination_agent,
+                obj=entity_object)
+            logging.info(
+                "Entity Type %s created successfully",
+                entity_object.display_name,
+            )
 
         except core_exceptions.AlreadyExists as error:
             print(error)
@@ -625,22 +629,19 @@ 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 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.
-
         Returns:
           None
         """
@@ -654,32 +655,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.)
@@ -689,7 +685,6 @@ 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:
           A dictionary with possible keys being webhooks, entities, intents,
           and route_groups, with keys missing if they were in the skip_list.
@@ -699,7 +694,8 @@ def copy_paste_agent_resources( #pylint: disable=too-many-arguments
         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
@@ -707,25 +703,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
 
@@ -733,21 +739,18 @@ 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.
@@ -769,25 +772,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])
@@ -800,22 +821,19 @@ 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.
@@ -840,27 +858,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])
@@ -873,18 +907,16 @@ 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>
@@ -892,7 +924,6 @@ def convert_start_page_dependencies(
             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
@@ -900,100 +931,157 @@ def convert_start_page_dependencies(
         """
         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
+                        ]
+
+            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':
+        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
-
         Returns:
           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:
@@ -1001,19 +1089,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 f8c50f0d..59686b1e 100644
--- a/src/dfcx_scrapi/tools/dataframe_functions.py
+++ b/src/dfcx_scrapi/tools/dataframe_functions.py
@@ -1,6 +1,6 @@
 """Utility file for dataframe functions in support of Dialogflow CX."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -70,7 +70,10 @@ def __init__(
         if scope:
             scopes += scope
 
-        if creds_path:
+        if creds:
+            self.sheets_client = gspread.authorize(creds)
+
+        elif creds_path:
             creds = ServiceAccountCredentials.from_json_keyfile_name(
                 filename=creds_path, scopes=scopes
             )
@@ -89,15 +92,11 @@ def __init__(
             creds = google.auth.default(scopes=scopes)[0]
             self.sheets_client = gspread.authorize(creds)
 
-        logging.info("create dfcx creds %s", creds_path)
-        self.entities = EntityTypes(creds_path, creds_dict)
-        self.intents = Intents(creds_path, creds_dict)
-        self.flows = Flows(creds_path, creds_dict)
-        self.pages = Pages(creds_path, creds_dict)
-        self.route_groups = TransitionRouteGroups(
-            creds_path, creds_dict
-        )
-        self.creds_path = creds_path
+        self.entities = EntityTypes(creds=self.creds)
+        self.intents = Intents(creds=self.creds)
+        self.flows = Flows(creds=self.creds)
+        self.pages = Pages(creds=self.creds)
+        self.route_groups = TransitionRouteGroups(creds=self.creds)
 
     @staticmethod
     def progress_bar(current, total, bar_length=50, type_="Progress"):
diff --git a/src/dfcx_scrapi/tools/levenshtein.py b/src/dfcx_scrapi/tools/levenshtein.py
index 4d42c01b..c659f465 100644
--- a/src/dfcx_scrapi/tools/levenshtein.py
+++ b/src/dfcx_scrapi/tools/levenshtein.py
@@ -1,6 +1,6 @@
 """Util class for performing analysis on DFCX objects and data."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -97,9 +97,10 @@ 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
             ).training_phrase
         elif isinstance(intent_key, pandas.core.frame.DataFrame):
@@ -113,7 +114,7 @@ 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
             ).training_phrase
         elif isinstance(intent_comparator, pandas.core.frame.DataFrame):
@@ -126,7 +127,7 @@ def calc_tp_distances(
                 """)
             return None
 
-        it = 0
+        count = 0
         completed = 0.0
         tp_distances = {}
         num_keys_overlapped = 0
@@ -138,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(
@@ -161,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 d20032ab..5297d1a4 100644
--- a/src/dfcx_scrapi/tools/maker_util.py
+++ b/src/dfcx_scrapi/tools/maker_util.py
@@ -1,6 +1,6 @@
 """Methods for creating CX object types"""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,6 +14,10 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+# DEPRECATION WARNING!
+# THIS CLASS WILL BE DEPRECATED IN A FUTURE VERSION OF SCRAPI POST v1.5.1
+# PLEASE NOTE THAT THE /builders CLASSES WILL TAKE PRECEDENCE OVER THIS CLASS
+
 import logging
 from google.cloud.dialogflowcx_v3beta1 import types
 
diff --git a/src/dfcx_scrapi/tools/nlu_evals.py b/src/dfcx_scrapi/tools/nlu_evals.py
new file mode 100644
index 00000000..d8c84977
--- /dev/null
+++ b/src/dfcx_scrapi/tools/nlu_evals.py
@@ -0,0 +1,308 @@
+"""A set of Utility methods to check resources stats on DFCX Agents."""
+
+# Copyright 2023 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 Dict
+from dataclasses import dataclass
+
+import logging
+import datetime
+import pandas as pd
+import gspread
+
+from dfcx_scrapi.core import scrapi_base
+from dfcx_scrapi.core import agents
+from dfcx_scrapi.core import flows
+from dfcx_scrapi.core import pages
+from dfcx_scrapi.core import intents
+from dfcx_scrapi.core import conversation
+from dfcx_scrapi.tools import dataframe_functions
+
+pd.options.display.max_colwidth = 200
+
+GLOBAL_SCOPE = [
+    "https://spreadsheets.google.com/feeds",
+    "https://www.googleapis.com/auth/drive",
+]
+INPUT_SCHEMA_COLUMNS = [
+        "flow_display_name",
+        "page_display_name",
+        "utterance",
+        "expected_intent",
+        "expected_parameters",
+        "description",
+    ]
+
+OUTPUT_SCHEMA_COLUMNS = [
+    "flow_display_name",
+    "page_display_name",
+    "utterance",
+    "expected_intent",
+    "expected_parameters",
+    "target_page",
+    "match_type",
+    "confidence",
+    "parameters_set",
+    "detected_intent",
+    "agent_display_name",
+    "description",
+    "input_source"
+    ]
+
+SUMMARY_SCHEMA_COLUMNS = [
+    "test_run_timestamp",
+    "total_tests",
+    "pass_count",
+    "pass_rate",
+    "no_match_count",
+    "no_match_rate",
+    "test_agent",
+    "data_source"
+    ]
+
+# logging config
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)-8s %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+
+@dataclass
+class Stats:
+    """Dataclass for the summary stats."""
+    no_match_count: int = 0
+    no_match_rate: float = 0.0
+    pass_count: int = 0
+    pass_rate: float = 0.0
+    test_agent: str = None
+    data_source: str = None
+
+
+class NluEvals(scrapi_base.ScrapiBase):
+    """NLU Evaluation Class for Dialogflow CX Testing."""
+    def __init__(
+        self,
+        agent_id: 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,
+            scope=GLOBAL_SCOPE
+        )
+
+        self.agent_id = agent_id
+        self._sheets_client = self._build_sheets_client()
+
+        self._a = agents.Agents(creds=self.creds)
+        self._i = intents.Intents(creds=self.creds)
+        self._f = flows.Flows(creds=self.creds)
+        self._p = pages.Pages(creds=self.creds)
+        self._dc = conversation.DialogflowConversation(
+            creds_path=creds_path, agent_id=agent_id
+        )
+        self._dffx = dataframe_functions.DataframeFunctions(creds=self.creds)
+
+    def _build_sheets_client(self):
+        client = gspread.authorize(self.creds)
+
+        return client
+
+    def _calculate_stats(self, df: pd.DataFrame):
+        """Calculate all the stats needed for the summary report."""
+        stats = Stats()
+        stats.no_match_count = (
+            df[df.detected_intent == "NO_MATCH"]
+            .groupby("detected_intent")
+            .size()
+            .sum()
+        )
+        stats.no_match_rate = stats.no_match_count / df.shape[0]
+        stats.pass_count = (
+            df[df.detected_intent == df.expected_intent]
+            .groupby("detected_intent")
+            .size()
+            .sum()
+        )
+        stats.pass_rate = stats.pass_count / df.shape[0]
+        stats.test_agent = df.agent_display_name.unique()[0]
+        stats.data_source = df.input_source.unique()[0]
+
+        return stats
+
+    def _write_report_summary_to_sheets(
+        self, df: pd.DataFrame, sheet_name: str, sheet_tab: str
+    ):
+        """Writes the output report summary to Google Sheets."""
+
+        gsheet = self._sheets_client.open(sheet_name)
+        sheet = gsheet.worksheet(sheet_tab)
+
+        df["test_run_timestamp"] = df.test_run_timestamp.astype("str")
+
+        sheet.append_row(
+            df.values.flatten().tolist(), value_input_option="USER_ENTERED"
+        )
+
+    def _append_test_results_to_sheets(
+        self, results: pd.DataFrame, sheet_name: str, sheet_tab: str
+    ):
+        """Adds results to an existing Google Sheet collection."""
+
+        gsheet = self._sheets_client.open(sheet_name)
+        sheet = gsheet.worksheet(sheet_tab)
+
+        # Fixes an error that sometimes happens when trying to write parameters
+        # to the sheet because they are formatted as objects
+        result_list = results.values.tolist()
+        result_list = [list(map(str, row)) for row in result_list]
+
+        sheet.append_rows(result_list, value_input_option="USER_ENTERED")
+
+    def _write_test_results_to_sheets(
+        self, results: pd.DataFrame, sheet_name: str, sheet_tab: str
+    ):
+        """Writes the output result details to Google Sheets."""
+
+        gsheet = self._sheets_client.open(sheet_name)
+        sheet = gsheet.worksheet(sheet_tab)
+
+        sheet.clear()
+
+        self._dffx.dataframe_to_sheets(sheet_name, sheet_tab, results)
+
+    def _clean_dataframe(self, df):
+        """Various Dataframe cleaning functions."""
+        df.columns = df.columns.str.lower()
+        df = df.replace("Start Page", "START_PAGE")
+        df.rename(
+                columns={
+                    "source": "description",
+                },
+                inplace=True,
+            )
+
+        # Validate input schema
+        try:
+            df = df[INPUT_SCHEMA_COLUMNS]
+        except KeyError as err:
+            raise UserWarning("Ensure your input data contains the following "\
+                              f"columns: {INPUT_SCHEMA_COLUMNS}") from err
+
+        df["agent_display_name"] = self._a.get_agent(self.agent_id).display_name
+
+        return df
+
+    def process_input_csv(self, input_file_path: str):
+        """Process the input data in CSV format."""
+        df = pd.read_csv(input_file_path)
+        df = df.fillna("")
+        df = self._clean_dataframe(df)
+        df["input_source"] = input_file_path
+
+        return df
+
+    def process_input_google_sheet(self, gsheet_name: str, gsheet_tab: str):
+        """Process the input data in Google Sheets format."""
+        df = self._dffx.sheets_to_dataframe(gsheet_name, gsheet_tab)
+        df = self._clean_dataframe(df)
+        df["input_source"] = gsheet_tab
+
+        return df
+
+    def run_evals(self, df: pd.DataFrame, chunk_size: int = 300,
+                  rate_limit: float = 10.0,
+                  eval_run_display_name: str = "Evals"):
+        """Run the full Eval dataset."""
+        logsx = "-" * 10
+
+        logging.info(f"{logsx} STARTING {eval_run_display_name} {logsx}")
+        results = self._dc.run_intent_detection(
+            test_set=df, chunk_size=chunk_size, rate_limit=rate_limit
+        )
+
+        # Reorder Columns
+        results = results.reindex(columns=OUTPUT_SCHEMA_COLUMNS)
+
+        # When a NO_MATCH occurs, the detected_intent field will be blank
+        # this replaces with NO_MATCH string, which will allow for easier stats
+        # calculation downstream
+        results.detected_intent.replace({"": "NO_MATCH"}, inplace=True)
+
+        logging.info(f"{logsx} {eval_run_display_name} COMPLETE {logsx}")
+
+        return results
+
+    def generate_report(self, df: pd.DataFrame,
+                        report_timestamp: datetime.datetime
+                        ):
+        """Generates a summary stats report for most recent NLU Eval tests."""
+        # Calc fields
+        stats = self._calculate_stats(df)
+
+        # Generate Dataframe format
+        df_report = pd.DataFrame(
+            columns=SUMMARY_SCHEMA_COLUMNS,
+            data=[
+                [
+                    report_timestamp,
+                    df.shape[0],
+                    stats.pass_count,
+                    stats.pass_rate,
+                    stats.no_match_count,
+                    stats.no_match_rate,
+                    stats.test_agent,
+                    stats.data_source,
+                ]
+            ],
+        )
+
+        return df_report
+
+    def write_summary_to_file(self, df: pd.DataFrame, output_file: str):
+        """Write summary output to a local CSV file."""
+        report_timestamp = datetime.datetime.now()
+        df_report = self.generate_report(df, report_timestamp)
+        df_report.to_csv(output_file, index=False)
+
+    def write_results_to_file(self, df: pd.DataFrame, output_file: str):
+        df.to_csv(output_file, index=False)
+
+    def write_results_to_sheets(self, df: pd.DataFrame, google_sheet_name: str,
+                                full_output_tab: str,
+                                summary_tab: str,
+                                append=False):
+        """Write summary and detailed output to Google Sheets."""
+        report_timestamp = datetime.datetime.now()
+        df_report = self.generate_report(df, report_timestamp)
+
+        self._write_report_summary_to_sheets(
+            df_report, google_sheet_name, summary_tab
+        )
+
+        if append:
+            self._append_test_results_to_sheets(
+                df, google_sheet_name, full_output_tab
+            )
+
+        else:
+            self._write_test_results_to_sheets(
+                df, google_sheet_name, full_output_tab
+            )
diff --git a/src/dfcx_scrapi/tools/nlu_util.py b/src/dfcx_scrapi/tools/nlu_util.py
index 1fd469c8..0bc152bd 100644
--- a/src/dfcx_scrapi/tools/nlu_util.py
+++ b/src/dfcx_scrapi/tools/nlu_util.py
@@ -1,6 +1,6 @@
 """Utility file for NLU Utterance Similarity to work with CX resources."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -40,21 +40,24 @@
 
 
 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
 
+        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,
@@ -66,18 +69,18 @@ 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:
+        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:
-            raise KeyError(f"Couldn't find column '{column_name}'")
+        except KeyError as err:
+            raise KeyError(f"Couldn't find column '{column_name}'") from err
 
         return column_data.to_numpy()
 
@@ -131,7 +134,7 @@ def _load_data(
         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 '
+                f'Page "{page_display_name}" does not exist in the '
                 "specified agent."
             )
         self.page = page_loader.get_page(page_id)
@@ -170,15 +173,15 @@ def _list_page_intents(self) -> Set[str]:
         return relevant_intents
 
     def _get_training_phrases(self):
-        intents = []
+        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)
-                intents.append(intent.display_name)
+                intent_list.append(intent.display_name)
 
-        return np.array(intents), np.array(training_phrases)
+        return np.array(intent_list), np.array(training_phrases)
 
     def generate_embeddings(self, utterances):
         return self.embedder.embed(utterances)
diff --git a/src/dfcx_scrapi/tools/search_util.py b/src/dfcx_scrapi/tools/search_util.py
index 2ec17d27..8c3abe6b 100644
--- a/src/dfcx_scrapi/tools/search_util.py
+++ b/src/dfcx_scrapi/tools/search_util.py
@@ -1,6 +1,6 @@
 """Util class for doing searches"""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/src/dfcx_scrapi/tools/semantic_clustering.py b/src/dfcx_scrapi/tools/semantic_clustering.py
index e9911607..8e828ad4 100644
--- a/src/dfcx_scrapi/tools/semantic_clustering.py
+++ b/src/dfcx_scrapi/tools/semantic_clustering.py
@@ -2,7 +2,7 @@
 For best results replace entities with a parameter name ex,
 all countries with the word country or all dates with the word date"""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/src/dfcx_scrapi/tools/stats_util.py b/src/dfcx_scrapi/tools/stats_util.py
index 510a4f9d..32493872 100644
--- a/src/dfcx_scrapi/tools/stats_util.py
+++ b/src/dfcx_scrapi/tools/stats_util.py
@@ -1,6 +1,6 @@
 """Utiliity functions to provide Agent Stats for a Dialogflow CX agent."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/src/dfcx_scrapi/tools/utterance_generator_util.py b/src/dfcx_scrapi/tools/utterance_generator_util.py
index 54dfc129..67e1fe02 100644
--- a/src/dfcx_scrapi/tools/utterance_generator_util.py
+++ b/src/dfcx_scrapi/tools/utterance_generator_util.py
@@ -1,6 +1,6 @@
 """Utility file for utterance generator to work with CX resources."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/src/dfcx_scrapi/tools/validation_util.py b/src/dfcx_scrapi/tools/validation_util.py
index ea7d7183..269fedca 100644
--- a/src/dfcx_scrapi/tools/validation_util.py
+++ b/src/dfcx_scrapi/tools/validation_util.py
@@ -1,6 +1,6 @@
 """Working with built in CX Validation featrure."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -76,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):
diff --git a/src/dfcx_scrapi/tools/webhook_util.py b/src/dfcx_scrapi/tools/webhook_util.py
index 2425e6ae..197a93c8 100644
--- a/src/dfcx_scrapi/tools/webhook_util.py
+++ b/src/dfcx_scrapi/tools/webhook_util.py
@@ -1,6 +1,6 @@
 """Util class for building DFCX webhook payloads."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -74,7 +74,9 @@ def build_session_info(parameters):
         return session_info
 
     @staticmethod
-    def build_response(response_text=None, page_info=None, session_info=None):
+    def build_response(
+        response_text=None, page_info=None, session_info=None, append=False
+    ):
         """Builds a Response object for Dialogflow CX.
 
         Provides the JSON object structure expected by DFCX for the Response
@@ -85,12 +87,16 @@ def build_response(response_text=None, page_info=None, session_info=None):
           response_text: The text response to be displayed to the user. Can
             also be empty string if no response to the user is required.
           page_info: (Optional) The JSON object returned by build_page_info()
-          session_info: (Optiona) The JSON object returned by
+          session_info: (Optional) The JSON object returned by
             build_session_info()
+          append: (Optional) Whether messages will append or replace to
+            the list of messages waiting to be sent to the user. If append
+            set to False it will replace the messages.
         """
+        action = 'APPEND' if append else 'REPLACE'
         if response_text:
             response_object = {
-                'mergeBehavior': 'REPLACE',
+                'mergeBehavior': action,
                 'messages': [
                     {
                         'text': {


Test patch:
diff --git a/examples/nlu_analysis_series/nlu_evaluation_testing.ipynb b/examples/nlu_analysis_series/nlu_evaluation_testing.ipynb
new file mode 100644
index 00000000..c9660c9a
--- /dev/null
+++ b/examples/nlu_analysis_series/nlu_evaluation_testing.ipynb
@@ -0,0 +1,421 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Copyright 2023 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": [
+    "# NLU Evaluation Testing\n",
+    "In this notebook, we will show you how to perform bulk NLU testing by providing a large input corpus and receiving the predicted Intent and Parameter extraction results from your agent.\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",
+    "\n",
+    "!pip install dfcx-scrapi"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Imports"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 17,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from dfcx_scrapi.tools.nlu_evals import NluEvals"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# (Option 1) Google Sheet as Input\n",
+    "The primary option for running the NLU Eval pipeline revolves around using Google Sheets as a source for your data.  \n",
+    "This method provides a simple, streamlined system that can pull and push data all into a single Google Sheet.\n",
+    "\n",
+    "In order to run the full NLU evaluation test, the following inputs are needed:\n",
+    "- `agent_id`, The Dialogflow CX Agent ID.\n",
+    "- `input_google_sheet`, the Display Name of the Google Sheet.\n",
+    "- `input_google_sheet_tab`, the Display Name of the tab on the Google Sheet where your input data lives.\n",
+    "- `output_google_sheet_results`, the Display Name of the tab on the Google Sheet where you want the full output results to be written.\n",
+    "- `output_google_sheet_summary`, the Display Name of the tab on the Google Sheet where you want the report summary to be written.\n",
+    "\n",
+    "_**NOTE** - In order for your Service Account to access your Google Sheet (read / write) you need to share the Google Sheet with your Service Account email address._\n",
+    "\n",
+    "You can find a [Sample Google Sheet dataset](https://docs.google.com/spreadsheets/d/e/2PACX-1vREvsZAktNvRr78KjUBlZl2PVUHKJru8hRCgmuDi9kn_oDT_weFKkGmyoQwRPdj0JcxK1kNzgceAPA5/pubhtml#) here."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 18,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "agent_id = '<YOUR_AGENT_ID>'\n",
+    "creds_path = '<YOUR_CREDS_FILE>'\n",
+    "\n",
+    "# Sample Inputs\n",
+    "input_google_sheet = 'Dialogflow CX SCRAPI - NLU Eval Sample Dataset'\n",
+    "input_google_sheet_tab = 'input_dataset'\n",
+    "output_google_sheet_results = 'results'\n",
+    "output_google_sheet_summary = 'summary'"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Run NLU Evals\n",
+    "There are 3 main stages that happen for the Eval Pipeline:\n",
+    "1. Process and validate the input data\n",
+    "2. Run the Eval Tests\n",
+    "3. Write the output summary and details to a report."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 19,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2023-09-11 11:40:31 INFO     ---------- STARTING Evals ----------\n",
+      "2023-09-11 11:40:37 WARNING  Text input is too long. Truncating to 256 characters.\n",
+      "2023-09-11 11:40:37 WARNING  TRUNCATED TEXT: Why can't I get my bill paid at all online? Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?Why can't I get my bill paid at all onli\n",
+      "2023-09-11 11:40:41 INFO     Progress(0/15)[>                                                 ] 0.00%\n",
+      "2023-09-11 11:40:51 INFO     Progress(15/15)[------------------------------------------------->] 100.00%\n",
+      "2023-09-11 11:40:51 INFO     ---------- Evals COMPLETE ----------\n"
+     ]
+    }
+   ],
+   "source": [
+    "nlu = NluEvals(agent_id, creds_path=creds_path)\n",
+    "\n",
+    "df = nlu.process_input_google_sheet(input_google_sheet, input_google_sheet_tab)\n",
+    "df = nlu.run_evals(df)\n",
+    "nlu.write_results_to_sheets(df, input_google_sheet, output_google_sheet_results, output_google_sheet_summary)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Inspect Results Locally\n",
+    "You can also inspect and filter the results of your tests locally as needed."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 20,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>flow_display_name</th>\n",
+       "      <th>page_display_name</th>\n",
+       "      <th>utterance</th>\n",
+       "      <th>expected_intent</th>\n",
+       "      <th>expected_parameters</th>\n",
+       "      <th>target_page</th>\n",
+       "      <th>match_type</th>\n",
+       "      <th>confidence</th>\n",
+       "      <th>parameters_set</th>\n",
+       "      <th>detected_intent</th>\n",
+       "      <th>agent_display_name</th>\n",
+       "      <th>description</th>\n",
+       "      <th>input_source</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>Default Start Flow</td>\n",
+       "      <td>START_PAGE</td>\n",
+       "      <td>I need to get my order status</td>\n",
+       "      <td>head_intent.order_status</td>\n",
+       "      <td></td>\n",
+       "      <td>sentiment_router</td>\n",
+       "      <td>INTENT</td>\n",
+       "      <td>1.000000</td>\n",
+       "      <td></td>\n",
+       "      <td>head_intent.order_status</td>\n",
+       "      <td>[Demo] Multi Demo Extravaganza Part Deux: The Revenge</td>\n",
+       "      <td>Demo Tests</td>\n",
+       "      <td>input_dataset</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>Default Start Flow</td>\n",
+       "      <td>START_PAGE</td>\n",
+       "      <td>Trying to check the status of my order</td>\n",
+       "      <td>head_intent.order_status</td>\n",
+       "      <td></td>\n",
+       "      <td>sentiment_router</td>\n",
+       "      <td>INTENT</td>\n",
+       "      <td>0.947959</td>\n",
+       "      <td></td>\n",
+       "      <td>head_intent.order_status</td>\n",
+       "      <td>[Demo] Multi Demo Extravaganza Part Deux: The Revenge</td>\n",
+       "      <td>Demo Tests</td>\n",
+       "      <td>input_dataset</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>Default Start Flow</td>\n",
+       "      <td>START_PAGE</td>\n",
+       "      <td>I hate this order status agent!</td>\n",
+       "      <td>head_intent.order_status</td>\n",
+       "      <td></td>\n",
+       "      <td>sentiment_router</td>\n",
+       "      <td>INTENT</td>\n",
+       "      <td>0.955709</td>\n",
+       "      <td></td>\n",
+       "      <td>head_intent.order_status</td>\n",
+       "      <td>[Demo] Multi Demo Extravaganza Part Deux: The Revenge</td>\n",
+       "      <td>Demo Tests</td>\n",
+       "      <td>input_dataset</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3</th>\n",
+       "      <td>Default Start Flow</td>\n",
+       "      <td>START_PAGE</td>\n",
+       "      <td>Wha'ts the point of ordering anything?</td>\n",
+       "      <td>NO_MATCH</td>\n",
+       "      <td></td>\n",
+       "      <td>sentiment_router</td>\n",
+       "      <td>INTENT</td>\n",
+       "      <td>0.841712</td>\n",
+       "      <td></td>\n",
+       "      <td>head_intent.order_status</td>\n",
+       "      <td>[Demo] Multi Demo Extravaganza Part Deux: The Revenge</td>\n",
+       "      <td>Demo Tests</td>\n",
+       "      <td>input_dataset</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>4</th>\n",
+       "      <td>Default Start Flow</td>\n",
+       "      <td>START_PAGE</td>\n",
+       "      <td>I was looking at the order of operations yesterday but couldn't figure it out</td>\n",
+       "      <td>NO_MATCH</td>\n",
+       "      <td></td>\n",
+       "      <td>sentiment_router</td>\n",
+       "      <td>INTENT</td>\n",
+       "      <td>0.790275</td>\n",
+       "      <td></td>\n",
+       "      <td>head_intent.order_status</td>\n",
+       "      <td>[Demo] Multi Demo Extravaganza Part Deux: The Revenge</td>\n",
+       "      <td>Demo Tests</td>\n",
+       "      <td>input_dataset</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "    flow_display_name page_display_name  \\\n",
+       "0  Default Start Flow        START_PAGE   \n",
+       "1  Default Start Flow        START_PAGE   \n",
+       "2  Default Start Flow        START_PAGE   \n",
+       "3  Default Start Flow        START_PAGE   \n",
+       "4  Default Start Flow        START_PAGE   \n",
+       "\n",
+       "                                                                       utterance  \\\n",
+       "0                                                  I need to get my order status   \n",
+       "1                                         Trying to check the status of my order   \n",
+       "2                                                I hate this order status agent!   \n",
+       "3                                         Wha'ts the point of ordering anything?   \n",
+       "4  I was looking at the order of operations yesterday but couldn't figure it out   \n",
+       "\n",
+       "            expected_intent expected_parameters       target_page match_type  \\\n",
+       "0  head_intent.order_status                      sentiment_router     INTENT   \n",
+       "1  head_intent.order_status                      sentiment_router     INTENT   \n",
+       "2  head_intent.order_status                      sentiment_router     INTENT   \n",
+       "3                  NO_MATCH                      sentiment_router     INTENT   \n",
+       "4                  NO_MATCH                      sentiment_router     INTENT   \n",
+       "\n",
+       "   confidence parameters_set           detected_intent  \\\n",
+       "0    1.000000                 head_intent.order_status   \n",
+       "1    0.947959                 head_intent.order_status   \n",
+       "2    0.955709                 head_intent.order_status   \n",
+       "3    0.841712                 head_intent.order_status   \n",
+       "4    0.790275                 head_intent.order_status   \n",
+       "\n",
+       "                                      agent_display_name description  \\\n",
+       "0  [Demo] Multi Demo Extravaganza Part Deux: The Revenge  Demo Tests   \n",
+       "1  [Demo] Multi Demo Extravaganza Part Deux: The Revenge  Demo Tests   \n",
+       "2  [Demo] Multi Demo Extravaganza Part Deux: The Revenge  Demo Tests   \n",
+       "3  [Demo] Multi Demo Extravaganza Part Deux: The Revenge  Demo Tests   \n",
+       "4  [Demo] Multi Demo Extravaganza Part Deux: The Revenge  Demo Tests   \n",
+       "\n",
+       "    input_source  \n",
+       "0  input_dataset  \n",
+       "1  input_dataset  \n",
+       "2  input_dataset  \n",
+       "3  input_dataset  \n",
+       "4  input_dataset  "
+      ]
+     },
+     "execution_count": 20,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "df.head()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# (Option 2) CSV as Input\n",
+    "Similar to the above pipeline, except we will process the input data from a CSV file.\n",
+    "\n",
+    "For the output to local files, you will need to define 2 output destinations:\n",
+    "1. An output file for the full detailed results\n",
+    "2. An output file for the report summary\n",
+    "\n",
+    "- `agent_id`, The Dialogflow CX Agent ID.\n",
+    "- `input_path`, The local path where your input data lives\n",
+    "- `output_summary_path`, The local path where you want the report summary written\n",
+    "- `output_results_path`, The local path where you want the full results written\n",
+    "\n",
+    "You can find a [Sample CSV Dataset here.](https://github.com/GoogleCloudPlatform/dfcx-scrapi/blob/main/data/nlu_evals_sample.csv)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 13,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "agent_id = '<YOUR_AGENT_ID>'\n",
+    "\n",
+    "input_path = '/path/to/your/input/data.csv'\n",
+    "output_summary_path = '/path/to/your/output/summary.csv'\n",
+    "output_results_path = '/path/to/your/output/results.csv'"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Run NLU Evals\n",
+    "\n",
+    "There are 3 main stages that happen for the Eval Pipeline:\n",
+    "1. Process and validate the input data\n",
+    "2. Run the Eval Tests\n",
+    "3. Write the output summary and details to a report."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 14,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2023-09-11 11:31:51 INFO     ---------- STARTING Evals ----------\n",
+      "2023-09-11 11:31:56 WARNING  Text input is too long. Truncating to 256 characters.\n",
+      "2023-09-11 11:31:56 WARNING  TRUNCATED TEXT: Why can't I get my bill paid at all online? Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?Why can't I get my bill paid at all online?Why can't I get my bill paid at all onli\n",
+      "2023-09-11 11:31:58 INFO     Progress(0/15)[>                                                 ] 0.00%\n",
+      "2023-09-11 11:32:08 INFO     Progress(15/15)[------------------------------------------------->] 100.00%\n",
+      "2023-09-11 11:32:08 INFO     ---------- Evals COMPLETE ----------\n"
+     ]
+    }
+   ],
+   "source": [
+    "nlu = NluEvals(agent_id, creds_path=creds_path)\n",
+    "\n",
+    "df = nlu.process_input_csv(input_path)\n",
+    "df = nlu.run_evals(df)\n",
+    "nlu.write_summary_to_file(df, output_summary_path)\n",
+    "nlu.write_results_to_file(df, output_results_path)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "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"
+  },
+  "orig_nbformat": 4
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/src/dfcx_scrapi/agent_extract/test_cases.py b/src/dfcx_scrapi/agent_extract/test_cases.py
new file mode 100644
index 00000000..ee5be205
--- /dev/null
+++ b/src/dfcx_scrapi/agent_extract/test_cases.py
@@ -0,0 +1,184 @@
+"""Test Case processing methods and functions."""
+
+# Copyright 2023 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 json
+import os
+
+from typing import Dict, List, Any
+
+from dfcx_scrapi.agent_extract import common
+from dfcx_scrapi.agent_extract import types
+
+
+class TestCases:
+    """Test Case processing methods and functions."""
+
+    def __init__(self):
+        self.common = common.Common()
+
+    @staticmethod
+    def build_test_case_path_list(agent_local_path: str):
+        """Builds a list of files, each representing a test case."""
+        root_dir = agent_local_path + "/testCases"
+
+        test_case_paths = []
+
+        for test_case in os.listdir(root_dir):
+            end = test_case.split(".")[-1]
+            if end == "json":
+                test_case_path = f"{root_dir}/{test_case}"
+                test_case_paths.append(test_case_path)
+
+        return test_case_paths
+
+    @staticmethod
+    def get_test_case_intent_phrase_pair(
+        tc: types.TestCase) -> List[Dict[str, str]]:
+        """Parse Test Case and return a list of intents in use.
+
+        This method will produce a List of Dicts where the contents of each
+        dict is the Training Phrase and associated Triggered Intent as listed
+        in the Test Case Conversation Turn. This information is used to compare
+        the User Input training phrase with the actual training phrases that
+        exist in the Intent resource.
+
+        The dict format is as follows:
+            {
+                training_phrase: <training_phrase>,
+                intent: <intent_display_name>
+            }
+        """
+        intent_data = []
+
+        if tc.conversation_turns:
+            for turn in tc.conversation_turns:
+                user = turn["userInput"]
+                agent = turn["virtualAgentOutput"]
+                intent = agent.get("triggeredIntent", None)
+                phrase = user.get("input", None)
+
+                text = phrase.get("text", None)
+
+                if text:
+                    text = text["text"]
+
+                if intent and text:
+                    intent_data.append(
+                        {
+                            "user_utterance": text,
+                            "intent": intent["name"],
+                            "status": "valid",
+                            "training_phrases": [],
+                        }
+                    )
+
+        return intent_data
+
+    @staticmethod
+    def get_test_case_intent_data(agent_local_path: str):
+        """Collect all Intent Files and Training Phrases for Test Case."""
+        intents_path = agent_local_path + "/intents"
+
+        intent_paths = []
+
+        for intent_dir in os.listdir(intents_path):
+            intent_dir_path = f"{intents_path}/{intent_dir}"
+            intent_paths.append(
+                {"intent": intent_dir, "file_path": intent_dir_path}
+            )
+
+        return intent_paths
+
+    @staticmethod
+    def flatten_tp_data(tp_data: List[Any]):
+        """Flatten the Training Phrase proto to a list of strings."""
+        cleaned_tps = []
+
+        for tp in tp_data["trainingPhrases"]:
+            parts_list = [part["text"].lower() for part in tp["parts"]]
+            cleaned_tps.append("".join(parts_list))
+
+        return cleaned_tps
+
+    def gather_intent_tps(self, tc: types.TestCase):
+        """Collect all TPs associated with Intent data in Test Case."""
+        tc.associated_intent_data = {}
+
+        for i, pair in enumerate(tc.intent_data):
+            intent_dir = tc.agent_path + "/intents/" + pair["intent"]
+
+            try:
+                if "trainingPhrases" in os.listdir(intent_dir):
+                    training_phrases_path = intent_dir + "/trainingPhrases"
+
+                    for lang_file in os.listdir(training_phrases_path):
+                        # lang_code = lang_file.split(".")[0]
+                        lang_code_path = f"{training_phrases_path}/{lang_file}"
+
+                        with open(
+                            lang_code_path, "r", encoding="UTF-8"
+                        ) as tp_file:
+                            tp_data = json.load(tp_file)
+                            cleaned_tps = self.flatten_tp_data(tp_data)
+
+                            tp_file.close()
+
+                        tc.intent_data[i]["training_phrases"].extend(
+                            cleaned_tps
+                        )
+                        tc.associated_intent_data[pair["intent"]] = cleaned_tps
+
+            except FileNotFoundError:
+                tc.intent_data[i]["status"] = "invalid_intent"
+                tc.has_invalid_intent = True
+                continue
+
+        return tc
+
+    def process_test_case(self, tc: types.TestCase, stats: types.AgentData):
+        """Process a single Test Case file."""
+
+        with open(tc.dir_path, "r", encoding="UTF-8") as tc_file:
+            tc.data = json.load(tc_file)
+            tc.resource_id = tc.data.get("name", None)
+            tc.display_name = tc.data.get("displayName", None)
+            tc.tags = tc.data.get("tags", None)
+            tc.conversation_turns = tc.data.get(
+                "testCaseConversationTurns", None
+            )
+            tc.test_config = tc.data.get("testConfig", None)
+
+            full_tc_id = f"{stats.agent_id}/testCases/{tc.resource_id}"
+            tc.data["name"] = full_tc_id
+            stats.test_cases.append(tc.data)
+
+            tc_file.close()
+
+        return stats
+
+    def process_test_cases_directory(
+            self, agent_local_path: str, stats: types.AgentData):
+        """Processing the test cases dir in the JSON package structure."""
+        test_case_paths = self.build_test_case_path_list(agent_local_path)
+        stats.total_test_cases = len(test_case_paths)
+
+        for test_case in test_case_paths:
+            tc = types.TestCase()
+            tc.dir_path = test_case
+            tc.agent_path = agent_local_path
+            stats = self.process_test_case(tc, stats)
+
+        return stats
diff --git a/src/dfcx_scrapi/core/test_cases.py b/src/dfcx_scrapi/core/test_cases.py
index 4ace6ad9..b705a278 100644
--- a/src/dfcx_scrapi/core/test_cases.py
+++ b/src/dfcx_scrapi/core/test_cases.py
@@ -1,6 +1,6 @@
 """Test Cases Resource functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-
+import pandas as pd
 import logging
 from typing import Dict, List
 
@@ -22,7 +22,9 @@
 from google.cloud.dialogflowcx_v3beta1 import types
 from google.protobuf import field_mask_pb2
 
-from dfcx_scrapi.core.scrapi_base import ScrapiBase
+from dfcx_scrapi.core import scrapi_base
+from dfcx_scrapi.core import flows
+from dfcx_scrapi.core import pages
 
 # logging config
 logging.basicConfig(
@@ -32,7 +34,7 @@
 )
 
 
-class TestCases(ScrapiBase):
+class TestCases(scrapi_base.ScrapiBase):
     """Core Class for CX Test Cases."""
 
     def __init__(
@@ -59,12 +61,132 @@ def __init__(
             self.test_case_id = test_case_id
             self.client_options = self._set_region(self.test_case_id)
 
-    def list_test_cases(self, agent_id: str = None):
+    def _convert_test_result_to_string(self, test_case: types.TestCase) -> str:
+        """Converts the Enum result to a string."""
+        if test_case.last_test_result.test_result == 0:
+            return "TEST_RESULT_UNSPECIFIED"
+        elif test_case.last_test_result.test_result == 1:
+            return "PASSED"
+        elif test_case.last_test_result.test_result == 2:
+            return "FAILED"
+        else:
+            return ""
+
+    def _convert_test_result_to_bool(self, test_case: types.TestCase) -> bool:
+        """Converts the String result to a boolean."""
+        test_result = self._convert_test_result_to_string(test_case)
+
+        if test_result == "PASSED":
+            return True
+        elif test_result == "FAILED":
+            return False
+        else:
+            return None
+
+    def _get_flow_id_from_test_config(
+            self, test_case: types.TestCase) -> str:
+        """Attempt to get the Flow ID from the Test Case Test Config."""
+        if "flow" in test_case.test_config:
+            return test_case.test_config.flow
+        elif "page" in test_case.test_config:
+            return "/".join(test_case.test_config.page.split("/")[:8])
+        else:
+            agent_id = "/".join(test_case.name.split("/")[:6])
+            return f"{agent_id}/flows/00000000-0000-0000-0000-000000000000"
+
+    def _get_page_id_from_test_config(
+            self, test_case: types.TestCase, flow_id: str) -> str:
+        """Attempt to get the Page ID from the Test Case Test Config."""
+        if "page" in test_case.test_config:
+            return test_case.test_config.page
+        else:
+            return f"{flow_id}/pages/START_PAGE"
+
+    def _get_page_display_name(
+            self, flow_id: str, page_id: str,
+            pages_map: Dict[str, Dict[str, str]]) -> str:
+        """Get the Page Display Name from the Pages Map based on the Page ID."""
+        page_map = pages_map.get(flow_id, None)
+        page = "START_PAGE"
+        if page_map:
+            page = page_map.get(page_id, None)
+
+        return page
+
+    def _process_test_case(self, test_case, flows_map, pages_map):
+        """Takes a response from list_test_cases and returns a single row
+        dataframe of the test case result.
+
+        Args:
+          test_case: The test case response
+          flows_map: A dictionary mapping flow IDs to flow display names
+          pages_map: A dictionary with keys as flow IDs and values as
+            dictionaries mapping page IDs to page display names for that flow
+
+        Returns: A dataframe with columns:
+          display_name, id, short_id, tags, creation_time, start_flow,
+          start_page, test_result, passed, test_time
+        """
+        flow_id = self._get_flow_id_from_test_config(test_case)
+        page_id = self._get_page_id_from_test_config(test_case, flow_id)
+        page = self._get_page_display_name(flow_id, page_id, pages_map)
+        test_result = self._convert_test_result_to_bool(test_case)
+
+        return pd.DataFrame(
+            {
+                "display_name": [test_case.display_name],
+                "id": [test_case.name],
+                "short_id": [test_case.name.split("/")[-1]],
+                "tags": [",".join(test_case.tags)],
+                "creation_time": [test_case.creation_time],
+                "start_flow": [flows_map.get(flow_id, None)],
+                "start_page": [page],
+                # "test_result": [test_result],
+                "passed": [test_result],
+                "test_time": [test_case.last_test_result.test_time]
+            }
+        )
+
+    def _retest_cases(
+            self, test_case_df: pd.DataFrame, retest_ids: List[str]
+            ) -> pd.DataFrame:
+        print("To retest:", len(retest_ids))
+        response = self.batch_run_test_cases(retest_ids, self.agent_id)
+        for result in response.results:
+            # Results may not be in the same order as they went in
+            # Process the name a bit to remove the /results/id part
+            tc_id_full = "/".join(result.name.split("/")[:-2])
+            tc_id = tc_id_full.rsplit("/", maxsplit=1)[-1]
+
+            # Update dataframe where id = tc_id_full
+            # row = test_case_df.loc[test_case_df['id']==tc_id_full]
+            test_case_df.loc[
+                test_case_df["id"] == tc_id_full, "short_id"
+            ] = tc_id
+            # test_case_df.loc[
+            #     test_case_df["id"] == tc_id_full, "test_result"
+            # ] = str(result.test_result)
+            test_case_df.loc[
+                test_case_df["id"] == tc_id_full, "test_time"
+            ] = result.test_time
+            test_case_df.loc[test_case_df["id"] == tc_id_full,"passed"] = (
+                str(result.test_result) == "TestResult.PASSED"
+            )
+
+        return test_case_df
+
+    @scrapi_base.api_call_counter_decorator
+    def list_test_cases(
+        self, agent_id: str = None, include_conversation_turns: bool = False
+    ):
         """List test cases from an agent.
 
         Args:
-          agent_id: The agent to list all pages for.
+          agent_id: The agent to list all test cases for.
             `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`
+          include_conversation_turns: Either to include the conversation turns
+            in the test cases or not. Default is False
+            which shows only the basic metadata about the test cases.
 
         Returns:
           List of test cases from an agent.
@@ -73,8 +195,14 @@ def list_test_cases(self, agent_id: str = None):
         if not agent_id:
             agent_id = self.agent_id
 
-        request = types.test_case.ListTestCasesRequest()
-        request.parent = agent_id
+        if include_conversation_turns:
+            test_case_view = types.ListTestCasesRequest.TestCaseView.FULL
+        else:
+            test_case_view = types.ListTestCasesRequest.TestCaseView.BASIC
+
+        request = types.test_case.ListTestCasesRequest(
+            parent=agent_id, view=test_case_view
+        )
 
         client_options = self._set_region(agent_id)
 
@@ -91,6 +219,7 @@ def list_test_cases(self, agent_id: str = None):
 
         return test_cases
 
+    @scrapi_base.api_call_counter_decorator
     def export_test_cases(
         self,
         gcs_uri: str,
@@ -137,6 +266,7 @@ def export_test_cases(
 
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def create_test_case(self, test_case: types.TestCase, agent_id: str = None):
         """Create a new Test Case in the specified CX Agent.
 
@@ -162,6 +292,7 @@ def create_test_case(self, test_case: types.TestCase, agent_id: str = None):
         response = client.create_test_case(request)
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def get_test_case(self, test_case_id: str):
         """Get test case object from CX Agent.
 
@@ -184,6 +315,7 @@ def get_test_case(self, test_case_id: str):
         response = client.get_test_case(request)
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def import_test_cases(self, gcs_uri: str, agent_id: str = None):
         """Import test cases from cloud storage.
 
@@ -212,6 +344,7 @@ def import_test_cases(self, gcs_uri: str, agent_id: str = None):
         result = response.result()
         return result
 
+    @scrapi_base.api_call_counter_decorator
     def batch_delete_test_cases(
         self,
         test_case_ids: List[str],
@@ -242,6 +375,7 @@ def batch_delete_test_cases(
         )
         client.batch_delete_test_cases(request)
 
+    @scrapi_base.api_call_counter_decorator
     def list_test_case_results(self, test_case_id: str):
         """List the results from a specific Test Case.
 
@@ -272,6 +406,7 @@ def list_test_case_results(self, test_case_id: str):
 
         return test_case_results
 
+    @scrapi_base.api_call_counter_decorator
     def batch_run_test_cases(
         self,
         test_cases: List[str],
@@ -309,6 +444,7 @@ def batch_run_test_cases(
         results = response.result()
         return results
 
+    @scrapi_base.api_call_counter_decorator
     def update_test_case(
         self,
         test_case_id: str = None,
@@ -348,6 +484,7 @@ def update_test_case(
         response = client.update_test_case(request)
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def run_test_case(self, test_case_id: str, environment: str = None):
         """Run test case and get result for a specified test case.
 
@@ -375,6 +512,7 @@ def run_test_case(self, test_case_id: str, environment: str = None):
         results = response.result()
         return results
 
+    @scrapi_base.api_call_counter_decorator
     def get_test_case_result(self, test_case_result_id: str):
         """Get test case result for a specified run on a specified test case.
 
@@ -396,6 +534,7 @@ def get_test_case_result(self, test_case_result_id: str):
         response = client.get_test_case_result(request)
         return response
 
+    @scrapi_base.api_call_counter_decorator
     def calculate_coverage(self, coverage_type: int, agent_id: str = None):
         """Calculate coverage of different resources in the test case set.
 
@@ -430,3 +569,50 @@ def calculate_coverage(self, coverage_type: int, agent_id: str = None):
         )
         response = client.calculate_coverage(request)
         return response
+
+    def get_test_case_results_df(self, agent_id=None, retest_all=False):
+        """Convert Test Cases to Dataframe.
+
+        Gets the test case results for this agent, and generates a dataframe
+        with their details. Any tests without a result will be run in a batch.
+
+        Args:
+          agent_id: The agent to create the test case for. Format:
+              `projects/<Project ID>/locations/<Location ID>/agents/<Agent ID>`
+          retest_all: if true, all test cases are re-run,
+            regardless of whether or not they had a result
+
+        Returns:
+          DataFrame of test case results for this agent, with columns:
+            display_name, id, short_id, tags, creation_time, start_flow,
+            start_page, passed, test_time
+        """
+        if agent_id:
+            self.agent_id = agent_id
+
+        dfcx_flows = flows.Flows(creds=self.creds, agent_id=self.agent_id)
+        dfcx_pages = pages.Pages(creds=self.creds)
+        flows_map = dfcx_flows.get_flows_map(agent_id=self.agent_id)
+        pages_map = {}
+        for flow_id in flows_map.keys():
+            pages_map[flow_id] = dfcx_pages.get_pages_map(flow_id=flow_id)
+
+        test_case_results = self.list_test_cases(self.agent_id)
+        retest_ids = []
+        test_case_rows = []
+
+        for test_case in test_case_results:
+            row = self._process_test_case(test_case, flows_map, pages_map)
+            test_case_rows.append(row)
+            test_result = self._convert_test_result_to_string(test_case)
+            if retest_all or test_result == "TEST_RESULT_UNSPECIFIED":
+                retest_ids.append(test_case.name)
+
+        # Create dataframe
+        test_case_df = pd.concat(test_case_rows)
+
+        # Retest any that haven't been run yet
+        if len(retest_ids) > 0:
+            test_case_df = self._retest_cases(test_case_df,retest_ids)
+
+        return test_case_df
diff --git a/src/dfcx_scrapi/core_async/test_cases.py b/src/dfcx_scrapi/core_async/test_cases.py
index 71669943..c307d3bd 100644
--- a/src/dfcx_scrapi/core_async/test_cases.py
+++ b/src/dfcx_scrapi/core_async/test_cases.py
@@ -1,6 +1,6 @@
 """Test Cases Resource Async functions."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/tests/conftest.py b/tests/conftest.py
index dcb1a90e..70b7e65d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,6 +1,6 @@
 """Pytest config file"""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/tests/license/license_check.sh b/tests/license/license_check.sh
index 99bbaf8d..a9a4c1da 100644
--- a/tests/license/license_check.sh
+++ b/tests/license/license_check.sh
@@ -1,5 +1,5 @@
 #!/bin/bash
-# Copyright 2021 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/tests/test_agents.py b/tests/test_agents.py
index 87ac325f..44bf2f1f 100644
--- a/tests/test_agents.py
+++ b/tests/test_agents.py
@@ -1,6 +1,6 @@
 """Test Class for Agent Functions in SCRAPI lib."""
 
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/tests/test_search_util.py b/tests/test_search_util.py
index e32c2130..e0dc913d 100644
--- a/tests/test_search_util.py
+++ b/tests/test_search_util.py
@@ -1,5 +1,5 @@
 """Unit Tests for Search Util Class"""
-# Copyright 2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.