diff --git a/.eslintrc.js b/.eslintrc.js
index 8d979dc0f8645..4425ad3a12659 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -906,6 +906,18 @@ module.exports = {
       },
     },
 
+    /**
+     * Enterprise Search overrides
+     */
+    {
+      files: ['x-pack/plugins/enterprise_search/**/*.{ts,tsx}'],
+      excludedFiles: ['x-pack/plugins/enterprise_search/**/*.{test,mock}.{ts,tsx}'],
+      rules: {
+        'react-hooks/exhaustive-deps': 'off',
+        '@typescript-eslint/no-explicit-any': 'error',
+      },
+    },
+
     /**
      * disable jsx-a11y for kbn-ui-framework
      */
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 4aab9943022d4..f053c6da9c29b 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -201,6 +201,11 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib
 # Design
 **/*.scss  @elastic/kibana-design
 
+# Enterprise Search
+/x-pack/plugins/enterprise_search/ @elastic/app-search-frontend @elastic/workplace-search-frontend
+/x-pack/test/functional_enterprise_search/ @elastic/app-search-frontend @elastic/workplace-search-frontend
+/x-pack/plugins/enterprise_search/**/*.scss @elastic/ent-search-design
+
 # Elasticsearch UI
 /src/plugins/dev_tools/ @elastic/es-ui
 /src/plugins/console/  @elastic/es-ui
diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap
index 9fee7b50f371b..1cfded4dc7b8f 100644
--- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap
+++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap
@@ -149,7 +149,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
             "euiIconType": "logoSecurity",
             "id": "security",
             "label": "Security",
-            "order": 3000,
+            "order": 4000,
           },
           "data-test-subj": "siem",
           "href": "siem",
@@ -164,7 +164,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
             "euiIconType": "logoObservability",
             "id": "observability",
             "label": "Observability",
-            "order": 2000,
+            "order": 3000,
           },
           "data-test-subj": "metrics",
           "href": "metrics",
@@ -233,7 +233,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
             "euiIconType": "logoObservability",
             "id": "observability",
             "label": "Observability",
-            "order": 2000,
+            "order": 3000,
           },
           "data-test-subj": "logs",
           "href": "logs",
diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md
index 86e281a49b744..40fc3f977006f 100644
--- a/src/core/public/public.api.md
+++ b/src/core/public/public.api.md
@@ -582,6 +582,12 @@ export const DEFAULT_APP_CATEGORIES: Readonly<{
         euiIconType: string;
         order: number;
     };
+    enterpriseSearch: {
+        id: string;
+        label: string;
+        order: number;
+        euiIconType: string;
+    };
     observability: {
         id: string;
         label: string;
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index efeafc9e68d35..95912c3af63e5 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -566,6 +566,12 @@ export const DEFAULT_APP_CATEGORIES: Readonly<{
         euiIconType: string;
         order: number;
     };
+    enterpriseSearch: {
+        id: string;
+        label: string;
+        order: number;
+        euiIconType: string;
+    };
     observability: {
         id: string;
         label: string;
diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts
index 5708bcfeac31a..cc9bfb1db04d5 100644
--- a/src/core/utils/default_app_categories.ts
+++ b/src/core/utils/default_app_categories.ts
@@ -29,20 +29,28 @@ export const DEFAULT_APP_CATEGORIES = Object.freeze({
     euiIconType: 'logoKibana',
     order: 1000,
   },
+  enterpriseSearch: {
+    id: 'enterpriseSearch',
+    label: i18n.translate('core.ui.enterpriseSearchNavList.label', {
+      defaultMessage: 'Enterprise Search',
+    }),
+    order: 2000,
+    euiIconType: 'logoEnterpriseSearch',
+  },
   observability: {
     id: 'observability',
     label: i18n.translate('core.ui.observabilityNavList.label', {
       defaultMessage: 'Observability',
     }),
     euiIconType: 'logoObservability',
-    order: 2000,
+    order: 3000,
   },
   security: {
     id: 'security',
     label: i18n.translate('core.ui.securityNavList.label', {
       defaultMessage: 'Security',
     }),
-    order: 3000,
+    order: 4000,
     euiIconType: 'logoSecurity',
   },
   management: {
diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json
index 596ba17d343c0..d0055008eb9bf 100644
--- a/x-pack/.i18nrc.json
+++ b/x-pack/.i18nrc.json
@@ -16,6 +16,7 @@
     "xpack.data": "plugins/data_enhanced",
     "xpack.embeddableEnhanced": "plugins/embeddable_enhanced",
     "xpack.endpoint": "plugins/endpoint",
+    "xpack.enterpriseSearch": "plugins/enterprise_search",
     "xpack.features": "plugins/features",
     "xpack.fileUpload": "plugins/file_upload",
     "xpack.globalSearch": ["plugins/global_search"],
diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md
new file mode 100644
index 0000000000000..8c316c848184b
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/README.md
@@ -0,0 +1,25 @@
+# Enterprise Search
+
+## Overview
+
+This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In its current MVP state, the plugin provides a basic engines overview from App Search with the goal of gathering user feedback and raising product awareness.
+
+## Development
+
+1. When developing locally, Enterprise Search should be running locally alongside Kibana on `localhost:3002`.
+2. Update `config/kibana.dev.yml` with `enterpriseSearch.host: 'http://localhost:3002'`
+3. For faster QA/development, run Enterprise Search on [elasticsearch-native auth](https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm) and log in as the `elastic` superuser on Kibana.
+
+## Testing
+
+### Unit tests
+
+From `kibana-root-folder/x-pack`, run:
+
+```bash
+yarn test:jest plugins/enterprise_search
+```
+
+### E2E tests
+
+See [our functional test runner README](../../test/functional_enterprise_search).
diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts
new file mode 100644
index 0000000000000..c134131caba75
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/common/constants.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export const ENGINES_PAGE_SIZE = 10;
diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json
new file mode 100644
index 0000000000000..9a2daefcd8c6e
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/kibana.json
@@ -0,0 +1,10 @@
+{
+  "id": "enterpriseSearch",
+  "version": "kibana",
+  "kibanaVersion": "kibana",
+  "requiredPlugins": ["home", "features", "licensing"],
+  "configPath": ["enterpriseSearch"],
+  "optionalPlugins": ["usageCollection", "security"],
+  "server": true,
+  "ui": true
+}
diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts
new file mode 100644
index 0000000000000..14fde357a980a
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { mockHistory } from './react_router_history.mock';
+export { mockKibanaContext } from './kibana_context.mock';
+export { mockLicenseContext } from './license_context.mock';
+export { mountWithContext, mountWithKibanaContext } from './mount_with_context.mock';
+export { shallowWithIntl } from './shallow_with_i18n.mock';
+
+// Note: shallow_usecontext must be imported directly as a file
diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts
new file mode 100644
index 0000000000000..fcfa1b0a21f13
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { httpServiceMock } from 'src/core/public/mocks';
+
+/**
+ * A set of default Kibana context values to use across component tests.
+ * @see enterprise_search/public/index.tsx for the KibanaContext definition/import
+ */
+export const mockKibanaContext = {
+  http: httpServiceMock.createSetupContract(),
+  setBreadcrumbs: jest.fn(),
+  enterpriseSearchUrl: 'http://localhost:3002',
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts
new file mode 100644
index 0000000000000..7c37ecc7cde1b
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { licensingMock } from '../../../../licensing/public/mocks';
+
+export const mockLicenseContext = {
+  license: licensingMock.createLicense(),
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx
new file mode 100644
index 0000000000000..dfcda544459d4
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx
@@ -0,0 +1,49 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mount } from 'enzyme';
+
+import { I18nProvider } from '@kbn/i18n/react';
+import { KibanaContext } from '../';
+import { mockKibanaContext } from './kibana_context.mock';
+import { LicenseContext } from '../shared/licensing';
+import { mockLicenseContext } from './license_context.mock';
+
+/**
+ * This helper mounts a component with all the contexts/providers used
+ * by the production app, while allowing custom context to be
+ * passed in via a second arg
+ *
+ * Example usage:
+ *
+ * const wrapper = mountWithContext(<Component />, { enterpriseSearchUrl: 'someOverride', license: {} });
+ */
+export const mountWithContext = (children: React.ReactNode, context?: object) => {
+  return mount(
+    <I18nProvider>
+      <KibanaContext.Provider value={{ ...mockKibanaContext, ...context }}>
+        <LicenseContext.Provider value={{ ...mockLicenseContext, ...context }}>
+          {children}
+        </LicenseContext.Provider>
+      </KibanaContext.Provider>
+    </I18nProvider>
+  );
+};
+
+/**
+ * This helper mounts a component with just the default KibanaContext -
+ * useful for isolated / helper components that only need this context
+ *
+ * Same usage/override functionality as mountWithContext
+ */
+export const mountWithKibanaContext = (children: React.ReactNode, context?: object) => {
+  return mount(
+    <KibanaContext.Provider value={{ ...mockKibanaContext, ...context }}>
+      {children}
+    </KibanaContext.Provider>
+  );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts
new file mode 100644
index 0000000000000..fd422465d87f1
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/**
+ * NOTE: This variable name MUST start with 'mock*' in order for
+ * Jest to accept its use within a jest.mock()
+ */
+export const mockHistory = {
+  createHref: jest.fn(({ pathname }) => `/enterprise_search${pathname}`),
+  push: jest.fn(),
+  location: {
+    pathname: '/current-path',
+  },
+};
+
+jest.mock('react-router-dom', () => ({
+  useHistory: jest.fn(() => mockHistory),
+}));
+
+/**
+ * For example usage, @see public/applications/shared/react_router_helpers/eui_link.test.tsx
+ */
diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts
new file mode 100644
index 0000000000000..767a52a75d1fb
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/**
+ * NOTE: These variable names MUST start with 'mock*' in order for
+ * Jest to accept its use within a jest.mock()
+ */
+import { mockKibanaContext } from './kibana_context.mock';
+import { mockLicenseContext } from './license_context.mock';
+
+jest.mock('react', () => ({
+  ...(jest.requireActual('react') as object),
+  useContext: jest.fn(() => ({ ...mockKibanaContext, ...mockLicenseContext })),
+}));
+
+/**
+ * Example usage within a component test using shallow():
+ *
+ * import '../../../test_utils/mock_shallow_usecontext'; // Must come before React's import, adjust relative path as needed
+ *
+ * import React from 'react';
+ * import { shallow } from 'enzyme';
+ *
+ * // ... etc.
+ */
+
+/**
+ * If you need to override the default mock context values, you can do so via jest.mockImplementation:
+ *
+ * import React, { useContext } from 'react';
+ *
+ * // ... etc.
+ *
+ * it('some test', () => {
+ *   useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: 'someOverride' }));
+ * });
+ */
diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx
new file mode 100644
index 0000000000000..ae7d0b09f9872
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { I18nProvider } from '@kbn/i18n/react';
+import { IntlProvider } from 'react-intl';
+
+const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {});
+const { intl } = intlProvider.getChildContext();
+
+/**
+ * This helper shallow wraps a component with react-intl's <I18nProvider> which
+ * fixes "Could not find required `intl` object" console errors when running tests
+ *
+ * Example usage (should be the same as shallow()):
+ *
+ * const wrapper = shallowWithIntl(<Component />);
+ */
+export const shallowWithIntl = (children: React.ReactNode) => {
+  const context = { context: { intl } };
+
+  return shallow(<I18nProvider>{children}</I18nProvider>, context)
+    .childAt(0)
+    .dive(context)
+    .shallow();
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg
new file mode 100644
index 0000000000000..ceab918e92e70
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg
@@ -0,0 +1,3 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18">
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M9.776 1.389a7.66 7.66 0 00-.725-.04v.001H9a7.65 7.65 0 00-.051 15.301v-.001H9a7.65 7.65 0 00.776-15.261zm-1.52 1.254a6.401 6.401 0 00.02 12.716l2.333-3.791a.875.875 0 00-.354-1.242l-3.07-1.534a2.125 2.125 0 01-.859-3.015l1.93-3.134zm1.489 12.714l1.929-3.134a2.125 2.125 0 00-.86-3.015l-3.07-1.534a.875.875 0 01-.353-1.242L9.724 2.64a6.401 6.401 0 01.02 12.717z" fill="#000"/>
+</svg>
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png
new file mode 100644
index 0000000000000..4d988d14f0483
Binary files /dev/null and b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png differ
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg
new file mode 100644
index 0000000000000..2284a425b5add
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg
@@ -0,0 +1,4 @@
+<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" fill="none">
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M15.5 11.915L27 5.277 19.5.937a7.002 7.002 0 00-7 0l-8 4.62A7 7 0 001 11.62v9.237a7 7 0 003.5 6.062l7.5 4.33V17.979a7 7 0 013.5-6.063zM10 27.785v-9.808a9 9 0 014.5-7.793l8.503-4.91L18.5 2.67a5.003 5.003 0 00-5 0l-8 4.619a5 5 0 00-2.5 4.33v9.238a5 5 0 002.5 4.33l4.5 2.598z" fill="#343741" />
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M18.409 13.55a7.09 7.09 0 011.035 1.711A6.93 6.93 0 0120 17.978v13.27l7.5-4.33a7 7 0 003.5-6.061v-9.238a6.992 6.992 0 00-1.587-4.422L18.409 13.55zm2.777.705A8.933 8.933 0 0122 17.978v9.807l4.5-2.598a5 5 0 002.5-4.33v-9.238c0-.588-.106-1.161-.303-1.7l-7.51 4.336z" fill="#017D73" />
+</svg>
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg
new file mode 100644
index 0000000000000..4e01e9a0b34fb
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg
@@ -0,0 +1,4 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18">
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M4 9a5.002 5.002 0 005 5 5 5 0 10-5-5zm5.506 1.653L8.37 12.697a3.751 3.751 0 01-.003-7.394L7.402 7.04a1.625 1.625 0 00.519 2.142l1.465.976a.375.375 0 01.12.495zm1.092.607l-.777 1.4a3.751 3.751 0 00-.04-7.329L8.494 7.647a.375.375 0 00.12.495l1.465.976c.705.47.93 1.402.52 2.142z" fill="#000"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 1.375A5.125 5.125 0 001.375 6.5v5A5.125 5.125 0 006.5 16.625h5a5.125 5.125 0 005.125-5.125v-5A5.125 5.125 0 0011.5 1.375h-5zM2.625 6.5A3.875 3.875 0 016.5 2.625h5A3.875 3.875 0 0115.375 6.5v5a3.875 3.875 0 01-3.875 3.875h-5A3.875 3.875 0 012.625 11.5v-5z" fill="#000"/>
+  </svg>
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx
new file mode 100644
index 0000000000000..9bb5cd3bffdf5
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx
@@ -0,0 +1,74 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext } from 'react';
+import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { sendTelemetry } from '../../../shared/telemetry';
+import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
+import { KibanaContext, IKibanaContext } from '../../../index';
+
+import { EngineOverviewHeader } from '../engine_overview_header';
+
+import './empty_states.scss';
+
+export const EmptyState: React.FC = () => {
+  const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext;
+
+  const buttonProps = {
+    href: `${enterpriseSearchUrl}/as/engines/new`,
+    target: '_blank',
+    onClick: () =>
+      sendTelemetry({
+        http,
+        product: 'app_search',
+        action: 'clicked',
+        metric: 'create_first_engine_button',
+      }),
+  };
+
+  return (
+    <EuiPage restrictWidth>
+      <SetBreadcrumbs isRoot />
+
+      <EuiPageBody>
+        <EngineOverviewHeader />
+        <EuiPageContent className="emptyState">
+          <EuiEmptyPrompt
+            className="emptyState__prompt"
+            iconType="eyeClosed"
+            title={
+              <h2>
+                <FormattedMessage
+                  id="xpack.enterpriseSearch.appSearch.emptyState.title"
+                  defaultMessage="Create your first engine"
+                />
+              </h2>
+            }
+            titleSize="l"
+            body={
+              <p>
+                <FormattedMessage
+                  id="xpack.enterpriseSearch.appSearch.emptyState.description1"
+                  defaultMessage="An App Search engine stores the documents for your search experience."
+                />
+              </p>
+            }
+            actions={
+              <EuiButton iconType="popout" fill {...buttonProps}>
+                <FormattedMessage
+                  id="xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta"
+                  defaultMessage="Create an engine"
+                />
+              </EuiButton>
+            }
+          />
+        </EuiPageContent>
+      </EuiPageBody>
+    </EuiPage>
+  );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss
new file mode 100644
index 0000000000000..01b0903add559
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/**
+ * Empty/Error UI states
+ */
+.emptyState {
+  min-height: $euiSizeXXL * 11.25;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+
+  &__prompt > .euiIcon {
+    margin-bottom: $euiSizeS;
+  }
+}
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx
new file mode 100644
index 0000000000000..12bf003564103
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx
@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import '../../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { EuiEmptyPrompt, EuiButton, EuiLoadingContent } from '@elastic/eui';
+
+jest.mock('../../../shared/telemetry', () => ({
+  sendTelemetry: jest.fn(),
+  SendAppSearchTelemetry: jest.fn(),
+}));
+import { sendTelemetry } from '../../../shared/telemetry';
+
+import { ErrorState, EmptyState, LoadingState } from './';
+
+describe('ErrorState', () => {
+  it('renders', () => {
+    const wrapper = shallow(<ErrorState />);
+
+    expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
+  });
+});
+
+describe('EmptyState', () => {
+  it('renders', () => {
+    const wrapper = shallow(<EmptyState />);
+
+    expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
+  });
+
+  it('sends telemetry on create first engine click', () => {
+    const wrapper = shallow(<EmptyState />);
+    const prompt = wrapper.find(EuiEmptyPrompt).dive();
+    const button = prompt.find(EuiButton);
+
+    button.simulate('click');
+    expect(sendTelemetry).toHaveBeenCalled();
+    (sendTelemetry as jest.Mock).mockClear();
+  });
+});
+
+describe('LoadingState', () => {
+  it('renders', () => {
+    const wrapper = shallow(<LoadingState />);
+
+    expect(wrapper.find(EuiLoadingContent)).toHaveLength(2);
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx
new file mode 100644
index 0000000000000..d8eeff2aba1c6
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx
@@ -0,0 +1,95 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext } from 'react';
+import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { EuiButton } from '../../../shared/react_router_helpers';
+import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
+import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
+import { KibanaContext, IKibanaContext } from '../../../index';
+import { EngineOverviewHeader } from '../engine_overview_header';
+
+import './empty_states.scss';
+
+export const ErrorState: React.FC = () => {
+  const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
+
+  return (
+    <EuiPage restrictWidth>
+      <SetBreadcrumbs isRoot />
+      <SendTelemetry action="error" metric="cannot_connect" />
+
+      <EuiPageBody>
+        <EngineOverviewHeader isButtonDisabled />
+        <EuiPageContent className="emptyState">
+          <EuiEmptyPrompt
+            className="emptyState__prompt"
+            iconType="alert"
+            iconColor="danger"
+            title={
+              <h2>
+                <FormattedMessage
+                  id="xpack.enterpriseSearch.appSearch.errorConnectingState.title"
+                  defaultMessage="Unable to connect"
+                />
+              </h2>
+            }
+            titleSize="l"
+            body={
+              <>
+                <p>
+                  <FormattedMessage
+                    id="xpack.enterpriseSearch.appSearch.errorConnectingState.description1"
+                    defaultMessage="We can’t establish a connection to App Search at the host URL: {enterpriseSearchUrl}"
+                    values={{
+                      enterpriseSearchUrl: <EuiCode>{enterpriseSearchUrl}</EuiCode>,
+                    }}
+                  />
+                </p>
+                <ol className="eui-textLeft">
+                  <li>
+                    <FormattedMessage
+                      id="xpack.enterpriseSearch.appSearch.errorConnectingState.description2"
+                      defaultMessage="Ensure the host URL is configured correctly in {configFile}."
+                      values={{
+                        configFile: <EuiCode>config/kibana.yml</EuiCode>,
+                      }}
+                    />
+                  </li>
+                  <li>
+                    <FormattedMessage
+                      id="xpack.enterpriseSearch.appSearch.errorConnectingState.description3"
+                      defaultMessage="Confirm that the App Search server is responsive."
+                    />
+                  </li>
+                  <li>
+                    <FormattedMessage
+                      id="xpack.enterpriseSearch.appSearch.errorConnectingState.description4"
+                      defaultMessage="Review the Setup guide or check your server log for {pluginLog} log messages."
+                      values={{
+                        pluginLog: <EuiCode>[enterpriseSearch][plugins]</EuiCode>,
+                      }}
+                    />
+                  </li>
+                </ol>
+              </>
+            }
+            actions={
+              <EuiButton iconType="help" fill to="/setup_guide">
+                <FormattedMessage
+                  id="xpack.enterpriseSearch.appSearch.errorConnectingState.setupGuideCta"
+                  defaultMessage="Review setup guide"
+                />
+              </EuiButton>
+            }
+          />
+        </EuiPageContent>
+      </EuiPageBody>
+    </EuiPage>
+  );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts
new file mode 100644
index 0000000000000..e92bf214c4cc7
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { LoadingState } from './loading_state';
+export { EmptyState } from './empty_state';
+export { ErrorState } from './error_state';
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx
new file mode 100644
index 0000000000000..2be917c8df096
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiLoadingContent } from '@elastic/eui';
+
+import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
+import { EngineOverviewHeader } from '../engine_overview_header';
+
+import './empty_states.scss';
+
+export const LoadingState: React.FC = () => {
+  return (
+    <EuiPage restrictWidth>
+      <SetBreadcrumbs isRoot />
+
+      <EuiPageBody>
+        <EngineOverviewHeader />
+        <EuiPageContent className="emptyState">
+          <EuiLoadingContent lines={5} />
+          <EuiSpacer size="xxl" />
+          <EuiLoadingContent lines={4} />
+        </EuiPageContent>
+      </EuiPageBody>
+    </EuiPage>
+  );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss
new file mode 100644
index 0000000000000..2c7f7de6458e2
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/**
+ * Engine Overview
+ */
+.engineOverview {
+  width: 100%;
+
+  &__body {
+    padding: $euiSize;
+
+    @include euiBreakpoint('m', 'l', 'xl') {
+      padding: $euiSizeXL;
+    }
+  }
+}
+
+.engineIcon {
+  display: inline-block;
+  width: $euiSize;
+  height: $euiSize;
+  margin-right: $euiSizeXS;
+}
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx
new file mode 100644
index 0000000000000..4d2a2ea1df9aa
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx
@@ -0,0 +1,171 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import '../../../__mocks__/react_router_history.mock';
+
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { render, ReactWrapper } from 'enzyme';
+
+import { I18nProvider } from '@kbn/i18n/react';
+import { KibanaContext } from '../../../';
+import { LicenseContext } from '../../../shared/licensing';
+import { mountWithContext, mockKibanaContext } from '../../../__mocks__';
+
+import { EmptyState, ErrorState } from '../empty_states';
+import { EngineTable, IEngineTablePagination } from './engine_table';
+
+import { EngineOverview } from './';
+
+describe('EngineOverview', () => {
+  describe('non-happy-path states', () => {
+    it('isLoading', () => {
+      // We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect)
+      // TODO: Consider pulling this out to a renderWithContext mock/helper
+      const wrapper: Cheerio = render(
+        <I18nProvider>
+          <KibanaContext.Provider value={{ http: {} }}>
+            <LicenseContext.Provider value={{ license: {} }}>
+              <EngineOverview />
+            </LicenseContext.Provider>
+          </KibanaContext.Provider>
+        </I18nProvider>
+      );
+
+      // render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly
+      expect(wrapper.find('.euiLoadingContent')).toHaveLength(2);
+    });
+
+    it('isEmpty', async () => {
+      const wrapper = await mountWithApiMock({
+        get: () => ({
+          results: [],
+          meta: { page: { total_results: 0 } },
+        }),
+      });
+
+      expect(wrapper.find(EmptyState)).toHaveLength(1);
+    });
+
+    it('hasErrorConnecting', async () => {
+      const wrapper = await mountWithApiMock({
+        get: () => ({ invalidPayload: true }),
+      });
+      expect(wrapper.find(ErrorState)).toHaveLength(1);
+    });
+  });
+
+  describe('happy-path states', () => {
+    const mockedApiResponse = {
+      results: [
+        {
+          name: 'hello-world',
+          created_at: 'Fri, 1 Jan 1970 12:00:00 +0000',
+          document_count: 50,
+          field_count: 10,
+        },
+      ],
+      meta: {
+        page: {
+          current: 1,
+          total_pages: 10,
+          total_results: 100,
+          size: 10,
+        },
+      },
+    };
+    const mockApi = jest.fn(() => mockedApiResponse);
+    let wrapper: ReactWrapper;
+
+    beforeAll(async () => {
+      wrapper = await mountWithApiMock({ get: mockApi });
+    });
+
+    it('renders', () => {
+      expect(wrapper.find(EngineTable)).toHaveLength(1);
+    });
+
+    it('calls the engines API', () => {
+      expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', {
+        query: {
+          type: 'indexed',
+          pageIndex: 1,
+        },
+      });
+    });
+
+    describe('pagination', () => {
+      const getTablePagination: () => IEngineTablePagination = () =>
+        wrapper.find(EngineTable).first().prop('pagination');
+
+      it('passes down page data from the API', () => {
+        const pagination = getTablePagination();
+
+        expect(pagination.totalEngines).toEqual(100);
+        expect(pagination.pageIndex).toEqual(0);
+      });
+
+      it('re-polls the API on page change', async () => {
+        await act(async () => getTablePagination().onPaginate(5));
+        wrapper.update();
+
+        expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', {
+          query: {
+            type: 'indexed',
+            pageIndex: 5,
+          },
+        });
+        expect(getTablePagination().pageIndex).toEqual(4);
+      });
+    });
+
+    describe('when on a platinum license', () => {
+      beforeAll(async () => {
+        mockApi.mockClear();
+        wrapper = await mountWithApiMock({
+          license: { type: 'platinum', isActive: true },
+          get: mockApi,
+        });
+      });
+
+      it('renders a 2nd meta engines table', () => {
+        expect(wrapper.find(EngineTable)).toHaveLength(2);
+      });
+
+      it('makes a 2nd call to the engines API with type meta', () => {
+        expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', {
+          query: {
+            type: 'meta',
+            pageIndex: 1,
+          },
+        });
+      });
+    });
+  });
+
+  /**
+   * Test helpers
+   */
+
+  const mountWithApiMock = async ({ get, license }: { get(): any; license?: object }) => {
+    let wrapper: ReactWrapper | undefined;
+    const httpMock = { ...mockKibanaContext.http, get };
+
+    // We get a lot of act() warning/errors in the terminal without this.
+    // TBH, I don't fully understand why since Enzyme's mount is supposed to
+    // have act() baked in - could be because of the wrapping context provider?
+    await act(async () => {
+      wrapper = mountWithContext(<EngineOverview />, { http: httpMock, license });
+    });
+    if (wrapper) {
+      wrapper.update(); // This seems to be required for the DOM to actually update
+
+      return wrapper;
+    } else {
+      throw new Error('Could not mount wrapper');
+    }
+  };
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx
new file mode 100644
index 0000000000000..13d092a657d11
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx
@@ -0,0 +1,155 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext, useEffect, useState } from 'react';
+import {
+  EuiPage,
+  EuiPageBody,
+  EuiPageContent,
+  EuiPageContentHeader,
+  EuiPageContentBody,
+  EuiTitle,
+  EuiSpacer,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
+import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
+import { LicenseContext, ILicenseContext, hasPlatinumLicense } from '../../../shared/licensing';
+import { KibanaContext, IKibanaContext } from '../../../index';
+
+import EnginesIcon from '../../assets/engine.svg';
+import MetaEnginesIcon from '../../assets/meta_engine.svg';
+
+import { LoadingState, EmptyState, ErrorState } from '../empty_states';
+import { EngineOverviewHeader } from '../engine_overview_header';
+import { EngineTable } from './engine_table';
+
+import './engine_overview.scss';
+
+interface IGetEnginesParams {
+  type: string;
+  pageIndex: number;
+}
+interface ISetEnginesCallbacks {
+  setResults: React.Dispatch<React.SetStateAction<never[]>>;
+  setResultsTotal: React.Dispatch<React.SetStateAction<number>>;
+}
+
+export const EngineOverview: React.FC = () => {
+  const { http } = useContext(KibanaContext) as IKibanaContext;
+  const { license } = useContext(LicenseContext) as ILicenseContext;
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasErrorConnecting, setHasErrorConnecting] = useState(false);
+
+  const [engines, setEngines] = useState([]);
+  const [enginesPage, setEnginesPage] = useState(1);
+  const [enginesTotal, setEnginesTotal] = useState(0);
+  const [metaEngines, setMetaEngines] = useState([]);
+  const [metaEnginesPage, setMetaEnginesPage] = useState(1);
+  const [metaEnginesTotal, setMetaEnginesTotal] = useState(0);
+
+  const getEnginesData = async ({ type, pageIndex }: IGetEnginesParams) => {
+    return await http.get('/api/app_search/engines', {
+      query: { type, pageIndex },
+    });
+  };
+  const setEnginesData = async (params: IGetEnginesParams, callbacks: ISetEnginesCallbacks) => {
+    try {
+      const response = await getEnginesData(params);
+
+      callbacks.setResults(response.results);
+      callbacks.setResultsTotal(response.meta.page.total_results);
+
+      setIsLoading(false);
+    } catch (error) {
+      setHasErrorConnecting(true);
+    }
+  };
+
+  useEffect(() => {
+    const params = { type: 'indexed', pageIndex: enginesPage };
+    const callbacks = { setResults: setEngines, setResultsTotal: setEnginesTotal };
+
+    setEnginesData(params, callbacks);
+  }, [enginesPage]);
+
+  useEffect(() => {
+    if (hasPlatinumLicense(license)) {
+      const params = { type: 'meta', pageIndex: metaEnginesPage };
+      const callbacks = { setResults: setMetaEngines, setResultsTotal: setMetaEnginesTotal };
+
+      setEnginesData(params, callbacks);
+    }
+  }, [license, metaEnginesPage]);
+
+  if (hasErrorConnecting) return <ErrorState />;
+  if (isLoading) return <LoadingState />;
+  if (!engines.length) return <EmptyState />;
+
+  return (
+    <EuiPage restrictWidth className="engineOverview">
+      <SetBreadcrumbs isRoot />
+      <SendTelemetry action="viewed" metric="engines_overview" />
+
+      <EuiPageBody>
+        <EngineOverviewHeader />
+
+        <EuiPageContent panelPaddingSize="s" className="engineOverview__body">
+          <EuiPageContentHeader>
+            <EuiTitle size="s">
+              <h2>
+                <img src={EnginesIcon} alt="" className="engineIcon" />
+                <FormattedMessage
+                  id="xpack.enterpriseSearch.appSearch.enginesOverview.engines"
+                  defaultMessage="Engines"
+                />
+              </h2>
+            </EuiTitle>
+          </EuiPageContentHeader>
+          <EuiPageContentBody data-test-subj="appSearchEngines">
+            <EngineTable
+              data={engines}
+              pagination={{
+                totalEngines: enginesTotal,
+                pageIndex: enginesPage - 1,
+                onPaginate: setEnginesPage,
+              }}
+            />
+          </EuiPageContentBody>
+
+          {metaEngines.length > 0 && (
+            <>
+              <EuiSpacer size="xl" />
+              <EuiPageContentHeader>
+                <EuiTitle size="s">
+                  <h2>
+                    <img src={MetaEnginesIcon} alt="" className="engineIcon" />
+                    <FormattedMessage
+                      id="xpack.enterpriseSearch.appSearch.enginesOverview.metaEngines"
+                      defaultMessage="Meta Engines"
+                    />
+                  </h2>
+                </EuiTitle>
+              </EuiPageContentHeader>
+              <EuiPageContentBody data-test-subj="appSearchMetaEngines">
+                <EngineTable
+                  data={metaEngines}
+                  pagination={{
+                    totalEngines: metaEnginesTotal,
+                    pageIndex: metaEnginesPage - 1,
+                    onPaginate: setMetaEnginesPage,
+                  }}
+                />
+              </EuiPageContentBody>
+            </>
+          )}
+        </EuiPageContent>
+      </EuiPageBody>
+    </EuiPage>
+  );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx
new file mode 100644
index 0000000000000..46b6e61e352de
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx
@@ -0,0 +1,80 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiLink } from '@elastic/eui';
+
+import { mountWithContext } from '../../../__mocks__';
+jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() }));
+import { sendTelemetry } from '../../../shared/telemetry';
+
+import { EngineTable } from './engine_table';
+
+describe('EngineTable', () => {
+  const onPaginate = jest.fn(); // onPaginate updates the engines API call upstream
+
+  const wrapper = mountWithContext(
+    <EngineTable
+      data={[
+        {
+          name: 'test-engine',
+          created_at: 'Fri, 1 Jan 1970 12:00:00 +0000',
+          document_count: 99999,
+          field_count: 10,
+        },
+      ]}
+      pagination={{
+        totalEngines: 50,
+        pageIndex: 0,
+        onPaginate,
+      }}
+    />
+  );
+  const table = wrapper.find(EuiBasicTable);
+
+  it('renders', () => {
+    expect(table).toHaveLength(1);
+    expect(table.prop('pagination').totalItemCount).toEqual(50);
+
+    const tableContent = table.text();
+    expect(tableContent).toContain('test-engine');
+    expect(tableContent).toContain('January 1, 1970');
+    expect(tableContent).toContain('99,999');
+    expect(tableContent).toContain('10');
+
+    expect(table.find(EuiPagination).find(EuiButtonEmpty)).toHaveLength(5); // Should display 5 pages at 10 engines per page
+  });
+
+  it('contains engine links which send telemetry', () => {
+    const engineLinks = wrapper.find(EuiLink);
+
+    engineLinks.forEach((link) => {
+      expect(link.prop('href')).toEqual('http://localhost:3002/as/engines/test-engine');
+      link.simulate('click');
+
+      expect(sendTelemetry).toHaveBeenCalledWith({
+        http: expect.any(Object),
+        product: 'app_search',
+        action: 'clicked',
+        metric: 'engine_table_link',
+      });
+    });
+  });
+
+  it('triggers onPaginate', () => {
+    table.prop('onChange')({ page: { index: 4 } });
+
+    expect(onPaginate).toHaveBeenCalledWith(5);
+  });
+
+  it('handles empty data', () => {
+    const emptyWrapper = mountWithContext(
+      <EngineTable data={[]} pagination={{ totalEngines: 0, pageIndex: 0, onPaginate: () => {} }} />
+    );
+    const emptyTable = emptyWrapper.find(EuiBasicTable);
+    expect(emptyTable.prop('pagination').pageIndex).toEqual(0);
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx
new file mode 100644
index 0000000000000..1e58d820dc83b
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx
@@ -0,0 +1,153 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext } from 'react';
+import { EuiBasicTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui';
+import { FormattedMessage, FormattedDate, FormattedNumber } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
+
+import { sendTelemetry } from '../../../shared/telemetry';
+import { KibanaContext, IKibanaContext } from '../../../index';
+
+import { ENGINES_PAGE_SIZE } from '../../../../../common/constants';
+
+export interface IEngineTableData {
+  name: string;
+  created_at: string;
+  document_count: number;
+  field_count: number;
+}
+export interface IEngineTablePagination {
+  totalEngines: number;
+  pageIndex: number;
+  onPaginate(pageIndex: number): void;
+}
+export interface IEngineTableProps {
+  data: IEngineTableData[];
+  pagination: IEngineTablePagination;
+}
+export interface IOnChange {
+  page: {
+    index: number;
+  };
+}
+
+export const EngineTable: React.FC<IEngineTableProps> = ({
+  data,
+  pagination: { totalEngines, pageIndex, onPaginate },
+}) => {
+  const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext;
+  const engineLinkProps = (name: string) => ({
+    href: `${enterpriseSearchUrl}/as/engines/${name}`,
+    target: '_blank',
+    onClick: () =>
+      sendTelemetry({
+        http,
+        product: 'app_search',
+        action: 'clicked',
+        metric: 'engine_table_link',
+      }),
+  });
+
+  const columns: Array<EuiBasicTableColumn<IEngineTableData>> = [
+    {
+      field: 'name',
+      name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', {
+        defaultMessage: 'Name',
+      }),
+      render: (name: string) => (
+        <EuiLink data-test-subj="engineNameLink" {...engineLinkProps(name)}>
+          {name}
+        </EuiLink>
+      ),
+      width: '30%',
+      truncateText: true,
+      mobileOptions: {
+        header: true,
+        // Note: the below props are valid props per https://elastic.github.io/eui/#/tabular-content/tables (Responsive tables), but EUI's types have a bug reporting it as an error
+        // @ts-ignore
+        enlarge: true,
+        fullWidth: true,
+        truncateText: false,
+      },
+    },
+    {
+      field: 'created_at',
+      name: i18n.translate(
+        'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.createdAt',
+        {
+          defaultMessage: 'Created At',
+        }
+      ),
+      dataType: 'string',
+      render: (dateString: string) => (
+        // e.g., January 1, 1970
+        <FormattedDate value={new Date(dateString)} year="numeric" month="long" day="numeric" />
+      ),
+    },
+    {
+      field: 'document_count',
+      name: i18n.translate(
+        'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.documentCount',
+        {
+          defaultMessage: 'Document Count',
+        }
+      ),
+      dataType: 'number',
+      render: (number: number) => <FormattedNumber value={number} />,
+      truncateText: true,
+    },
+    {
+      field: 'field_count',
+      name: i18n.translate(
+        'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.fieldCount',
+        {
+          defaultMessage: 'Field Count',
+        }
+      ),
+      dataType: 'number',
+      render: (number: number) => <FormattedNumber value={number} />,
+      truncateText: true,
+    },
+    {
+      field: 'name',
+      name: i18n.translate(
+        'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.actions',
+        {
+          defaultMessage: 'Actions',
+        }
+      ),
+      dataType: 'string',
+      render: (name: string) => (
+        <EuiLink {...engineLinkProps(name)}>
+          <FormattedMessage
+            id="xpack.enterpriseSearch.appSearch.enginesOverview.table.action.manage"
+            defaultMessage="Manage"
+          />
+        </EuiLink>
+      ),
+      align: 'right',
+      width: '100px',
+    },
+  ];
+
+  return (
+    <EuiBasicTable
+      items={data}
+      columns={columns}
+      pagination={{
+        pageIndex,
+        pageSize: ENGINES_PAGE_SIZE,
+        totalItemCount: totalEngines,
+        hidePerPageOptions: true,
+      }}
+      onChange={({ page }: IOnChange) => {
+        const { index } = page;
+        onPaginate(index + 1); // Note on paging - App Search's API pages start at 1, EuiBasicTables' pages start at 0
+      }}
+    />
+  );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts
new file mode 100644
index 0000000000000..48b7645dc39e8
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { EngineOverview } from './engine_overview';
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx
new file mode 100644
index 0000000000000..2e49540270ef0
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import '../../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() }));
+import { sendTelemetry } from '../../../shared/telemetry';
+
+import { EngineOverviewHeader } from '../engine_overview_header';
+
+describe('EngineOverviewHeader', () => {
+  it('renders', () => {
+    const wrapper = shallow(<EngineOverviewHeader />);
+    expect(wrapper.find('h1')).toHaveLength(1);
+  });
+
+  it('renders a launch app search button that sends telemetry on click', () => {
+    const wrapper = shallow(<EngineOverviewHeader />);
+    const button = wrapper.find('[data-test-subj="launchButton"]');
+
+    expect(button.prop('href')).toBe('http://localhost:3002/as');
+    expect(button.prop('isDisabled')).toBeFalsy();
+
+    button.simulate('click');
+    expect(sendTelemetry).toHaveBeenCalled();
+  });
+
+  it('renders a disabled button when isButtonDisabled is true', () => {
+    const wrapper = shallow(<EngineOverviewHeader isButtonDisabled />);
+    const button = wrapper.find('[data-test-subj="launchButton"]');
+
+    expect(button.prop('isDisabled')).toBe(true);
+    expect(button.prop('href')).toBeUndefined();
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx
new file mode 100644
index 0000000000000..9aafa8ec0380c
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext } from 'react';
+import {
+  EuiPageHeader,
+  EuiPageHeaderSection,
+  EuiTitle,
+  EuiButton,
+  EuiButtonProps,
+  EuiLinkProps,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { sendTelemetry } from '../../../shared/telemetry';
+import { KibanaContext, IKibanaContext } from '../../../index';
+
+interface IEngineOverviewHeaderProps {
+  isButtonDisabled?: boolean;
+}
+
+export const EngineOverviewHeader: React.FC<IEngineOverviewHeaderProps> = ({
+  isButtonDisabled,
+}) => {
+  const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext;
+
+  const buttonProps = {
+    fill: true,
+    iconType: 'popout',
+    'data-test-subj': 'launchButton',
+  } as EuiButtonProps & EuiLinkProps;
+
+  if (isButtonDisabled) {
+    buttonProps.isDisabled = true;
+  } else {
+    buttonProps.href = `${enterpriseSearchUrl}/as`;
+    buttonProps.target = '_blank';
+    buttonProps.onClick = () =>
+      sendTelemetry({
+        http,
+        product: 'app_search',
+        action: 'clicked',
+        metric: 'header_launch_button',
+      });
+  }
+
+  return (
+    <EuiPageHeader>
+      <EuiPageHeaderSection>
+        <EuiTitle size="l">
+          <h1>
+            <FormattedMessage
+              id="xpack.enterpriseSearch.appSearch.enginesOverview.title"
+              defaultMessage="Engine Overview"
+            />
+          </h1>
+        </EuiTitle>
+      </EuiPageHeaderSection>
+      <EuiPageHeaderSection>
+        <EuiButton {...buttonProps}>
+          <FormattedMessage
+            id="xpack.enterpriseSearch.appSearch.productCta"
+            defaultMessage="Launch App Search"
+          />
+        </EuiButton>
+      </EuiPageHeaderSection>
+    </EuiPageHeader>
+  );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts
new file mode 100644
index 0000000000000..2d37f037e21e5
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { EngineOverviewHeader } from './engine_overview_header';
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts
new file mode 100644
index 0000000000000..c367424d375f9
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { SetupGuide } from './setup_guide';
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx
new file mode 100644
index 0000000000000..82cc344d49632
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
+import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide';
+import { SetupGuide } from './';
+
+describe('SetupGuide', () => {
+  it('renders', () => {
+    const wrapper = shallow(<SetupGuide />);
+
+    expect(wrapper.find(SetupGuideLayout)).toHaveLength(1);
+    expect(wrapper.find(SetBreadcrumbs)).toHaveLength(1);
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx
new file mode 100644
index 0000000000000..df278bf938a69
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx
@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { EuiSpacer, EuiTitle, EuiText } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
+
+import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide';
+import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
+import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
+import GettingStarted from '../../assets/getting_started.png';
+
+export const SetupGuide: React.FC = () => (
+  <SetupGuideLayout
+    productName={i18n.translate('xpack.enterpriseSearch.appSearch.productName', {
+      defaultMessage: 'App Search',
+    })}
+    productEuiIcon="logoAppSearch"
+    standardAuthLink="https://swiftype.com/documentation/app-search/self-managed/security#standard"
+    elasticsearchNativeAuthLink="https://swiftype.com/documentation/app-search/self-managed/security#elasticsearch-native-realm"
+  >
+    <SetBreadcrumbs text="Setup Guide" />
+    <SendTelemetry action="viewed" metric="setup_guide" />
+
+    <a
+      href="https://www.elastic.co/webinars/getting-started-with-elastic-app-search"
+      target="_blank"
+      rel="noopener noreferrer"
+    >
+      <img
+        className="setupGuide__thumbnail"
+        src={GettingStarted}
+        alt={i18n.translate('xpack.enterpriseSearch.appSearch.setupGuide.videoAlt', {
+          defaultMessage:
+            "Getting started with App Search - in this short video we'll guide you through how to get App Search up and running",
+        })}
+        width="1280"
+        height-="720"
+      />
+    </a>
+
+    <EuiTitle size="s">
+      <p>
+        <FormattedMessage
+          id="xpack.enterpriseSearch.appSearch.setupGuide.description"
+          defaultMessage="Elastic App Search provides tools to design and deploy a powerful search to your websites and mobile applications."
+        />
+      </p>
+    </EuiTitle>
+    <EuiSpacer size="m" />
+    <EuiText>
+      <p>
+        <FormattedMessage
+          id="xpack.enterpriseSearch.appSearch.setupGuide.notConfigured"
+          defaultMessage="App Search is not configured in your Kibana instance yet."
+        />
+      </p>
+    </EuiText>
+  </SetupGuideLayout>
+);
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx
new file mode 100644
index 0000000000000..45e318ca0f9d9
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx
@@ -0,0 +1,46 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import '../__mocks__/shallow_usecontext.mock';
+
+import React, { useContext } from 'react';
+import { Redirect } from 'react-router-dom';
+import { shallow } from 'enzyme';
+
+import { SetupGuide } from './components/setup_guide';
+import { EngineOverview } from './components/engine_overview';
+
+import { AppSearch } from './';
+
+describe('App Search Routes', () => {
+  describe('/', () => {
+    it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => {
+      (useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' }));
+      const wrapper = shallow(<AppSearch />);
+
+      expect(wrapper.find(Redirect)).toHaveLength(1);
+      expect(wrapper.find(EngineOverview)).toHaveLength(0);
+    });
+
+    it('renders Engine Overview when enterpriseSearchUrl is set', () => {
+      (useContext as jest.Mock).mockImplementationOnce(() => ({
+        enterpriseSearchUrl: 'https://foo.bar',
+      }));
+      const wrapper = shallow(<AppSearch />);
+
+      expect(wrapper.find(EngineOverview)).toHaveLength(1);
+      expect(wrapper.find(Redirect)).toHaveLength(0);
+    });
+  });
+
+  describe('/setup_guide', () => {
+    it('renders', () => {
+      const wrapper = shallow(<AppSearch />);
+
+      expect(wrapper.find(SetupGuide)).toHaveLength(1);
+    });
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx
new file mode 100644
index 0000000000000..8f7142f1631a9
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx
@@ -0,0 +1,28 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext } from 'react';
+import { Route, Redirect } from 'react-router-dom';
+
+import { KibanaContext, IKibanaContext } from '../index';
+
+import { SetupGuide } from './components/setup_guide';
+import { EngineOverview } from './components/engine_overview';
+
+export const AppSearch: React.FC = () => {
+  const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
+
+  return (
+    <>
+      <Route exact path="/">
+        {!enterpriseSearchUrl ? <Redirect to="/setup_guide" /> : <EngineOverview />}
+      </Route>
+      <Route path="/setup_guide">
+        <SetupGuide />
+      </Route>
+    </>
+  );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx
new file mode 100644
index 0000000000000..1aead8468ca3b
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx
@@ -0,0 +1,40 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+
+import { coreMock } from 'src/core/public/mocks';
+import { licensingMock } from '../../../licensing/public/mocks';
+
+import { renderApp } from './';
+import { AppSearch } from './app_search';
+
+describe('renderApp', () => {
+  const params = coreMock.createAppMountParamters();
+  const core = coreMock.createStart();
+  const config = {};
+  const plugins = {
+    licensing: licensingMock.createSetup(),
+  } as any;
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('mounts and unmounts UI', () => {
+    const MockApp = () => <div className="hello-world">Hello world!</div>;
+
+    const unmount = renderApp(MockApp, core, params, config, plugins);
+    expect(params.element.querySelector('.hello-world')).not.toBeNull();
+    unmount();
+    expect(params.element.innerHTML).toEqual('');
+  });
+
+  it('renders AppSearch', () => {
+    renderApp(AppSearch, core, params, config, plugins);
+    expect(params.element.querySelector('.setupGuide')).not.toBeNull();
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx
new file mode 100644
index 0000000000000..4ef7aca8260a2
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Router } from 'react-router-dom';
+
+import { I18nProvider } from '@kbn/i18n/react';
+import { CoreStart, AppMountParameters, HttpSetup, ChromeBreadcrumb } from 'src/core/public';
+import { ClientConfigType, PluginsSetup } from '../plugin';
+import { LicenseProvider } from './shared/licensing';
+
+export interface IKibanaContext {
+  enterpriseSearchUrl?: string;
+  http: HttpSetup;
+  setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void;
+}
+
+export const KibanaContext = React.createContext({});
+
+/**
+ * This file serves as a reusable wrapper to share Kibana-level context and other helpers
+ * between various Enterprise Search plugins (e.g. AppSearch, WorkplaceSearch, ES landing page)
+ * which should be imported and passed in as the first param in plugin.ts.
+ */
+
+export const renderApp = (
+  App: React.FC,
+  core: CoreStart,
+  params: AppMountParameters,
+  config: ClientConfigType,
+  plugins: PluginsSetup
+) => {
+  ReactDOM.render(
+    <I18nProvider>
+      <KibanaContext.Provider
+        value={{
+          http: core.http,
+          enterpriseSearchUrl: config.host,
+          setBreadcrumbs: core.chrome.setBreadcrumbs,
+        }}
+      >
+        <LicenseProvider license$={plugins.licensing.license$}>
+          <Router history={params.history}>
+            <App />
+          </Router>
+        </LicenseProvider>
+      </KibanaContext.Provider>
+    </I18nProvider>,
+    params.element
+  );
+  return () => ReactDOM.unmountComponentAtNode(params.element);
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts
new file mode 100644
index 0000000000000..42f308c554268
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getPublicUrl } from './';
+
+describe('Enterprise Search URL helper', () => {
+  const httpMock = { get: jest.fn() } as any;
+
+  it('calls and returns the public URL API endpoint', async () => {
+    httpMock.get.mockImplementationOnce(() => ({ publicUrl: 'http://some.vanity.url' }));
+
+    expect(await getPublicUrl(httpMock)).toEqual('http://some.vanity.url');
+  });
+
+  it('strips trailing slashes', async () => {
+    httpMock.get.mockImplementationOnce(() => ({ publicUrl: 'http://trailing.slash/' }));
+
+    expect(await getPublicUrl(httpMock)).toEqual('http://trailing.slash');
+  });
+
+  // For the most part, error logging/handling is done on the server side.
+  // On the front-end, we should simply gracefully fall back to config.host
+  // if we can't fetch a public URL
+  it('falls back to an empty string', async () => {
+    expect(await getPublicUrl(httpMock)).toEqual('');
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts
new file mode 100644
index 0000000000000..419c187a0048a
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { HttpSetup } from 'src/core/public';
+
+/**
+ * On Elastic Cloud, the host URL set in kibana.yml is not necessarily the same
+ * URL we want to send users to in the front-end (e.g. if a vanity URL is set).
+ *
+ * This helper checks a Kibana API endpoint (which has checks an Enterprise
+ * Search internal API endpoint) for the correct public-facing URL to use.
+ */
+export const getPublicUrl = async (http: HttpSetup): Promise<string> => {
+  try {
+    const { publicUrl } = await http.get('/api/enterprise_search/public_url');
+    return stripTrailingSlash(publicUrl);
+  } catch {
+    return '';
+  }
+};
+
+const stripTrailingSlash = (url: string): string => {
+  return url.endsWith('/') ? url.slice(0, -1) : url;
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts
new file mode 100644
index 0000000000000..bbbb688b8ea7b
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { getPublicUrl } from './get_enterprise_search_url';
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts
new file mode 100644
index 0000000000000..7ea73577c4de6
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts
@@ -0,0 +1,206 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { generateBreadcrumb } from './generate_breadcrumbs';
+import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './';
+
+import { mockHistory as mockHistoryUntyped } from '../../__mocks__';
+const mockHistory = mockHistoryUntyped as any;
+
+jest.mock('../react_router_helpers', () => ({ letBrowserHandleEvent: jest.fn(() => false) }));
+import { letBrowserHandleEvent } from '../react_router_helpers';
+
+describe('generateBreadcrumb', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it("creates a breadcrumb object matching EUI's breadcrumb type", () => {
+    const breadcrumb = generateBreadcrumb({
+      text: 'Hello World',
+      path: '/hello_world',
+      history: mockHistory,
+    });
+    expect(breadcrumb).toEqual({
+      text: 'Hello World',
+      href: '/enterprise_search/hello_world',
+      onClick: expect.any(Function),
+    });
+  });
+
+  it('prevents default navigation and uses React Router history on click', () => {
+    const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }) as any;
+    const event = { preventDefault: jest.fn() };
+    breadcrumb.onClick(event);
+
+    expect(mockHistory.push).toHaveBeenCalled();
+    expect(event.preventDefault).toHaveBeenCalled();
+  });
+
+  it('does not prevent default browser behavior on new tab/window clicks', () => {
+    const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }) as any;
+
+    (letBrowserHandleEvent as jest.Mock).mockImplementationOnce(() => true);
+    breadcrumb.onClick();
+
+    expect(mockHistory.push).not.toHaveBeenCalled();
+  });
+
+  it('does not generate link behavior if path is excluded', () => {
+    const breadcrumb = generateBreadcrumb({ text: 'Unclickable breadcrumb' });
+
+    expect(breadcrumb.href).toBeUndefined();
+    expect(breadcrumb.onClick).toBeUndefined();
+  });
+});
+
+describe('enterpriseSearchBreadcrumbs', () => {
+  const breadCrumbs = [
+    {
+      text: 'Page 1',
+      path: '/page1',
+    },
+    {
+      text: 'Page 2',
+      path: '/page2',
+    },
+  ];
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  const subject = () => enterpriseSearchBreadcrumbs(mockHistory)(breadCrumbs);
+
+  it('Builds a chain of breadcrumbs with Enterprise Search at the root', () => {
+    expect(subject()).toEqual([
+      {
+        text: 'Enterprise Search',
+      },
+      {
+        href: '/enterprise_search/page1',
+        onClick: expect.any(Function),
+        text: 'Page 1',
+      },
+      {
+        href: '/enterprise_search/page2',
+        onClick: expect.any(Function),
+        text: 'Page 2',
+      },
+    ]);
+  });
+
+  it('shows just the root if breadcrumbs is empty', () => {
+    expect(enterpriseSearchBreadcrumbs(mockHistory)()).toEqual([
+      {
+        text: 'Enterprise Search',
+      },
+    ]);
+  });
+
+  describe('links', () => {
+    const eventMock = {
+      preventDefault: jest.fn(),
+    } as any;
+
+    it('has Enterprise Search text first', () => {
+      expect(subject()[0].onClick).toBeUndefined();
+    });
+
+    it('has a link to page 1 second', () => {
+      (subject()[1] as any).onClick(eventMock);
+      expect(mockHistory.push).toHaveBeenCalledWith('/page1');
+    });
+
+    it('has a link to page 2 last', () => {
+      (subject()[2] as any).onClick(eventMock);
+      expect(mockHistory.push).toHaveBeenCalledWith('/page2');
+    });
+  });
+});
+
+describe('appSearchBreadcrumbs', () => {
+  const breadCrumbs = [
+    {
+      text: 'Page 1',
+      path: '/page1',
+    },
+    {
+      text: 'Page 2',
+      path: '/page2',
+    },
+  ];
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+    mockHistory.createHref.mockImplementation(
+      ({ pathname }: any) => `/enterprise_search/app_search${pathname}`
+    );
+  });
+
+  const subject = () => appSearchBreadcrumbs(mockHistory)(breadCrumbs);
+
+  it('Builds a chain of breadcrumbs with Enterprise Search and App Search at the root', () => {
+    expect(subject()).toEqual([
+      {
+        text: 'Enterprise Search',
+      },
+      {
+        href: '/enterprise_search/app_search/',
+        onClick: expect.any(Function),
+        text: 'App Search',
+      },
+      {
+        href: '/enterprise_search/app_search/page1',
+        onClick: expect.any(Function),
+        text: 'Page 1',
+      },
+      {
+        href: '/enterprise_search/app_search/page2',
+        onClick: expect.any(Function),
+        text: 'Page 2',
+      },
+    ]);
+  });
+
+  it('shows just the root if breadcrumbs is empty', () => {
+    expect(appSearchBreadcrumbs(mockHistory)()).toEqual([
+      {
+        text: 'Enterprise Search',
+      },
+      {
+        href: '/enterprise_search/app_search/',
+        onClick: expect.any(Function),
+        text: 'App Search',
+      },
+    ]);
+  });
+
+  describe('links', () => {
+    const eventMock = {
+      preventDefault: jest.fn(),
+    } as any;
+
+    it('has Enterprise Search text first', () => {
+      expect(subject()[0].onClick).toBeUndefined();
+    });
+
+    it('has a link to App Search second', () => {
+      (subject()[1] as any).onClick(eventMock);
+      expect(mockHistory.push).toHaveBeenCalledWith('/');
+    });
+
+    it('has a link to page 1 third', () => {
+      (subject()[2] as any).onClick(eventMock);
+      expect(mockHistory.push).toHaveBeenCalledWith('/page1');
+    });
+
+    it('has a link to page 2 last', () => {
+      (subject()[3] as any).onClick(eventMock);
+      expect(mockHistory.push).toHaveBeenCalledWith('/page2');
+    });
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts
new file mode 100644
index 0000000000000..0e1bb796cbf2e
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Breadcrumb as EuiBreadcrumb } from '@elastic/eui';
+import { History } from 'history';
+
+import { letBrowserHandleEvent } from '../react_router_helpers';
+
+/**
+ * Generate React-Router-friendly EUI breadcrumb objects
+ * https://elastic.github.io/eui/#/navigation/breadcrumbs
+ */
+
+interface IGenerateBreadcrumbProps {
+  text: string;
+  path?: string;
+  history?: History;
+}
+
+export const generateBreadcrumb = ({ text, path, history }: IGenerateBreadcrumbProps) => {
+  const breadcrumb = { text } as EuiBreadcrumb;
+
+  if (path && history) {
+    breadcrumb.href = history.createHref({ pathname: path });
+    breadcrumb.onClick = (event) => {
+      if (letBrowserHandleEvent(event)) return;
+      event.preventDefault();
+      history.push(path);
+    };
+  }
+
+  return breadcrumb;
+};
+
+/**
+ * Product-specific breadcrumb helpers
+ */
+
+export type TBreadcrumbs = IGenerateBreadcrumbProps[];
+
+export const enterpriseSearchBreadcrumbs = (history: History) => (
+  breadcrumbs: TBreadcrumbs = []
+) => [
+  generateBreadcrumb({ text: 'Enterprise Search' }),
+  ...breadcrumbs.map(({ text, path }: IGenerateBreadcrumbProps) =>
+    generateBreadcrumb({ text, path, history })
+  ),
+];
+
+export const appSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) =>
+  enterpriseSearchBreadcrumbs(history)([{ text: 'App Search', path: '/' }, ...breadcrumbs]);
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts
new file mode 100644
index 0000000000000..cf8bbbc593f2f
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { enterpriseSearchBreadcrumbs } from './generate_breadcrumbs';
+export { appSearchBreadcrumbs } from './generate_breadcrumbs';
+export { SetAppSearchBreadcrumbs } from './set_breadcrumbs';
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx
new file mode 100644
index 0000000000000..974ca54277c51
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx
@@ -0,0 +1,63 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+
+import '../../__mocks__/react_router_history.mock';
+import { mountWithKibanaContext } from '../../__mocks__';
+
+jest.mock('./generate_breadcrumbs', () => ({ appSearchBreadcrumbs: jest.fn() }));
+import { appSearchBreadcrumbs, SetAppSearchBreadcrumbs } from './';
+
+describe('SetAppSearchBreadcrumbs', () => {
+  const setBreadcrumbs = jest.fn();
+  const builtBreadcrumbs = [] as any;
+  const appSearchBreadCrumbsInnerCall = jest.fn().mockReturnValue(builtBreadcrumbs);
+  const appSearchBreadCrumbsOuterCall = jest.fn().mockReturnValue(appSearchBreadCrumbsInnerCall);
+  (appSearchBreadcrumbs as jest.Mock).mockImplementation(appSearchBreadCrumbsOuterCall);
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  const mountSetAppSearchBreadcrumbs = (props: any) => {
+    return mountWithKibanaContext(<SetAppSearchBreadcrumbs {...props} />, {
+      http: {},
+      enterpriseSearchUrl: 'http://localhost:3002',
+      setBreadcrumbs,
+    });
+  };
+
+  describe('when isRoot is false', () => {
+    const subject = () => mountSetAppSearchBreadcrumbs({ text: 'Page 1', isRoot: false });
+
+    it('calls appSearchBreadcrumbs to build breadcrumbs, then registers them with Kibana', () => {
+      subject();
+
+      // calls appSearchBreadcrumbs to build breadcrumbs with the target page and current location
+      expect(appSearchBreadCrumbsInnerCall).toHaveBeenCalledWith([
+        { text: 'Page 1', path: '/current-path' },
+      ]);
+
+      // then registers them with Kibana
+      expect(setBreadcrumbs).toHaveBeenCalledWith(builtBreadcrumbs);
+    });
+  });
+
+  describe('when isRoot is true', () => {
+    const subject = () => mountSetAppSearchBreadcrumbs({ text: 'Page 1', isRoot: true });
+
+    it('calls appSearchBreadcrumbs to build breadcrumbs with an empty breadcrumb, then registers them with Kibana', () => {
+      subject();
+
+      // uses an empty bredcrumb
+      expect(appSearchBreadCrumbsInnerCall).toHaveBeenCalledWith([]);
+
+      // then registers them with Kibana
+      expect(setBreadcrumbs).toHaveBeenCalledWith(builtBreadcrumbs);
+    });
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx
new file mode 100644
index 0000000000000..ad3cd65c09516
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Breadcrumb as EuiBreadcrumb } from '@elastic/eui';
+import { KibanaContext, IKibanaContext } from '../../index';
+import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs';
+
+/**
+ * Small on-mount helper for setting Kibana's chrome breadcrumbs on any App Search view
+ * @see https://github.com/elastic/kibana/blob/master/src/core/public/chrome/chrome_service.tsx
+ */
+
+export type TSetBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => void;
+
+interface IBreadcrumbProps {
+  text: string;
+  isRoot?: never;
+}
+interface IRootBreadcrumbProps {
+  isRoot: true;
+  text?: never;
+}
+
+export const SetAppSearchBreadcrumbs: React.FC<IBreadcrumbProps | IRootBreadcrumbProps> = ({
+  text,
+  isRoot,
+}) => {
+  const history = useHistory();
+  const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext;
+
+  const crumb = isRoot ? [] : [{ text, path: history.location.pathname }];
+
+  useEffect(() => {
+    setBreadcrumbs(appSearchBreadcrumbs(history)(crumb as TBreadcrumbs | []));
+  }, []);
+
+  return null;
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts
new file mode 100644
index 0000000000000..9c8c1417d48db
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { LicenseContext, LicenseProvider, ILicenseContext } from './license_context';
+export { hasPlatinumLicense } from './license_checks';
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts
new file mode 100644
index 0000000000000..ad134e7d36b10
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { hasPlatinumLicense } from './license_checks';
+
+describe('hasPlatinumLicense', () => {
+  it('is true for platinum licenses', () => {
+    expect(hasPlatinumLicense({ isActive: true, type: 'platinum' } as any)).toEqual(true);
+  });
+
+  it('is true for enterprise licenses', () => {
+    expect(hasPlatinumLicense({ isActive: true, type: 'enterprise' } as any)).toEqual(true);
+  });
+
+  it('is true for trial licenses', () => {
+    expect(hasPlatinumLicense({ isActive: true, type: 'platinum' } as any)).toEqual(true);
+  });
+
+  it('is false if the current license is expired', () => {
+    expect(hasPlatinumLicense({ isActive: false, type: 'platinum' } as any)).toEqual(false);
+    expect(hasPlatinumLicense({ isActive: false, type: 'enterprise' } as any)).toEqual(false);
+    expect(hasPlatinumLicense({ isActive: false, type: 'trial' } as any)).toEqual(false);
+  });
+
+  it('is false for licenses below platinum', () => {
+    expect(hasPlatinumLicense({ isActive: true, type: 'basic' } as any)).toEqual(false);
+    expect(hasPlatinumLicense({ isActive: false, type: 'standard' } as any)).toEqual(false);
+    expect(hasPlatinumLicense({ isActive: true, type: 'gold' } as any)).toEqual(false);
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts
new file mode 100644
index 0000000000000..de4a17ce2bd3c
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ILicense } from '../../../../../licensing/public';
+
+export const hasPlatinumLicense = (license?: ILicense) => {
+  return license?.isActive && ['platinum', 'enterprise', 'trial'].includes(license?.type as string);
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx
new file mode 100644
index 0000000000000..c65474ec1f590
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext } from 'react';
+
+import { mountWithContext } from '../../__mocks__';
+import { LicenseContext, ILicenseContext } from './';
+
+describe('LicenseProvider', () => {
+  const MockComponent: React.FC = () => {
+    const { license } = useContext(LicenseContext) as ILicenseContext;
+    return <div className="license-test">{license?.type}</div>;
+  };
+
+  it('renders children', () => {
+    const wrapper = mountWithContext(<MockComponent />, { license: { type: 'basic' } });
+
+    expect(wrapper.find('.license-test')).toHaveLength(1);
+    expect(wrapper.text()).toEqual('basic');
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx
new file mode 100644
index 0000000000000..9b47959ff7544
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx
@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import useObservable from 'react-use/lib/useObservable';
+import { Observable } from 'rxjs';
+
+import { ILicense } from '../../../../../licensing/public';
+
+export interface ILicenseContext {
+  license: ILicense;
+}
+interface ILicenseContextProps {
+  license$: Observable<ILicense>;
+  children: React.ReactNode;
+}
+
+export const LicenseContext = React.createContext({});
+
+export const LicenseProvider: React.FC<ILicenseContextProps> = ({ license$, children }) => {
+  // Listen for changes to license subscription
+  const license = useObservable(license$);
+
+  // Render rest of application and pass down license via context
+  return <LicenseContext.Provider value={{ license }} children={children} />;
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx
new file mode 100644
index 0000000000000..7d4c068b21155
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx
@@ -0,0 +1,77 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { shallow, mount } from 'enzyme';
+import { EuiLink, EuiButton } from '@elastic/eui';
+
+import '../../__mocks__/react_router_history.mock';
+import { mockHistory } from '../../__mocks__';
+
+import { EuiReactRouterLink, EuiReactRouterButton } from './eui_link';
+
+describe('EUI & React Router Component Helpers', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('renders', () => {
+    const wrapper = shallow(<EuiReactRouterLink to="/" />);
+
+    expect(wrapper.find(EuiLink)).toHaveLength(1);
+  });
+
+  it('renders an EuiButton', () => {
+    const wrapper = shallow(<EuiReactRouterButton to="/" />);
+
+    expect(wrapper.find(EuiButton)).toHaveLength(1);
+  });
+
+  it('passes down all ...rest props', () => {
+    const wrapper = shallow(<EuiReactRouterLink to="/" data-test-subj="foo" external={true} />);
+    const link = wrapper.find(EuiLink);
+
+    expect(link.prop('external')).toEqual(true);
+    expect(link.prop('data-test-subj')).toEqual('foo');
+  });
+
+  it('renders with the correct href and onClick props', () => {
+    const wrapper = mount(<EuiReactRouterLink to="/foo/bar" />);
+    const link = wrapper.find(EuiLink);
+
+    expect(link.prop('onClick')).toBeInstanceOf(Function);
+    expect(link.prop('href')).toEqual('/enterprise_search/foo/bar');
+    expect(mockHistory.createHref).toHaveBeenCalled();
+  });
+
+  describe('onClick', () => {
+    it('prevents default navigation and uses React Router history', () => {
+      const wrapper = mount(<EuiReactRouterLink to="/bar/baz" />);
+
+      const simulatedEvent = {
+        button: 0,
+        target: { getAttribute: () => '_self' },
+        preventDefault: jest.fn(),
+      };
+      wrapper.find(EuiLink).simulate('click', simulatedEvent);
+
+      expect(simulatedEvent.preventDefault).toHaveBeenCalled();
+      expect(mockHistory.push).toHaveBeenCalled();
+    });
+
+    it('does not prevent default browser behavior on new tab/window clicks', () => {
+      const wrapper = mount(<EuiReactRouterLink to="/bar/baz" />);
+
+      const simulatedEvent = {
+        shiftKey: true,
+        target: { getAttribute: () => '_blank' },
+      };
+      wrapper.find(EuiLink).simulate('click', simulatedEvent);
+
+      expect(mockHistory.push).not.toHaveBeenCalled();
+    });
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx
new file mode 100644
index 0000000000000..f486e432bae76
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx
@@ -0,0 +1,57 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { useHistory } from 'react-router-dom';
+import { EuiLink, EuiButton, EuiButtonProps, EuiLinkAnchorProps } from '@elastic/eui';
+
+import { letBrowserHandleEvent } from './link_events';
+
+/**
+ * Generates either an EuiLink or EuiButton with a React-Router-ified link
+ *
+ * Based off of EUI's recommendations for handling React Router:
+ * https://github.com/elastic/eui/blob/master/wiki/react-router.md#react-router-51
+ */
+
+interface IEuiReactRouterProps {
+  to: string;
+}
+
+export const EuiReactRouterHelper: React.FC<IEuiReactRouterProps> = ({ to, children }) => {
+  const history = useHistory();
+
+  const onClick = (event: React.MouseEvent) => {
+    if (letBrowserHandleEvent(event)) return;
+
+    // Prevent regular link behavior, which causes a browser refresh.
+    event.preventDefault();
+
+    // Push the route to the history.
+    history.push(to);
+  };
+
+  // Generate the correct link href (with basename etc. accounted for)
+  const href = history.createHref({ pathname: to });
+
+  const reactRouterProps = { href, onClick };
+  return React.cloneElement(children as React.ReactElement, reactRouterProps);
+};
+
+type TEuiReactRouterLinkProps = EuiLinkAnchorProps & IEuiReactRouterProps;
+type TEuiReactRouterButtonProps = EuiButtonProps & IEuiReactRouterProps;
+
+export const EuiReactRouterLink: React.FC<TEuiReactRouterLinkProps> = ({ to, ...rest }) => (
+  <EuiReactRouterHelper to={to}>
+    <EuiLink {...rest} />
+  </EuiReactRouterHelper>
+);
+
+export const EuiReactRouterButton: React.FC<TEuiReactRouterButtonProps> = ({ to, ...rest }) => (
+  <EuiReactRouterHelper to={to}>
+    <EuiButton {...rest} />
+  </EuiReactRouterHelper>
+);
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts
new file mode 100644
index 0000000000000..46dc328633153
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { letBrowserHandleEvent } from './link_events';
+export { EuiReactRouterLink as EuiLink } from './eui_link';
+export { EuiReactRouterButton as EuiButton } from './eui_link';
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts
new file mode 100644
index 0000000000000..3682946b63a13
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts
@@ -0,0 +1,102 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { letBrowserHandleEvent } from '../react_router_helpers';
+
+describe('letBrowserHandleEvent', () => {
+  const event = {
+    defaultPrevented: false,
+    metaKey: false,
+    altKey: false,
+    ctrlKey: false,
+    shiftKey: false,
+    button: 0,
+    target: {
+      getAttribute: () => '_self',
+    },
+  } as any;
+
+  describe('the browser should handle the link when', () => {
+    it('default is prevented', () => {
+      expect(letBrowserHandleEvent({ ...event, defaultPrevented: true })).toBe(true);
+    });
+
+    it('is modified with metaKey', () => {
+      expect(letBrowserHandleEvent({ ...event, metaKey: true })).toBe(true);
+    });
+
+    it('is modified with altKey', () => {
+      expect(letBrowserHandleEvent({ ...event, altKey: true })).toBe(true);
+    });
+
+    it('is modified with ctrlKey', () => {
+      expect(letBrowserHandleEvent({ ...event, ctrlKey: true })).toBe(true);
+    });
+
+    it('is modified with shiftKey', () => {
+      expect(letBrowserHandleEvent({ ...event, shiftKey: true })).toBe(true);
+    });
+
+    it('it is not a left click event', () => {
+      expect(letBrowserHandleEvent({ ...event, button: 2 })).toBe(true);
+    });
+
+    it('the target is anything value other than _self', () => {
+      expect(
+        letBrowserHandleEvent({
+          ...event,
+          target: targetValue('_blank'),
+        })
+      ).toBe(true);
+    });
+  });
+
+  describe('the browser should NOT handle the link when', () => {
+    it('default is not prevented', () => {
+      expect(letBrowserHandleEvent({ ...event, defaultPrevented: false })).toBe(false);
+    });
+
+    it('is not modified', () => {
+      expect(
+        letBrowserHandleEvent({
+          ...event,
+          metaKey: false,
+          altKey: false,
+          ctrlKey: false,
+          shiftKey: false,
+        })
+      ).toBe(false);
+    });
+
+    it('it is a left click event', () => {
+      expect(letBrowserHandleEvent({ ...event, button: 0 })).toBe(false);
+    });
+
+    it('the target is a value of _self', () => {
+      expect(
+        letBrowserHandleEvent({
+          ...event,
+          target: targetValue('_self'),
+        })
+      ).toBe(false);
+    });
+
+    it('the target has no value', () => {
+      expect(
+        letBrowserHandleEvent({
+          ...event,
+          target: targetValue(null),
+        })
+      ).toBe(false);
+    });
+  });
+});
+
+const targetValue = (value: string | null) => {
+  return {
+    getAttribute: () => value,
+  };
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts
new file mode 100644
index 0000000000000..93da2ab71d952
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { MouseEvent } from 'react';
+
+/**
+ * Helper functions for determining which events we should
+ * let browsers handle natively, e.g. new tabs/windows
+ */
+
+type THandleEvent = (event: MouseEvent) => boolean;
+
+export const letBrowserHandleEvent: THandleEvent = (event) =>
+  event.defaultPrevented ||
+  isModifiedEvent(event) ||
+  !isLeftClickEvent(event) ||
+  isTargetBlank(event);
+
+const isModifiedEvent: THandleEvent = (event) =>
+  !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
+
+const isLeftClickEvent: THandleEvent = (event) => event.button === 0;
+
+const isTargetBlank: THandleEvent = (event) => {
+  const element = event.target as HTMLElement;
+  const target = element.getAttribute('target');
+  return !!target && target !== '_self';
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts
new file mode 100644
index 0000000000000..c367424d375f9
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { SetupGuide } from './setup_guide';
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss
new file mode 100644
index 0000000000000..ecfa13cc828f0
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss
@@ -0,0 +1,51 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/**
+ * Setup Guide
+ */
+.setupGuide {
+  padding: 0;
+  min-height: 100vh;
+
+  &__sidebar {
+    flex-basis: $euiSizeXXL * 7.5;
+    flex-shrink: 0;
+    padding: $euiSizeL;
+    margin-right: 0;
+
+    background-color: $euiColorLightestShade;
+    border-color: $euiBorderColor;
+    border-style: solid;
+    border-width: 0 0 $euiBorderWidthThin 0; // bottom - mobile view
+
+    @include euiBreakpoint('m', 'l', 'xl') {
+      border-width: 0 $euiBorderWidthThin 0 0; // right - desktop view
+    }
+    @include euiBreakpoint('m', 'l') {
+      flex-basis: $euiSizeXXL * 10;
+    }
+    @include euiBreakpoint('xl') {
+      flex-basis: $euiSizeXXL * 12.5;
+    }
+  }
+
+  &__body {
+    align-self: start;
+    padding: $euiSizeL;
+
+    @include euiBreakpoint('l') {
+      padding: $euiSizeXXL ($euiSizeXXL * 1.25);
+    }
+  }
+
+  &__thumbnail {
+    display: block;
+    max-width: 100%;
+    height: auto;
+    margin: $euiSizeL auto;
+  }
+}
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx
new file mode 100644
index 0000000000000..0423ae61779af
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { EuiSteps, EuiIcon, EuiLink } from '@elastic/eui';
+
+import { mountWithContext } from '../../__mocks__';
+
+import { SetupGuide } from './';
+
+describe('SetupGuide', () => {
+  it('renders', () => {
+    const wrapper = shallow(
+      <SetupGuide productName="Enterprise Search" productEuiIcon="logoEnterpriseSearch">
+        <p data-test-subj="test">Wow!</p>
+      </SetupGuide>
+    );
+
+    expect(wrapper.find('h1').text()).toEqual('Enterprise Search');
+    expect(wrapper.find(EuiIcon).prop('type')).toEqual('logoEnterpriseSearch');
+    expect(wrapper.find('[data-test-subj="test"]').text()).toEqual('Wow!');
+    expect(wrapper.find(EuiSteps)).toHaveLength(1);
+  });
+
+  it('renders with optional auth links', () => {
+    const wrapper = mountWithContext(
+      <SetupGuide
+        productName="Foo"
+        productEuiIcon="logoAppSearch"
+        standardAuthLink="http://foo.com"
+        elasticsearchNativeAuthLink="http://bar.com"
+      >
+        Baz
+      </SetupGuide>
+    );
+
+    expect(wrapper.find(EuiLink).first().prop('href')).toEqual('http://bar.com');
+    expect(wrapper.find(EuiLink).last().prop('href')).toEqual('http://foo.com');
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx
new file mode 100644
index 0000000000000..31ff0089dbd7c
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx
@@ -0,0 +1,226 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import {
+  EuiPage,
+  EuiPageSideBar,
+  EuiPageBody,
+  EuiPageContent,
+  EuiSpacer,
+  EuiFlexGroup,
+  EuiFlexItem,
+  EuiTitle,
+  EuiText,
+  EuiIcon,
+  EuiSteps,
+  EuiCode,
+  EuiCodeBlock,
+  EuiAccordion,
+  EuiLink,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
+
+import './setup_guide.scss';
+
+/**
+ * Shared Setup Guide component. Sidebar content and product name/links are
+ * customizable, but the basic layout and instruction steps are DRYed out
+ */
+
+interface ISetupGuideProps {
+  children: React.ReactNode;
+  productName: string;
+  productEuiIcon: 'logoAppSearch' | 'logoWorkplaceSearch' | 'logoEnterpriseSearch';
+  standardAuthLink?: string;
+  elasticsearchNativeAuthLink?: string;
+}
+
+export const SetupGuide: React.FC<ISetupGuideProps> = ({
+  children,
+  productName,
+  productEuiIcon,
+  standardAuthLink,
+  elasticsearchNativeAuthLink,
+}) => (
+  <EuiPage className="setupGuide">
+    <EuiPageSideBar className="setupGuide__sidebar">
+      <EuiText color="subdued" size="s">
+        <strong>
+          <FormattedMessage
+            id="xpack.enterpriseSearch.setupGuide.title"
+            defaultMessage="Setup Guide"
+          />
+        </strong>
+      </EuiText>
+      <EuiSpacer size="s" />
+
+      <EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
+        <EuiFlexItem grow={false}>
+          <EuiIcon type={productEuiIcon} size="l" />
+        </EuiFlexItem>
+        <EuiFlexItem>
+          <EuiTitle size="m">
+            <h1>{productName}</h1>
+          </EuiTitle>
+        </EuiFlexItem>
+      </EuiFlexGroup>
+
+      {children}
+    </EuiPageSideBar>
+
+    <EuiPageBody className="setupGuide__body">
+      <EuiPageContent>
+        <EuiSteps
+          headingElement="h2"
+          steps={[
+            {
+              title: i18n.translate('xpack.enterpriseSearch.setupGuide.step1.title', {
+                defaultMessage: 'Add your {productName} host URL to your Kibana configuration',
+                values: { productName },
+              }),
+              children: (
+                <EuiText>
+                  <p>
+                    <FormattedMessage
+                      id="xpack.enterpriseSearch.setupGuide.step1.instruction1"
+                      defaultMessage="In your {configFile} file, set {configSetting} to the URL of your {productName} instance. For example:"
+                      values={{
+                        productName,
+                        configFile: <EuiCode>config/kibana.yml</EuiCode>,
+                        configSetting: <EuiCode>enterpriseSearch.host</EuiCode>,
+                      }}
+                    />
+                  </p>
+                  <EuiCodeBlock language="yml">
+                    enterpriseSearch.host: &apos;http://localhost:3002&apos;
+                  </EuiCodeBlock>
+                </EuiText>
+              ),
+            },
+            {
+              title: i18n.translate('xpack.enterpriseSearch.setupGuide.step2.title', {
+                defaultMessage: 'Reload your Kibana instance',
+              }),
+              children: (
+                <EuiText>
+                  <p>
+                    <FormattedMessage
+                      id="xpack.enterpriseSearch.setupGuide.step2.instruction1"
+                      defaultMessage="Restart Kibana to pick up the configuration changes from the previous step."
+                    />
+                  </p>
+                  <p>
+                    <FormattedMessage
+                      id="xpack.enterpriseSearch.setupGuide.step2.instruction2"
+                      defaultMessage="If you’re using {elasticsearchNativeAuthLink} in {productName}, you’re all set. Your users can now access {productName} in Kibana with their current {productName} access and permissions."
+                      values={{
+                        productName,
+                        elasticsearchNativeAuthLink: elasticsearchNativeAuthLink ? (
+                          <EuiLink href={elasticsearchNativeAuthLink} target="_blank">
+                            Elasticsearch Native Auth
+                          </EuiLink>
+                        ) : (
+                          'Elasticsearch Native Auth'
+                        ),
+                      }}
+                    />
+                  </p>
+                </EuiText>
+              ),
+            },
+            {
+              title: i18n.translate('xpack.enterpriseSearch.setupGuide.step3.title', {
+                defaultMessage: 'Troubleshooting issues',
+              }),
+              children: (
+                <>
+                  <EuiAccordion
+                    buttonContent={i18n.translate(
+                      'xpack.enterpriseSearch.troubleshooting.differentEsClusters.title',
+                      {
+                        defaultMessage:
+                          '{productName} and Kibana are on different Elasticsearch clusters',
+                        values: { productName },
+                      }
+                    )}
+                    id="differentEsClusters"
+                    paddingSize="s"
+                  >
+                    <EuiText>
+                      <p>
+                        <FormattedMessage
+                          id="xpack.enterpriseSearch.troubleshooting.differentEsClusters.description"
+                          defaultMessage="This plugin does not currently support {productName} and Kibana running on different clusters."
+                          values={{ productName }}
+                        />
+                      </p>
+                    </EuiText>
+                  </EuiAccordion>
+                  <EuiSpacer />
+                  <EuiAccordion
+                    buttonContent={i18n.translate(
+                      'xpack.enterpriseSearch.troubleshooting.differentAuth.title',
+                      {
+                        defaultMessage:
+                          '{productName} and Kibana are on different authentication methods',
+                        values: { productName },
+                      }
+                    )}
+                    id="differentAuth"
+                    paddingSize="s"
+                  >
+                    <EuiText>
+                      <p>
+                        <FormattedMessage
+                          id="xpack.enterpriseSearch.troubleshooting.differentAuth.description"
+                          defaultMessage="This plugin does not currently support {productName} and Kibana operating on different authentication methods, for example, {productName} using a different SAML provider than Kibana."
+                          values={{ productName }}
+                        />
+                      </p>
+                    </EuiText>
+                  </EuiAccordion>
+                  <EuiSpacer />
+                  <EuiAccordion
+                    buttonContent={i18n.translate(
+                      'xpack.enterpriseSearch.troubleshooting.standardAuth.title',
+                      {
+                        defaultMessage: '{productName} on Standard authentication is not supported',
+                        values: { productName },
+                      }
+                    )}
+                    id="standardAuth"
+                    paddingSize="s"
+                  >
+                    <EuiText>
+                      <p>
+                        <FormattedMessage
+                          id="xpack.enterpriseSearch.troubleshooting.standardAuth.description"
+                          defaultMessage="This plugin does not fully support {productName} on {standardAuthLink}. Users created in {productName} must have Kibana access. Users created in Kibana will not see {productName} in the navigation menu."
+                          values={{
+                            productName,
+                            standardAuthLink: standardAuthLink ? (
+                              <EuiLink href={standardAuthLink} target="_blank">
+                                Standard Auth
+                              </EuiLink>
+                            ) : (
+                              'Standard Auth'
+                            ),
+                          }}
+                        />
+                      </p>
+                    </EuiText>
+                  </EuiAccordion>
+                </>
+              ),
+            },
+          ]}
+        />
+      </EuiPageContent>
+    </EuiPageBody>
+  </EuiPage>
+);
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts
new file mode 100644
index 0000000000000..f871f48b17154
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { sendTelemetry } from './send_telemetry';
+export { SendAppSearchTelemetry } from './send_telemetry';
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx
new file mode 100644
index 0000000000000..9825c0d8ab889
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+
+import { httpServiceMock } from 'src/core/public/mocks';
+import { mountWithKibanaContext } from '../../__mocks__';
+import { sendTelemetry, SendAppSearchTelemetry } from './';
+
+describe('Shared Telemetry Helpers', () => {
+  const httpMock = httpServiceMock.createSetupContract();
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  describe('sendTelemetry', () => {
+    it('successfully calls the server-side telemetry endpoint', () => {
+      sendTelemetry({
+        http: httpMock,
+        product: 'enterprise_search',
+        action: 'viewed',
+        metric: 'setup_guide',
+      });
+
+      expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', {
+        headers: { 'Content-Type': 'application/json' },
+        body: '{"action":"viewed","metric":"setup_guide"}',
+      });
+    });
+
+    it('throws an error if the telemetry endpoint fails', () => {
+      const httpRejectMock = sendTelemetry({
+        http: { put: () => Promise.reject() },
+      } as any);
+
+      expect(httpRejectMock).rejects.toThrow('Unable to send telemetry');
+    });
+  });
+
+  describe('React component helpers', () => {
+    it('SendAppSearchTelemetry component', () => {
+      mountWithKibanaContext(<SendAppSearchTelemetry action="clicked" metric="button" />, {
+        http: httpMock,
+      });
+
+      expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', {
+        headers: { 'Content-Type': 'application/json' },
+        body: '{"action":"clicked","metric":"button"}',
+      });
+    });
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx
new file mode 100644
index 0000000000000..300cb18272717
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx
@@ -0,0 +1,50 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext, useEffect } from 'react';
+
+import { HttpSetup } from 'src/core/public';
+import { KibanaContext, IKibanaContext } from '../../index';
+
+interface ISendTelemetryProps {
+  action: 'viewed' | 'error' | 'clicked';
+  metric: string; // e.g., 'setup_guide'
+}
+
+interface ISendTelemetry extends ISendTelemetryProps {
+  http: HttpSetup;
+  product: 'app_search' | 'workplace_search' | 'enterprise_search';
+}
+
+/**
+ * Base function - useful for non-component actions, e.g. clicks
+ */
+
+export const sendTelemetry = async ({ http, product, action, metric }: ISendTelemetry) => {
+  try {
+    await http.put(`/api/${product}/telemetry`, {
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ action, metric }),
+    });
+  } catch (error) {
+    throw new Error('Unable to send telemetry');
+  }
+};
+
+/**
+ * React component helpers - useful for on-page-load/views
+ * TODO: SendWorkplaceSearchTelemetry and SendEnterpriseSearchTelemetry
+ */
+
+export const SendAppSearchTelemetry: React.FC<ISendTelemetryProps> = ({ action, metric }) => {
+  const { http } = useContext(KibanaContext) as IKibanaContext;
+
+  useEffect(() => {
+    sendTelemetry({ http, action, metric, product: 'app_search' });
+  }, [action, metric, http]);
+
+  return null;
+};
diff --git a/x-pack/plugins/enterprise_search/public/index.ts b/x-pack/plugins/enterprise_search/public/index.ts
new file mode 100644
index 0000000000000..06272641b1929
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/index.ts
@@ -0,0 +1,12 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { PluginInitializerContext } from 'src/core/public';
+import { EnterpriseSearchPlugin } from './plugin';
+
+export const plugin = (initializerContext: PluginInitializerContext) => {
+  return new EnterpriseSearchPlugin(initializerContext);
+};
diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts
new file mode 100644
index 0000000000000..fbfcc303de47a
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/plugin.ts
@@ -0,0 +1,88 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+  Plugin,
+  PluginInitializerContext,
+  CoreSetup,
+  CoreStart,
+  AppMountParameters,
+  HttpSetup,
+} from 'src/core/public';
+
+import {
+  FeatureCatalogueCategory,
+  HomePublicPluginSetup,
+} from '../../../../src/plugins/home/public';
+import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public';
+import { LicensingPluginSetup } from '../../licensing/public';
+
+import { getPublicUrl } from './applications/shared/enterprise_search_url';
+import AppSearchLogo from './applications/app_search/assets/logo.svg';
+
+export interface ClientConfigType {
+  host?: string;
+}
+export interface PluginsSetup {
+  home: HomePublicPluginSetup;
+  licensing: LicensingPluginSetup;
+}
+
+export class EnterpriseSearchPlugin implements Plugin {
+  private config: ClientConfigType;
+  private hasCheckedPublicUrl: boolean = false;
+
+  constructor(initializerContext: PluginInitializerContext) {
+    this.config = initializerContext.config.get<ClientConfigType>();
+  }
+
+  public setup(core: CoreSetup, plugins: PluginsSetup) {
+    const config = { host: this.config.host };
+
+    core.application.register({
+      id: 'appSearch',
+      title: 'App Search',
+      appRoute: '/app/enterprise_search/app_search',
+      category: DEFAULT_APP_CATEGORIES.enterpriseSearch,
+      mount: async (params: AppMountParameters) => {
+        const [coreStart] = await core.getStartServices();
+
+        await this.setPublicUrl(config, coreStart.http);
+
+        const { renderApp } = await import('./applications');
+        const { AppSearch } = await import('./applications/app_search');
+
+        return renderApp(AppSearch, coreStart, params, config, plugins);
+      },
+    });
+    // TODO: Workplace Search will need to register its own plugin.
+
+    plugins.home.featureCatalogue.register({
+      id: 'appSearch',
+      title: 'App Search',
+      icon: AppSearchLogo,
+      description:
+        'Leverage dashboards, analytics, and APIs for advanced application search made simple.',
+      path: '/app/enterprise_search/app_search',
+      category: FeatureCatalogueCategory.DATA,
+      showOnHomePage: true,
+    });
+    // TODO: Workplace Search will need to register its own feature catalogue section/card.
+  }
+
+  public start(core: CoreStart) {}
+
+  public stop() {}
+
+  private async setPublicUrl(config: ClientConfigType, http: HttpSetup) {
+    if (!config.host) return; // No API to check
+    if (this.hasCheckedPublicUrl) return; // We've already performed the check
+
+    const publicUrl = await getPublicUrl(http);
+    if (publicUrl) config.host = publicUrl;
+    this.hasCheckedPublicUrl = true;
+  }
+}
diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts
new file mode 100644
index 0000000000000..e95056b871324
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts
@@ -0,0 +1,143 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { loggingSystemMock } from 'src/core/server/mocks';
+
+jest.mock('../../../../../../src/core/server', () => ({
+  SavedObjectsErrorHelpers: {
+    isNotFoundError: jest.fn(),
+  },
+}));
+import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
+
+import { registerTelemetryUsageCollector, incrementUICounter } from './telemetry';
+
+describe('App Search Telemetry Usage Collector', () => {
+  const mockLogger = loggingSystemMock.create().get();
+
+  const makeUsageCollectorStub = jest.fn();
+  const registerStub = jest.fn();
+  const usageCollectionMock = {
+    makeUsageCollector: makeUsageCollectorStub,
+    registerCollector: registerStub,
+  } as any;
+
+  const savedObjectsRepoStub = {
+    get: () => ({
+      attributes: {
+        'ui_viewed.setup_guide': 10,
+        'ui_viewed.engines_overview': 20,
+        'ui_error.cannot_connect': 3,
+        'ui_clicked.create_first_engine_button': 40,
+        'ui_clicked.header_launch_button': 50,
+        'ui_clicked.engine_table_link': 60,
+      },
+    }),
+    incrementCounter: jest.fn(),
+  };
+  const savedObjectsMock = {
+    createInternalRepository: jest.fn(() => savedObjectsRepoStub),
+  } as any;
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  describe('registerTelemetryUsageCollector', () => {
+    it('should make and register the usage collector', () => {
+      registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger);
+
+      expect(registerStub).toHaveBeenCalledTimes(1);
+      expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1);
+      expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('app_search');
+      expect(makeUsageCollectorStub.mock.calls[0][0].isReady()).toBe(true);
+    });
+  });
+
+  describe('fetchTelemetryMetrics', () => {
+    it('should return existing saved objects data', async () => {
+      registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger);
+      const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch();
+
+      expect(savedObjectsCounts).toEqual({
+        ui_viewed: {
+          setup_guide: 10,
+          engines_overview: 20,
+        },
+        ui_error: {
+          cannot_connect: 3,
+        },
+        ui_clicked: {
+          create_first_engine_button: 40,
+          header_launch_button: 50,
+          engine_table_link: 60,
+        },
+      });
+    });
+
+    it('should return a default telemetry object if no saved data exists', async () => {
+      const emptySavedObjectsMock = {
+        createInternalRepository: () => ({
+          get: () => ({ attributes: null }),
+        }),
+      } as any;
+
+      registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock, mockLogger);
+      const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch();
+
+      expect(savedObjectsCounts).toEqual({
+        ui_viewed: {
+          setup_guide: 0,
+          engines_overview: 0,
+        },
+        ui_error: {
+          cannot_connect: 0,
+        },
+        ui_clicked: {
+          create_first_engine_button: 0,
+          header_launch_button: 0,
+          engine_table_link: 0,
+        },
+      });
+    });
+
+    it('should not throw but log a warning if saved objects errors', async () => {
+      const errorSavedObjectsMock = { createInternalRepository: () => ({}) } as any;
+      registerTelemetryUsageCollector(usageCollectionMock, errorSavedObjectsMock, mockLogger);
+
+      // Without log warning (not found)
+      (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true);
+      await makeUsageCollectorStub.mock.calls[0][0].fetch();
+
+      expect(mockLogger.warn).not.toHaveBeenCalled();
+
+      // With log warning
+      (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false);
+      await makeUsageCollectorStub.mock.calls[0][0].fetch();
+
+      expect(mockLogger.warn).toHaveBeenCalledWith(
+        'Failed to retrieve App Search telemetry data: TypeError: savedObjectsRepository.get is not a function'
+      );
+    });
+  });
+
+  describe('incrementUICounter', () => {
+    it('should increment the saved objects internal repository', async () => {
+      const response = await incrementUICounter({
+        savedObjects: savedObjectsMock,
+        uiAction: 'ui_clicked',
+        metric: 'button',
+      });
+
+      expect(savedObjectsRepoStub.incrementCounter).toHaveBeenCalledWith(
+        'app_search_telemetry',
+        'app_search_telemetry',
+        'ui_clicked.button'
+      );
+      expect(response).toEqual({ success: true });
+    });
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts
new file mode 100644
index 0000000000000..a10f96907ad28
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts
@@ -0,0 +1,156 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { get } from 'lodash';
+import {
+  ISavedObjectsRepository,
+  SavedObjectsServiceStart,
+  SavedObjectAttributes,
+  Logger,
+} from 'src/core/server';
+import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
+
+// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯
+import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
+
+interface ITelemetry {
+  ui_viewed: {
+    setup_guide: number;
+    engines_overview: number;
+  };
+  ui_error: {
+    cannot_connect: number;
+  };
+  ui_clicked: {
+    create_first_engine_button: number;
+    header_launch_button: number;
+    engine_table_link: number;
+  };
+}
+
+export const AS_TELEMETRY_NAME = 'app_search_telemetry';
+
+/**
+ * Register the telemetry collector
+ */
+
+export const registerTelemetryUsageCollector = (
+  usageCollection: UsageCollectionSetup,
+  savedObjects: SavedObjectsServiceStart,
+  log: Logger
+) => {
+  const telemetryUsageCollector = usageCollection.makeUsageCollector<ITelemetry>({
+    type: 'app_search',
+    fetch: async () => fetchTelemetryMetrics(savedObjects, log),
+    isReady: () => true,
+    schema: {
+      ui_viewed: {
+        setup_guide: { type: 'long' },
+        engines_overview: { type: 'long' },
+      },
+      ui_error: {
+        cannot_connect: { type: 'long' },
+      },
+      ui_clicked: {
+        create_first_engine_button: { type: 'long' },
+        header_launch_button: { type: 'long' },
+        engine_table_link: { type: 'long' },
+      },
+    },
+  });
+  usageCollection.registerCollector(telemetryUsageCollector);
+};
+
+/**
+ * Fetch the aggregated telemetry metrics from our saved objects
+ */
+
+const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => {
+  const savedObjectsRepository = savedObjects.createInternalRepository();
+  const savedObjectAttributes = (await getSavedObjectAttributesFromRepo(
+    savedObjectsRepository,
+    log
+  )) as SavedObjectAttributes;
+
+  const defaultTelemetrySavedObject: ITelemetry = {
+    ui_viewed: {
+      setup_guide: 0,
+      engines_overview: 0,
+    },
+    ui_error: {
+      cannot_connect: 0,
+    },
+    ui_clicked: {
+      create_first_engine_button: 0,
+      header_launch_button: 0,
+      engine_table_link: 0,
+    },
+  };
+
+  // If we don't have an existing/saved telemetry object, return the default
+  if (!savedObjectAttributes) {
+    return defaultTelemetrySavedObject;
+  }
+
+  return {
+    ui_viewed: {
+      setup_guide: get(savedObjectAttributes, 'ui_viewed.setup_guide', 0),
+      engines_overview: get(savedObjectAttributes, 'ui_viewed.engines_overview', 0),
+    },
+    ui_error: {
+      cannot_connect: get(savedObjectAttributes, 'ui_error.cannot_connect', 0),
+    },
+    ui_clicked: {
+      create_first_engine_button: get(
+        savedObjectAttributes,
+        'ui_clicked.create_first_engine_button',
+        0
+      ),
+      header_launch_button: get(savedObjectAttributes, 'ui_clicked.header_launch_button', 0),
+      engine_table_link: get(savedObjectAttributes, 'ui_clicked.engine_table_link', 0),
+    },
+  } as ITelemetry;
+};
+
+/**
+ * Helper function - fetches saved objects attributes
+ */
+
+const getSavedObjectAttributesFromRepo = async (
+  savedObjectsRepository: ISavedObjectsRepository,
+  log: Logger
+) => {
+  try {
+    return (await savedObjectsRepository.get(AS_TELEMETRY_NAME, AS_TELEMETRY_NAME)).attributes;
+  } catch (e) {
+    if (!SavedObjectsErrorHelpers.isNotFoundError(e)) {
+      log.warn(`Failed to retrieve App Search telemetry data: ${e}`);
+    }
+    return null;
+  }
+};
+
+/**
+ * Set saved objection attributes - used by telemetry route
+ */
+
+interface IIncrementUICounter {
+  savedObjects: SavedObjectsServiceStart;
+  uiAction: string;
+  metric: string;
+}
+
+export async function incrementUICounter({ savedObjects, uiAction, metric }: IIncrementUICounter) {
+  const internalRepository = savedObjects.createInternalRepository();
+
+  await internalRepository.incrementCounter(
+    AS_TELEMETRY_NAME,
+    AS_TELEMETRY_NAME,
+    `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide
+  );
+
+  return { success: true };
+}
diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts
new file mode 100644
index 0000000000000..1e4159124ed94
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/index.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server';
+import { schema, TypeOf } from '@kbn/config-schema';
+import { EnterpriseSearchPlugin } from './plugin';
+
+export const plugin = (initializerContext: PluginInitializerContext) => {
+  return new EnterpriseSearchPlugin(initializerContext);
+};
+
+export const configSchema = schema.object({
+  host: schema.maybe(schema.string()),
+  enabled: schema.boolean({ defaultValue: true }),
+  accessCheckTimeout: schema.number({ defaultValue: 5000 }),
+  accessCheckTimeoutWarning: schema.number({ defaultValue: 300 }),
+});
+
+export type ConfigType = TypeOf<typeof configSchema>;
+
+export const config: PluginConfigDescriptor<ConfigType> = {
+  schema: configSchema,
+  exposeToBrowser: {
+    host: true,
+  },
+};
diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts
new file mode 100644
index 0000000000000..11d4a387b533f
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts
@@ -0,0 +1,128 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+jest.mock('./enterprise_search_config_api', () => ({
+  callEnterpriseSearchConfigAPI: jest.fn(),
+}));
+import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api';
+
+import { checkAccess } from './check_access';
+
+describe('checkAccess', () => {
+  const mockSecurity = {
+    authz: {
+      mode: {
+        useRbacForRequest: () => true,
+      },
+      checkPrivilegesWithRequest: () => ({
+        globally: () => ({
+          hasAllRequested: false,
+        }),
+      }),
+      actions: {
+        ui: {
+          get: () => null,
+        },
+      },
+    },
+  };
+  const mockDependencies = {
+    request: {},
+    config: { host: 'http://localhost:3002' },
+    security: mockSecurity,
+  } as any;
+
+  describe('when security is disabled', () => {
+    it('should allow all access', async () => {
+      const security = undefined;
+      expect(await checkAccess({ ...mockDependencies, security })).toEqual({
+        hasAppSearchAccess: true,
+        hasWorkplaceSearchAccess: true,
+      });
+    });
+  });
+
+  describe('when the user is a superuser', () => {
+    it('should allow all access', async () => {
+      const security = {
+        ...mockSecurity,
+        authz: {
+          mode: { useRbacForRequest: () => true },
+          checkPrivilegesWithRequest: () => ({
+            globally: () => ({
+              hasAllRequested: true,
+            }),
+          }),
+          actions: { ui: { get: () => {} } },
+        },
+      };
+      expect(await checkAccess({ ...mockDependencies, security })).toEqual({
+        hasAppSearchAccess: true,
+        hasWorkplaceSearchAccess: true,
+      });
+    });
+
+    it('falls back to assuming a non-superuser role if auth credentials are missing', async () => {
+      const security = {
+        authz: {
+          ...mockSecurity.authz,
+          checkPrivilegesWithRequest: () => ({
+            globally: () => Promise.reject({ statusCode: 403 }),
+          }),
+        },
+      };
+      expect(await checkAccess({ ...mockDependencies, security })).toEqual({
+        hasAppSearchAccess: false,
+        hasWorkplaceSearchAccess: false,
+      });
+    });
+
+    it('throws other authz errors', async () => {
+      const security = {
+        authz: {
+          ...mockSecurity.authz,
+          checkPrivilegesWithRequest: undefined,
+        },
+      };
+      await expect(checkAccess({ ...mockDependencies, security })).rejects.toThrow();
+    });
+  });
+
+  describe('when the user is a non-superuser', () => {
+    describe('when enterpriseSearch.host is not set in kibana.yml', () => {
+      it('should deny all access', async () => {
+        const config = { host: undefined };
+        expect(await checkAccess({ ...mockDependencies, config })).toEqual({
+          hasAppSearchAccess: false,
+          hasWorkplaceSearchAccess: false,
+        });
+      });
+    });
+
+    describe('when enterpriseSearch.host is set in kibana.yml', () => {
+      it('should make a http call and return the access response', async () => {
+        (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({
+          access: {
+            hasAppSearchAccess: false,
+            hasWorkplaceSearchAccess: true,
+          },
+        }));
+        expect(await checkAccess(mockDependencies)).toEqual({
+          hasAppSearchAccess: false,
+          hasWorkplaceSearchAccess: true,
+        });
+      });
+
+      it('falls back to no access if no http response', async () => {
+        (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({}));
+        expect(await checkAccess(mockDependencies)).toEqual({
+          hasAppSearchAccess: false,
+          hasWorkplaceSearchAccess: false,
+        });
+      });
+    });
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.ts
new file mode 100644
index 0000000000000..0239cb6422d03
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/lib/check_access.ts
@@ -0,0 +1,76 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { KibanaRequest, Logger } from 'src/core/server';
+import { SecurityPluginSetup } from '../../../security/server';
+import { ConfigType } from '../';
+
+import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api';
+
+interface ICheckAccess {
+  request: KibanaRequest;
+  security?: SecurityPluginSetup;
+  config: ConfigType;
+  log: Logger;
+}
+export interface IAccess {
+  hasAppSearchAccess: boolean;
+  hasWorkplaceSearchAccess: boolean;
+}
+
+const ALLOW_ALL_PLUGINS = {
+  hasAppSearchAccess: true,
+  hasWorkplaceSearchAccess: true,
+};
+const DENY_ALL_PLUGINS = {
+  hasAppSearchAccess: false,
+  hasWorkplaceSearchAccess: false,
+};
+
+/**
+ * Determines whether the user has access to our Enterprise Search products
+ * via HTTP call. If not, we hide the corresponding plugin links from the
+ * nav and catalogue in `plugin.ts`, which disables plugin access
+ */
+export const checkAccess = async ({
+  config,
+  security,
+  request,
+  log,
+}: ICheckAccess): Promise<IAccess> => {
+  // If security has been disabled, always show the plugin
+  if (!security?.authz.mode.useRbacForRequest(request)) {
+    return ALLOW_ALL_PLUGINS;
+  }
+
+  // If the user is a "superuser" or has the base Kibana all privilege globally, always show the plugin
+  const isSuperUser = async (): Promise<boolean> => {
+    try {
+      const { hasAllRequested } = await security.authz
+        .checkPrivilegesWithRequest(request)
+        .globally(security.authz.actions.ui.get('enterpriseSearch', 'all'));
+      return hasAllRequested;
+    } catch (err) {
+      if (err.statusCode === 401 || err.statusCode === 403) {
+        return false;
+      }
+      throw err;
+    }
+  };
+  if (await isSuperUser()) {
+    return ALLOW_ALL_PLUGINS;
+  }
+
+  // Hide the plugin when enterpriseSearch.host is not defined in kibana.yml
+  if (!config.host) {
+    return DENY_ALL_PLUGINS;
+  }
+
+  // When enterpriseSearch.host is defined in kibana.yml,
+  // make a HTTP call which returns product access
+  const { access } = (await callEnterpriseSearchConfigAPI({ request, config, log })) || {};
+  return access || DENY_ALL_PLUGINS;
+};
diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts
new file mode 100644
index 0000000000000..cf35a458b4825
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts
@@ -0,0 +1,111 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+jest.mock('node-fetch');
+const fetchMock = require('node-fetch') as jest.Mock;
+const { Response } = jest.requireActual('node-fetch');
+
+import { loggingSystemMock } from 'src/core/server/mocks';
+
+import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api';
+
+describe('callEnterpriseSearchConfigAPI', () => {
+  const mockConfig = {
+    host: 'http://localhost:3002',
+    accessCheckTimeout: 200,
+    accessCheckTimeoutWarning: 100,
+  };
+  const mockRequest = {
+    url: { path: '/app/kibana' },
+    headers: { authorization: '==someAuth' },
+  };
+  const mockDependencies = {
+    config: mockConfig,
+    request: mockRequest,
+    log: loggingSystemMock.create().get(),
+  } as any;
+
+  const mockResponse = {
+    version: {
+      number: '1.0.0',
+    },
+    settings: {
+      external_url: 'http://some.vanity.url/',
+    },
+    access: {
+      user: 'someuser',
+      products: {
+        app_search: true,
+        workplace_search: false,
+      },
+    },
+  };
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('calls the config API endpoint', async () => {
+    fetchMock.mockImplementationOnce((url: string) => {
+      expect(url).toEqual('http://localhost:3002/api/ent/v1/internal/client_config');
+      return Promise.resolve(new Response(JSON.stringify(mockResponse)));
+    });
+
+    expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({
+      publicUrl: 'http://some.vanity.url/',
+      access: {
+        hasAppSearchAccess: true,
+        hasWorkplaceSearchAccess: false,
+      },
+    });
+  });
+
+  it('returns early if config.host is not set', async () => {
+    const config = { host: '' };
+
+    expect(await callEnterpriseSearchConfigAPI({ ...mockDependencies, config })).toEqual({});
+    expect(fetchMock).not.toHaveBeenCalled();
+  });
+
+  it('handles server errors', async () => {
+    fetchMock.mockImplementationOnce(() => {
+      return Promise.reject('500');
+    });
+    expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({});
+    expect(mockDependencies.log.error).toHaveBeenCalledWith(
+      'Could not perform access check to Enterprise Search: 500'
+    );
+
+    fetchMock.mockImplementationOnce(() => {
+      return Promise.resolve('Bad Data');
+    });
+    expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({});
+    expect(mockDependencies.log.error).toHaveBeenCalledWith(
+      'Could not perform access check to Enterprise Search: TypeError: response.json is not a function'
+    );
+  });
+
+  it('handles timeouts', async () => {
+    jest.useFakeTimers();
+
+    // Warning
+    callEnterpriseSearchConfigAPI(mockDependencies);
+    jest.advanceTimersByTime(150);
+    expect(mockDependencies.log.warn).toHaveBeenCalledWith(
+      'Enterprise Search access check took over 100ms. Please ensure your Enterprise Search server is respondingly normally and not adversely impacting Kibana load speeds.'
+    );
+
+    // Timeout
+    fetchMock.mockImplementationOnce(async () => {
+      jest.advanceTimersByTime(250);
+      return Promise.reject({ name: 'AbortError' });
+    });
+    expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({});
+    expect(mockDependencies.log.warn).toHaveBeenCalledWith(
+      "Exceeded 200ms timeout while checking http://localhost:3002. Please consider increasing your enterpriseSearch.accessCheckTimeout value so that users aren't prevented from accessing Enterprise Search plugins due to slow responses."
+    );
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts
new file mode 100644
index 0000000000000..7a6d1eac1b454
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts
@@ -0,0 +1,78 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import AbortController from 'abort-controller';
+import fetch from 'node-fetch';
+
+import { KibanaRequest, Logger } from 'src/core/server';
+import { ConfigType } from '../';
+import { IAccess } from './check_access';
+
+interface IParams {
+  request: KibanaRequest;
+  config: ConfigType;
+  log: Logger;
+}
+interface IReturn {
+  publicUrl?: string;
+  access?: IAccess;
+}
+
+/**
+ * Calls an internal Enterprise Search API endpoint which returns
+ * useful various settings (e.g. product access, external URL)
+ * needed by the Kibana plugin at the setup stage
+ */
+const ENDPOINT = '/api/ent/v1/internal/client_config';
+
+export const callEnterpriseSearchConfigAPI = async ({
+  config,
+  log,
+  request,
+}: IParams): Promise<IReturn> => {
+  if (!config.host) return {};
+
+  const TIMEOUT_WARNING = `Enterprise Search access check took over ${config.accessCheckTimeoutWarning}ms. Please ensure your Enterprise Search server is respondingly normally and not adversely impacting Kibana load speeds.`;
+  const TIMEOUT_MESSAGE = `Exceeded ${config.accessCheckTimeout}ms timeout while checking ${config.host}. Please consider increasing your enterpriseSearch.accessCheckTimeout value so that users aren't prevented from accessing Enterprise Search plugins due to slow responses.`;
+  const CONNECTION_ERROR = 'Could not perform access check to Enterprise Search';
+
+  const warningTimeout = setTimeout(() => {
+    log.warn(TIMEOUT_WARNING);
+  }, config.accessCheckTimeoutWarning);
+
+  const controller = new AbortController();
+  const timeout = setTimeout(() => {
+    controller.abort();
+  }, config.accessCheckTimeout);
+
+  try {
+    const enterpriseSearchUrl = encodeURI(`${config.host}${ENDPOINT}`);
+    const response = await fetch(enterpriseSearchUrl, {
+      headers: { Authorization: request.headers.authorization as string },
+      signal: controller.signal,
+    });
+    const data = await response.json();
+
+    return {
+      publicUrl: data?.settings?.external_url,
+      access: {
+        hasAppSearchAccess: !!data?.access?.products?.app_search,
+        hasWorkplaceSearchAccess: !!data?.access?.products?.workplace_search,
+      },
+    };
+  } catch (err) {
+    if (err.name === 'AbortError') {
+      log.warn(TIMEOUT_MESSAGE);
+    } else {
+      log.error(`${CONNECTION_ERROR}: ${err.toString()}`);
+      if (err instanceof Error) log.debug(err.stack as string);
+    }
+    return {};
+  } finally {
+    clearTimeout(warningTimeout);
+    clearTimeout(timeout);
+  }
+};
diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts
new file mode 100644
index 0000000000000..70be8600862e9
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/plugin.ts
@@ -0,0 +1,121 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Observable } from 'rxjs';
+import { first } from 'rxjs/operators';
+import {
+  Plugin,
+  PluginInitializerContext,
+  CoreSetup,
+  Logger,
+  SavedObjectsServiceStart,
+  IRouter,
+  KibanaRequest,
+} from 'src/core/server';
+import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
+import { SecurityPluginSetup } from '../../security/server';
+import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server';
+
+import { ConfigType } from './';
+import { checkAccess } from './lib/check_access';
+import { registerPublicUrlRoute } from './routes/enterprise_search/public_url';
+import { registerEnginesRoute } from './routes/app_search/engines';
+import { registerTelemetryRoute } from './routes/app_search/telemetry';
+import { registerTelemetryUsageCollector } from './collectors/app_search/telemetry';
+import { appSearchTelemetryType } from './saved_objects/app_search/telemetry';
+
+export interface PluginsSetup {
+  usageCollection?: UsageCollectionSetup;
+  security?: SecurityPluginSetup;
+  features: FeaturesPluginSetup;
+}
+
+export interface IRouteDependencies {
+  router: IRouter;
+  config: ConfigType;
+  log: Logger;
+  getSavedObjectsService?(): SavedObjectsServiceStart;
+}
+
+export class EnterpriseSearchPlugin implements Plugin {
+  private config: Observable<ConfigType>;
+  private logger: Logger;
+
+  constructor(initializerContext: PluginInitializerContext) {
+    this.config = initializerContext.config.create<ConfigType>();
+    this.logger = initializerContext.logger.get();
+  }
+
+  public async setup(
+    { capabilities, http, savedObjects, getStartServices }: CoreSetup,
+    { usageCollection, security, features }: PluginsSetup
+  ) {
+    const config = await this.config.pipe(first()).toPromise();
+
+    /**
+     * Register space/feature control
+     */
+    features.registerFeature({
+      id: 'enterpriseSearch',
+      name: 'Enterprise Search',
+      order: 0,
+      icon: 'logoEnterpriseSearch',
+      navLinkId: 'appSearch', // TODO - remove this once functional tests no longer rely on navLinkId
+      app: ['kibana', 'appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch'
+      catalogue: ['appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch'
+      privileges: null,
+    });
+
+    /**
+     * Register user access to the Enterprise Search plugins
+     */
+    capabilities.registerSwitcher(async (request: KibanaRequest) => {
+      const dependencies = { config, security, request, log: this.logger };
+
+      const { hasAppSearchAccess } = await checkAccess(dependencies);
+      // TODO: hasWorkplaceSearchAccess
+
+      return {
+        navLinks: {
+          appSearch: hasAppSearchAccess,
+        },
+        catalogue: {
+          appSearch: hasAppSearchAccess,
+        },
+      };
+    });
+
+    /**
+     * Register routes
+     */
+    const router = http.createRouter();
+    const dependencies = { router, config, log: this.logger };
+
+    registerPublicUrlRoute(dependencies);
+    registerEnginesRoute(dependencies);
+
+    /**
+     * Bootstrap the routes, saved objects, and collector for telemetry
+     */
+    savedObjects.registerType(appSearchTelemetryType);
+    let savedObjectsStarted: SavedObjectsServiceStart;
+
+    getStartServices().then(([coreStart]) => {
+      savedObjectsStarted = coreStart.savedObjects;
+      if (usageCollection) {
+        registerTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
+      }
+    });
+    registerTelemetryRoute({
+      ...dependencies,
+      getSavedObjectsService: () => savedObjectsStarted,
+    });
+  }
+
+  public start() {}
+
+  public stop() {}
+}
diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts
new file mode 100644
index 0000000000000..3cca5e21ce9c3
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { MockRouter } from './router.mock';
+export { mockConfig, mockLogger, mockDependencies } from './routerDependencies.mock';
diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts
new file mode 100644
index 0000000000000..1ca7755979f99
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts
@@ -0,0 +1,102 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { httpServiceMock, httpServerMock } from 'src/core/server/mocks';
+import {
+  IRouter,
+  KibanaRequest,
+  RequestHandlerContext,
+  RouteValidatorConfig,
+} from 'src/core/server';
+
+/**
+ * Test helper that mocks Kibana's router and DRYs out various helper (callRoute, schema validation)
+ */
+
+type methodType = 'get' | 'post' | 'put' | 'patch' | 'delete';
+type payloadType = 'params' | 'query' | 'body';
+
+interface IMockRouterProps {
+  method: methodType;
+  payload?: payloadType;
+}
+interface IMockRouterRequest {
+  body?: object;
+  query?: object;
+  params?: object;
+}
+type TMockRouterRequest = KibanaRequest | IMockRouterRequest;
+
+export class MockRouter {
+  public router!: jest.Mocked<IRouter>;
+  public method: methodType;
+  public payload?: payloadType;
+  public response = httpServerMock.createResponseFactory();
+
+  constructor({ method, payload }: IMockRouterProps) {
+    this.createRouter();
+    this.method = method;
+    this.payload = payload;
+  }
+
+  public createRouter = () => {
+    this.router = httpServiceMock.createRouter();
+  };
+
+  public callRoute = async (request: TMockRouterRequest) => {
+    const [, handler] = this.router[this.method].mock.calls[0];
+
+    const context = {} as jest.Mocked<RequestHandlerContext>;
+    await handler(context, httpServerMock.createKibanaRequest(request as any), this.response);
+  };
+
+  /**
+   * Schema validation helpers
+   */
+
+  public validateRoute = (request: TMockRouterRequest) => {
+    if (!this.payload) throw new Error('Cannot validate wihout a payload type specified.');
+
+    const [config] = this.router[this.method].mock.calls[0];
+    const validate = config.validate as RouteValidatorConfig<{}, {}, {}>;
+
+    const payloadValidation = validate[this.payload] as { validate(request: KibanaRequest): void };
+    const payloadRequest = request[this.payload] as KibanaRequest;
+
+    payloadValidation.validate(payloadRequest);
+  };
+
+  public shouldValidate = (request: TMockRouterRequest) => {
+    expect(() => this.validateRoute(request)).not.toThrow();
+  };
+
+  public shouldThrow = (request: TMockRouterRequest) => {
+    expect(() => this.validateRoute(request)).toThrow();
+  };
+}
+
+/**
+ * Example usage:
+ */
+// const mockRouter = new MockRouter({ method: 'get', payload: 'body' });
+//
+// beforeEach(() => {
+//   jest.clearAllMocks();
+//   mockRouter.createRouter();
+//
+//   registerExampleRoute({ router: mockRouter.router, ...dependencies }); // Whatever other dependencies the route needs
+// });
+
+// it('hits the endpoint successfully', async () => {
+//   await mockRouter.callRoute({ body: { foo: 'bar' } });
+//
+//   expect(mockRouter.response.ok).toHaveBeenCalled();
+// });
+
+// it('validates', () => {
+//   const request = { body: { foo: 'bar' } };
+//   mockRouter.shouldValidate(request);
+// });
diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts
new file mode 100644
index 0000000000000..9b6fa30271d61
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { loggingSystemMock } from 'src/core/server/mocks';
+import { ConfigType } from '../../';
+
+export const mockLogger = loggingSystemMock.createLogger().get();
+
+export const mockConfig = {
+  enabled: true,
+  host: 'http://localhost:3002',
+  accessCheckTimeout: 5000,
+  accessCheckTimeoutWarning: 300,
+} as ConfigType;
+
+/**
+ * This is useful for tests that don't use either config or log,
+ * but should still pass them in to pass Typescript definitions
+ */
+export const mockDependencies = {
+  // Mock router should be handled on a per-test basis
+  config: mockConfig,
+  log: mockLogger,
+};
diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts
new file mode 100644
index 0000000000000..d5b1bc5003456
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts
@@ -0,0 +1,160 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { MockRouter, mockConfig, mockLogger } from '../__mocks__';
+
+import { registerEnginesRoute } from './engines';
+
+jest.mock('node-fetch');
+const fetch = jest.requireActual('node-fetch');
+const { Response } = fetch;
+const fetchMock = require('node-fetch') as jest.Mocked<typeof fetch>;
+
+describe('engine routes', () => {
+  describe('GET /api/app_search/engines', () => {
+    const AUTH_HEADER = 'Basic 123';
+    const mockRequest = {
+      headers: {
+        authorization: AUTH_HEADER,
+      },
+      query: {
+        type: 'indexed',
+        pageIndex: 1,
+      },
+    };
+
+    let mockRouter: MockRouter;
+
+    beforeEach(() => {
+      jest.clearAllMocks();
+      mockRouter = new MockRouter({ method: 'get', payload: 'query' });
+
+      registerEnginesRoute({
+        router: mockRouter.router,
+        log: mockLogger,
+        config: mockConfig,
+      });
+    });
+
+    describe('when the underlying App Search API returns a 200', () => {
+      beforeEach(() => {
+        AppSearchAPI.shouldBeCalledWith(
+          `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`,
+          { headers: { Authorization: AUTH_HEADER } }
+        ).andReturn({
+          results: [{ name: 'engine1' }],
+          meta: { page: { total_results: 1 } },
+        });
+      });
+
+      it('should return 200 with a list of engines from the App Search API', async () => {
+        await mockRouter.callRoute(mockRequest);
+
+        expect(mockRouter.response.ok).toHaveBeenCalledWith({
+          body: { results: [{ name: 'engine1' }], meta: { page: { total_results: 1 } } },
+        });
+      });
+    });
+
+    describe('when the App Search URL is invalid', () => {
+      beforeEach(() => {
+        AppSearchAPI.shouldBeCalledWith(
+          `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`,
+          { headers: { Authorization: AUTH_HEADER } }
+        ).andReturnError();
+      });
+
+      it('should return 404 with a message', async () => {
+        await mockRouter.callRoute(mockRequest);
+
+        expect(mockRouter.response.notFound).toHaveBeenCalledWith({
+          body: 'cannot-connect',
+        });
+        expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to App Search: Failed');
+        expect(mockLogger.debug).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when the App Search API returns invalid data', () => {
+      beforeEach(() => {
+        AppSearchAPI.shouldBeCalledWith(
+          `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`,
+          { headers: { Authorization: AUTH_HEADER } }
+        ).andReturnInvalidData();
+      });
+
+      it('should return 404 with a message', async () => {
+        await mockRouter.callRoute(mockRequest);
+
+        expect(mockRouter.response.notFound).toHaveBeenCalledWith({
+          body: 'cannot-connect',
+        });
+        expect(mockLogger.error).toHaveBeenCalledWith(
+          'Cannot connect to App Search: Error: Invalid data received from App Search: {"foo":"bar"}'
+        );
+        expect(mockLogger.debug).toHaveBeenCalled();
+      });
+    });
+
+    describe('validates', () => {
+      it('correctly', () => {
+        const request = { query: { type: 'meta', pageIndex: 5 } };
+        mockRouter.shouldValidate(request);
+      });
+
+      it('wrong pageIndex type', () => {
+        const request = { query: { type: 'indexed', pageIndex: 'indexed' } };
+        mockRouter.shouldThrow(request);
+      });
+
+      it('wrong type string', () => {
+        const request = { query: { type: 'invalid', pageIndex: 1 } };
+        mockRouter.shouldThrow(request);
+      });
+
+      it('missing pageIndex', () => {
+        const request = { query: { type: 'indexed' } };
+        mockRouter.shouldThrow(request);
+      });
+
+      it('missing type', () => {
+        const request = { query: { pageIndex: 1 } };
+        mockRouter.shouldThrow(request);
+      });
+    });
+
+    const AppSearchAPI = {
+      shouldBeCalledWith(expectedUrl: string, expectedParams: object) {
+        return {
+          andReturn(response: object) {
+            fetchMock.mockImplementation((url: string, params: object) => {
+              expect(url).toEqual(expectedUrl);
+              expect(params).toEqual(expectedParams);
+
+              return Promise.resolve(new Response(JSON.stringify(response)));
+            });
+          },
+          andReturnInvalidData() {
+            fetchMock.mockImplementation((url: string, params: object) => {
+              expect(url).toEqual(expectedUrl);
+              expect(params).toEqual(expectedParams);
+
+              return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' })));
+            });
+          },
+          andReturnError() {
+            fetchMock.mockImplementation((url: string, params: object) => {
+              expect(url).toEqual(expectedUrl);
+              expect(params).toEqual(expectedParams);
+
+              return Promise.reject('Failed');
+            });
+          },
+        };
+      },
+    };
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts
new file mode 100644
index 0000000000000..ca83c0e187ddb
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts
@@ -0,0 +1,59 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import fetch from 'node-fetch';
+import querystring from 'querystring';
+import { schema } from '@kbn/config-schema';
+
+import { IRouteDependencies } from '../../plugin';
+import { ENGINES_PAGE_SIZE } from '../../../common/constants';
+
+export function registerEnginesRoute({ router, config, log }: IRouteDependencies) {
+  router.get(
+    {
+      path: '/api/app_search/engines',
+      validate: {
+        query: schema.object({
+          type: schema.oneOf([schema.literal('indexed'), schema.literal('meta')]),
+          pageIndex: schema.number(),
+        }),
+      },
+    },
+    async (context, request, response) => {
+      try {
+        const enterpriseSearchUrl = config.host as string;
+        const { type, pageIndex } = request.query;
+
+        const params = querystring.stringify({
+          type,
+          'page[current]': pageIndex,
+          'page[size]': ENGINES_PAGE_SIZE,
+        });
+        const url = `${encodeURI(enterpriseSearchUrl)}/as/engines/collection?${params}`;
+
+        const enginesResponse = await fetch(url, {
+          headers: { Authorization: request.headers.authorization as string },
+        });
+
+        const engines = await enginesResponse.json();
+        const hasValidData =
+          Array.isArray(engines?.results) && typeof engines?.meta?.page?.total_results === 'number';
+
+        if (hasValidData) {
+          return response.ok({ body: engines });
+        } else {
+          // Either a completely incorrect Enterprise Search host URL was configured, or App Search is returning bad data
+          throw new Error(`Invalid data received from App Search: ${JSON.stringify(engines)}`);
+        }
+      } catch (e) {
+        log.error(`Cannot connect to App Search: ${e.toString()}`);
+        if (e instanceof Error) log.debug(e.stack as string);
+
+        return response.notFound({ body: 'cannot-connect' });
+      }
+    }
+  );
+}
diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts
new file mode 100644
index 0000000000000..e2d5fbcec3705
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts
@@ -0,0 +1,108 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks';
+import { MockRouter, mockConfig, mockLogger } from '../__mocks__';
+
+import { registerTelemetryRoute } from './telemetry';
+
+jest.mock('../../collectors/app_search/telemetry', () => ({
+  incrementUICounter: jest.fn(),
+}));
+import { incrementUICounter } from '../../collectors/app_search/telemetry';
+
+/**
+ * Since these route callbacks are so thin, these serve simply as integration tests
+ * to ensure they're wired up to the collector functions correctly. Business logic
+ * is tested more thoroughly in the collectors/telemetry tests.
+ */
+describe('App Search Telemetry API', () => {
+  let mockRouter: MockRouter;
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+    mockRouter = new MockRouter({ method: 'put', payload: 'body' });
+
+    registerTelemetryRoute({
+      router: mockRouter.router,
+      getSavedObjectsService: () => savedObjectsServiceMock.createStartContract(),
+      log: mockLogger,
+      config: mockConfig,
+    });
+  });
+
+  describe('PUT /api/app_search/telemetry', () => {
+    it('increments the saved objects counter', async () => {
+      const successResponse = { success: true };
+      (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse));
+
+      await mockRouter.callRoute({ body: { action: 'viewed', metric: 'setup_guide' } });
+
+      expect(incrementUICounter).toHaveBeenCalledWith({
+        savedObjects: expect.any(Object),
+        uiAction: 'ui_viewed',
+        metric: 'setup_guide',
+      });
+      expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse });
+    });
+
+    it('throws an error when incrementing fails', async () => {
+      (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => Promise.reject('Failed')));
+
+      await mockRouter.callRoute({ body: { action: 'error', metric: 'error' } });
+
+      expect(incrementUICounter).toHaveBeenCalled();
+      expect(mockLogger.error).toHaveBeenCalled();
+      expect(mockRouter.response.internalError).toHaveBeenCalled();
+    });
+
+    it('throws an error if the Saved Objects service is unavailable', async () => {
+      jest.clearAllMocks();
+      registerTelemetryRoute({
+        router: mockRouter.router,
+        getSavedObjectsService: null,
+        log: mockLogger,
+      } as any);
+      await mockRouter.callRoute({});
+
+      expect(incrementUICounter).not.toHaveBeenCalled();
+      expect(mockLogger.error).toHaveBeenCalled();
+      expect(mockRouter.response.internalError).toHaveBeenCalled();
+      expect(loggingSystemMock.collect(mockLogger).error[0][0]).toEqual(
+        expect.stringContaining(
+          'App Search UI telemetry error: Error: Could not find Saved Objects service'
+        )
+      );
+    });
+
+    describe('validates', () => {
+      it('correctly', () => {
+        const request = { body: { action: 'viewed', metric: 'setup_guide' } };
+        mockRouter.shouldValidate(request);
+      });
+
+      it('wrong action string', () => {
+        const request = { body: { action: 'invalid', metric: 'setup_guide' } };
+        mockRouter.shouldThrow(request);
+      });
+
+      it('wrong metric type', () => {
+        const request = { body: { action: 'clicked', metric: true } };
+        mockRouter.shouldThrow(request);
+      });
+
+      it('action is missing', () => {
+        const request = { body: { metric: 'engines_overview' } };
+        mockRouter.shouldThrow(request);
+      });
+
+      it('metric is missing', () => {
+        const request = { body: { action: 'error' } };
+        mockRouter.shouldThrow(request);
+      });
+    });
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts
new file mode 100644
index 0000000000000..4cc9b64adc092
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts
@@ -0,0 +1,50 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { schema } from '@kbn/config-schema';
+
+import { IRouteDependencies } from '../../plugin';
+import { incrementUICounter } from '../../collectors/app_search/telemetry';
+
+export function registerTelemetryRoute({
+  router,
+  getSavedObjectsService,
+  log,
+}: IRouteDependencies) {
+  router.put(
+    {
+      path: '/api/app_search/telemetry',
+      validate: {
+        body: schema.object({
+          action: schema.oneOf([
+            schema.literal('viewed'),
+            schema.literal('clicked'),
+            schema.literal('error'),
+          ]),
+          metric: schema.string(),
+        }),
+      },
+    },
+    async (ctx, request, response) => {
+      const { action, metric } = request.body;
+
+      try {
+        if (!getSavedObjectsService) throw new Error('Could not find Saved Objects service');
+
+        return response.ok({
+          body: await incrementUICounter({
+            savedObjects: getSavedObjectsService(),
+            uiAction: `ui_${action}`,
+            metric,
+          }),
+        });
+      } catch (e) {
+        log.error(`App Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}`);
+        return response.internalError({ body: 'App Search UI telemetry failed' });
+      }
+    }
+  );
+}
diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts
new file mode 100644
index 0000000000000..846aae3fce56f
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts
@@ -0,0 +1,52 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { MockRouter, mockDependencies } from '../__mocks__';
+
+jest.mock('../../lib/enterprise_search_config_api', () => ({
+  callEnterpriseSearchConfigAPI: jest.fn(),
+}));
+import { callEnterpriseSearchConfigAPI } from '../../lib/enterprise_search_config_api';
+
+import { registerPublicUrlRoute } from './public_url';
+
+describe('Enterprise Search Public URL API', () => {
+  let mockRouter: MockRouter;
+
+  beforeEach(() => {
+    mockRouter = new MockRouter({ method: 'get' });
+
+    registerPublicUrlRoute({
+      ...mockDependencies,
+      router: mockRouter.router,
+    });
+  });
+
+  describe('GET /api/enterprise_search/public_url', () => {
+    it('returns a publicUrl', async () => {
+      (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => {
+        return Promise.resolve({ publicUrl: 'http://some.vanity.url' });
+      });
+
+      await mockRouter.callRoute({});
+
+      expect(mockRouter.response.ok).toHaveBeenCalledWith({
+        body: { publicUrl: 'http://some.vanity.url' },
+        headers: { 'content-type': 'application/json' },
+      });
+    });
+
+    // For the most part, all error logging is handled by callEnterpriseSearchConfigAPI.
+    // This endpoint should mostly just fall back gracefully to an empty string
+    it('falls back to an empty string', async () => {
+      await mockRouter.callRoute({});
+      expect(mockRouter.response.ok).toHaveBeenCalledWith({
+        body: { publicUrl: '' },
+        headers: { 'content-type': 'application/json' },
+      });
+    });
+  });
+});
diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts
new file mode 100644
index 0000000000000..a9edd4eb10da0
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouteDependencies } from '../../plugin';
+import { callEnterpriseSearchConfigAPI } from '../../lib/enterprise_search_config_api';
+
+export function registerPublicUrlRoute({ router, config, log }: IRouteDependencies) {
+  router.get(
+    {
+      path: '/api/enterprise_search/public_url',
+      validate: false,
+    },
+    async (context, request, response) => {
+      const { publicUrl = '' } =
+        (await callEnterpriseSearchConfigAPI({ request, config, log })) || {};
+
+      return response.ok({
+        body: { publicUrl },
+        headers: { 'content-type': 'application/json' },
+      });
+    }
+  );
+}
diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts
new file mode 100644
index 0000000000000..32322d494b5e2
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+/* istanbul ignore file */
+
+import { SavedObjectsType } from 'src/core/server';
+import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry';
+
+export const appSearchTelemetryType: SavedObjectsType = {
+  name: AS_TELEMETRY_NAME,
+  hidden: false,
+  namespaceType: 'agnostic',
+  mappings: {
+    dynamic: false,
+    properties: {},
+  },
+};
diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts
index 06f064a379fe6..8a499a3eba8fa 100644
--- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts
+++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts
@@ -189,13 +189,15 @@ describe('features', () => {
     group: 'global',
     expectManageSpaces: true,
     expectGetFeatures: true,
+    expectEnterpriseSearch: true,
   },
   {
     group: 'space',
     expectManageSpaces: false,
     expectGetFeatures: false,
+    expectEnterpriseSearch: false,
   },
-].forEach(({ group, expectManageSpaces, expectGetFeatures }) => {
+].forEach(({ group, expectManageSpaces, expectGetFeatures, expectEnterpriseSearch }) => {
   describe(`${group}`, () => {
     test('actions defined in any feature privilege are included in `all`', () => {
       const features: Feature[] = [
@@ -256,6 +258,7 @@ describe('features', () => {
               actions.ui.get('management', 'kibana', 'spaces'),
             ]
           : []),
+        ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []),
         actions.ui.get('catalogue', 'all-catalogue-1'),
         actions.ui.get('catalogue', 'all-catalogue-2'),
         actions.ui.get('management', 'all-management', 'all-management-1'),
@@ -450,6 +453,7 @@ describe('features', () => {
               actions.ui.get('management', 'kibana', 'spaces'),
             ]
           : []),
+        ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []),
       ]);
       expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]);
     });
@@ -514,6 +518,7 @@ describe('features', () => {
               actions.ui.get('management', 'kibana', 'spaces'),
             ]
           : []),
+        ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []),
       ]);
       expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]);
     });
@@ -579,6 +584,7 @@ describe('features', () => {
               actions.ui.get('management', 'kibana', 'spaces'),
             ]
           : []),
+        ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []),
       ]);
       expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]);
     });
@@ -840,6 +846,7 @@ describe('subFeatures', () => {
         actions.space.manage,
         actions.ui.get('spaces', 'manage'),
         actions.ui.get('management', 'kibana', 'spaces'),
+        actions.ui.get('enterpriseSearch', 'all'),
         actions.ui.get('foo', 'foo'),
       ]);
       expect(actual).toHaveProperty('global.read', [
@@ -991,6 +998,7 @@ describe('subFeatures', () => {
         actions.space.manage,
         actions.ui.get('spaces', 'manage'),
         actions.ui.get('management', 'kibana', 'spaces'),
+        actions.ui.get('enterpriseSearch', 'all'),
         actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
         actions.savedObject.get('all-sub-feature-type', 'get'),
         actions.savedObject.get('all-sub-feature-type', 'find'),
@@ -1189,6 +1197,7 @@ describe('subFeatures', () => {
         actions.space.manage,
         actions.ui.get('spaces', 'manage'),
         actions.ui.get('management', 'kibana', 'spaces'),
+        actions.ui.get('enterpriseSearch', 'all'),
       ]);
       expect(actual).toHaveProperty('global.read', [actions.login, actions.version]);
 
@@ -1315,6 +1324,7 @@ describe('subFeatures', () => {
         actions.space.manage,
         actions.ui.get('spaces', 'manage'),
         actions.ui.get('management', 'kibana', 'spaces'),
+        actions.ui.get('enterpriseSearch', 'all'),
         actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
         actions.savedObject.get('all-sub-feature-type', 'get'),
         actions.savedObject.get('all-sub-feature-type', 'find'),
@@ -1477,6 +1487,7 @@ describe('subFeatures', () => {
         actions.space.manage,
         actions.ui.get('spaces', 'manage'),
         actions.ui.get('management', 'kibana', 'spaces'),
+        actions.ui.get('enterpriseSearch', 'all'),
       ]);
       expect(actual).toHaveProperty('global.read', [actions.login, actions.version]);
 
@@ -1592,6 +1603,7 @@ describe('subFeatures', () => {
         actions.space.manage,
         actions.ui.get('spaces', 'manage'),
         actions.ui.get('management', 'kibana', 'spaces'),
+        actions.ui.get('enterpriseSearch', 'all'),
         actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
         actions.savedObject.get('all-sub-feature-type', 'get'),
         actions.savedObject.get('all-sub-feature-type', 'find'),
diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts
index 5a15290a7f1a2..f9ee5fc750127 100644
--- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts
+++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts
@@ -101,6 +101,7 @@ export function privilegesFactory(
             actions.space.manage,
             actions.ui.get('spaces', 'manage'),
             actions.ui.get('management', 'kibana', 'spaces'),
+            actions.ui.get('enterpriseSearch', 'all'),
             ...allActions,
           ],
           read: [actions.login, actions.version, ...readActions],
diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json
index 13d7c62316040..1ea16a2a9940c 100644
--- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json
+++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json
@@ -7,6 +7,40 @@
         }
       }
     },
+    "app_search": {
+      "properties": {
+        "ui_viewed": {
+          "properties": {
+            "setup_guide": {
+              "type": "long"
+            },
+            "engines_overview": {
+              "type": "long"
+            }
+          }
+        },
+        "ui_error": {
+          "properties": {
+            "cannot_connect": {
+              "type": "long"
+            }
+          }
+        },
+        "ui_clicked": {
+          "properties": {
+            "create_first_engine_button": {
+              "type": "long"
+            },
+            "header_launch_button": {
+              "type": "long"
+            },
+            "engine_table_link": {
+              "type": "long"
+            }
+          }
+        }
+      }
+    },
     "fileUploadTelemetry": {
       "properties": {
         "filesUploadedTotalCount": {
diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js
index 29be6d826c1bc..ee8af9e040401 100644
--- a/x-pack/scripts/functional_tests.js
+++ b/x-pack/scripts/functional_tests.js
@@ -53,6 +53,7 @@ const onlyNotInCoverageTests = [
   require.resolve('../test/reporting_api_integration/config.js'),
   require.resolve('../test/functional_embedded/config.ts'),
   require.resolve('../test/ingest_manager_api_integration/config.ts'),
+  require.resolve('../test/functional_enterprise_search/without_host_configured.config.ts'),
 ];
 
 require('@kbn/plugin-helpers').babelRegister();
diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts
index 11fb9b2de7199..df6eca795f801 100644
--- a/x-pack/test/api_integration/apis/features/features/features.ts
+++ b/x-pack/test/api_integration/apis/features/features/features.ts
@@ -97,6 +97,7 @@ export default function ({ getService }: FtrProviderContext) {
             'visualize',
             'dashboard',
             'dev_tools',
+            'enterpriseSearch',
             'advancedSettings',
             'indexPatterns',
             'timelion',
diff --git a/x-pack/test/functional_enterprise_search/README.md b/x-pack/test/functional_enterprise_search/README.md
new file mode 100644
index 0000000000000..63d13cbac7020
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/README.md
@@ -0,0 +1,41 @@
+# Enterprise Search Functional E2E Tests
+
+## Running these tests
+
+Follow the [Functional Test Runner instructions](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html#_running_functional_tests).
+
+There are two suites available to run, a suite that requires a Kibana instance without an `enterpriseSearch.host`
+configured, and one that does. The later also [requires a running Enterprise Search instance](#enterprise-search-requirement), and a Private API key
+from that instance set in an Environment variable.
+
+Ex.
+
+```sh
+# Run specs from the x-pack directory
+cd x-pack
+
+# Run tests that do not require enterpriseSearch.host variable
+node scripts/functional_tests --config test/functional_enterprise_search/without_host_configured.config.ts
+
+# Run tests that require enterpriseSearch.host variable
+APP_SEARCH_API_KEY=[use private key from local App Search instance here] node scripts/functional_tests --config test/functional_enterprise_search/with_host_configured.config.ts
+```
+
+## Enterprise Search Requirement
+
+The `with_host_configured` tests will not currently start an instance of App Search automatically. As such, they are not run as part of CI and are most useful for local regression testing.
+
+The easiest way to start Enterprise Search for these tests is to check out the `ent-search` project
+and use the following script.
+
+```sh
+cd script/stack_scripts
+/start-with-license-and-expiration.sh platinum 500000
+```
+
+Requirements for Enterprise Search:
+
+- Running on port 3002 against a separate Elasticsearch cluster.
+- Elasticsearch must have a platinum or greater level license (or trial).
+- Must have Standard or Native Auth configured with an `enterprise_search` user with password `changeme`.
+- There should be NO existing Engines or Meta Engines.
diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts
new file mode 100644
index 0000000000000..e4ebd61c0692a
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts
@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import expect from '@kbn/expect';
+import { EsArchiver } from 'src/es_archiver';
+import { AppSearchService, IEngine } from '../../../../services/app_search_service';
+import { Browser } from '../../../../../../../test/functional/services/common';
+import { FtrProviderContext } from '../../../../ftr_provider_context';
+
+export default function enterpriseSearchSetupEnginesTests({
+  getService,
+  getPageObjects,
+}: FtrProviderContext) {
+  const esArchiver = getService('esArchiver') as EsArchiver;
+  const browser = getService('browser') as Browser;
+  const retry = getService('retry');
+  const appSearch = getService('appSearch') as AppSearchService;
+
+  const PageObjects = getPageObjects(['appSearch', 'security']);
+
+  describe('Engines Overview', function () {
+    let engine1: IEngine;
+    let engine2: IEngine;
+    let metaEngine: IEngine;
+
+    before(async () => {
+      await esArchiver.load('empty_kibana');
+      engine1 = await appSearch.createEngine();
+      engine2 = await appSearch.createEngine();
+      metaEngine = await appSearch.createMetaEngine([engine1.name, engine2.name]);
+    });
+
+    after(async () => {
+      await esArchiver.unload('empty_kibana');
+      appSearch.destroyEngine(engine1.name);
+      appSearch.destroyEngine(engine2.name);
+      appSearch.destroyEngine(metaEngine.name);
+    });
+
+    describe('when an enterpriseSearch.host is configured', () => {
+      it('navigating to the enterprise_search plugin will redirect a user to the App Search Engines Overview page', async () => {
+        await PageObjects.security.forceLogout();
+        const { user, password } = appSearch.getEnterpriseSearchUser();
+        await PageObjects.security.login(user, password, {
+          expectSpaceSelector: false,
+        });
+
+        await PageObjects.appSearch.navigateToPage();
+        await retry.try(async function () {
+          const currentUrl = await browser.getCurrentUrl();
+          expect(currentUrl).to.contain('/app_search');
+        });
+      });
+
+      it('lists engines', async () => {
+        const engineLinks = await PageObjects.appSearch.getEngineLinks();
+        const engineLinksText = await Promise.all(engineLinks.map((l) => l.getVisibleText()));
+
+        expect(engineLinksText.includes(engine1.name)).to.equal(true);
+        expect(engineLinksText.includes(engine2.name)).to.equal(true);
+      });
+
+      it('lists meta engines', async () => {
+        const metaEngineLinks = await PageObjects.appSearch.getMetaEngineLinks();
+        const metaEngineLinksText = await Promise.all(
+          metaEngineLinks.map((l) => l.getVisibleText())
+        );
+        expect(metaEngineLinksText.includes(metaEngine.name)).to.equal(true);
+      });
+    });
+  });
+}
diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts
new file mode 100644
index 0000000000000..ac4984e0db019
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { FtrProviderContext } from '../../../ftr_provider_context';
+
+export default function ({ loadTestFile }: FtrProviderContext) {
+  describe('Enterprise Search', function () {
+    loadTestFile(require.resolve('./app_search/engines'));
+  });
+}
diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts
new file mode 100644
index 0000000000000..1d478c6baf29c
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import expect from '@kbn/expect';
+import { FtrProviderContext } from '../../../../ftr_provider_context';
+
+export default function enterpriseSearchSetupGuideTests({
+  getService,
+  getPageObjects,
+}: FtrProviderContext) {
+  const esArchiver = getService('esArchiver');
+  const browser = getService('browser');
+  const retry = getService('retry');
+
+  const PageObjects = getPageObjects(['appSearch']);
+
+  describe('Setup Guide', function () {
+    before(async () => await esArchiver.load('empty_kibana'));
+    after(async () => {
+      await esArchiver.unload('empty_kibana');
+    });
+
+    describe('when no enterpriseSearch.host is configured', () => {
+      it('navigating to the enterprise_search plugin will redirect a user to the setup guide', async () => {
+        await PageObjects.appSearch.navigateToPage();
+        await retry.try(async function () {
+          const currentUrl = await browser.getCurrentUrl();
+          expect(currentUrl).to.contain('/app_search/setup_guide');
+        });
+      });
+    });
+  });
+}
diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts
new file mode 100644
index 0000000000000..31a92e752fcf4
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { FtrProviderContext } from '../../../ftr_provider_context';
+
+export default function ({ loadTestFile }: FtrProviderContext) {
+  describe('Enterprise Search', function () {
+    this.tags('ciGroup10');
+
+    loadTestFile(require.resolve('./app_search/setup_guide'));
+  });
+}
diff --git a/x-pack/test/functional_enterprise_search/base_config.ts b/x-pack/test/functional_enterprise_search/base_config.ts
new file mode 100644
index 0000000000000..f737b6cd4b5f4
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/base_config.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
+import { pageObjects } from './page_objects';
+import { services } from './services';
+
+export default async function ({ readConfigFile }: FtrConfigProviderContext) {
+  const xPackFunctionalConfig = await readConfigFile(require.resolve('../functional/config'));
+
+  return {
+    // default to the xpack functional config
+    ...xPackFunctionalConfig.getAll(),
+    services,
+    pageObjects,
+  };
+}
diff --git a/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts b/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts
new file mode 100644
index 0000000000000..bb257cdcbfe1b
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts
@@ -0,0 +1,12 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { GenericFtrProviderContext } from '@kbn/test/types/ftr';
+
+import { pageObjects } from './page_objects';
+import { services } from './services';
+
+export type FtrProviderContext = GenericFtrProviderContext<typeof services, typeof pageObjects>;
diff --git a/x-pack/test/functional_enterprise_search/page_objects/app_search.ts b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts
new file mode 100644
index 0000000000000..d845a1935a149
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { FtrProviderContext } from '../ftr_provider_context';
+import { TestSubjects } from '../../../../test/functional/services/common';
+import { WebElementWrapper } from '../../../../test/functional/services/lib/web_element_wrapper';
+
+export function AppSearchPageProvider({ getService, getPageObjects }: FtrProviderContext) {
+  const PageObjects = getPageObjects(['common']);
+  const testSubjects = getService('testSubjects') as TestSubjects;
+
+  return {
+    async navigateToPage(): Promise<void> {
+      return await PageObjects.common.navigateToApp('enterprise_search/app_search');
+    },
+
+    async getEngineLinks(): Promise<WebElementWrapper[]> {
+      const engines = await testSubjects.find('appSearchEngines');
+      return await testSubjects.findAllDescendant('engineNameLink', engines);
+    },
+
+    async getMetaEngineLinks(): Promise<WebElementWrapper[]> {
+      const metaEngines = await testSubjects.find('appSearchMetaEngines');
+      return await testSubjects.findAllDescendant('engineNameLink', metaEngines);
+    },
+  };
+}
diff --git a/x-pack/test/functional_enterprise_search/page_objects/index.ts b/x-pack/test/functional_enterprise_search/page_objects/index.ts
new file mode 100644
index 0000000000000..009fb26482419
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/page_objects/index.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { pageObjects as basePageObjects } from '../../functional/page_objects';
+import { AppSearchPageProvider } from './app_search';
+
+export const pageObjects = {
+  ...basePageObjects,
+  appSearch: AppSearchPageProvider,
+};
diff --git a/x-pack/test/functional_enterprise_search/services/app_search_client.ts b/x-pack/test/functional_enterprise_search/services/app_search_client.ts
new file mode 100644
index 0000000000000..fbd15b83f97ea
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/services/app_search_client.ts
@@ -0,0 +1,121 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import http from 'http';
+
+/**
+ * A simple request client for making API calls to the App Search API
+ */
+const makeRequest = <T>(method: string, path: string, body?: object): Promise<T> => {
+  return new Promise(function (resolve, reject) {
+    const APP_SEARCH_API_KEY = process.env.APP_SEARCH_API_KEY;
+
+    if (!APP_SEARCH_API_KEY) {
+      throw new Error('Please provide a valid APP_SEARCH_API_KEY. See README for more details.');
+    }
+
+    let postData;
+
+    if (body) {
+      postData = JSON.stringify(body);
+    }
+
+    const req = http.request(
+      {
+        method,
+        hostname: 'localhost',
+        port: 3002,
+        path,
+        agent: false, // Create a new agent just for this one request
+        headers: {
+          Authorization: `Bearer ${APP_SEARCH_API_KEY}`,
+          'Content-Type': 'application/json',
+          ...(!!postData && { 'Content-Length': Buffer.byteLength(postData) }),
+        },
+      },
+      (res) => {
+        const bodyChunks: Uint8Array[] = [];
+        res.on('data', function (chunk) {
+          bodyChunks.push(chunk);
+        });
+
+        res.on('end', function () {
+          let responseBody;
+          try {
+            responseBody = JSON.parse(Buffer.concat(bodyChunks).toString());
+          } catch (e) {
+            reject(e);
+          }
+
+          if (res.statusCode && res.statusCode > 299) {
+            reject('Error calling App Search API: ' + JSON.stringify(responseBody));
+          }
+
+          resolve(responseBody);
+        });
+      }
+    );
+
+    req.on('error', (e) => {
+      reject(e);
+    });
+
+    if (postData) {
+      req.write(postData);
+    }
+    req.end();
+  });
+};
+
+export interface IEngine {
+  name: string;
+}
+
+export const createEngine = async (engineName: string): Promise<IEngine> => {
+  return await makeRequest('POST', '/api/as/v1/engines', { name: engineName });
+};
+
+export const destroyEngine = async (engineName: string): Promise<object> => {
+  return await makeRequest('DELETE', `/api/as/v1/engines/${engineName}`);
+};
+
+export const createMetaEngine = async (
+  engineName: string,
+  sourceEngines: string[]
+): Promise<IEngine> => {
+  return await makeRequest('POST', '/api/as/v1/engines', {
+    name: engineName,
+    type: 'meta',
+    source_engines: sourceEngines,
+  });
+};
+
+export interface ISearchResponse {
+  results: object[];
+}
+
+const search = async (engineName: string): Promise<ISearchResponse> => {
+  return await makeRequest('POST', `/api/as/v1/engines/${engineName}/search`, { query: '' });
+};
+
+// Since the App Search API does not issue document receipts, the only way to tell whether or not documents
+// are fully indexed is to poll the search endpoint.
+export const waitForIndexedDocs = (engineName: string) => {
+  return new Promise(async function (resolve) {
+    let isReady = false;
+    while (!isReady) {
+      const response = await search(engineName);
+      if (response.results && response.results.length > 0) {
+        isReady = true;
+        resolve();
+      }
+    }
+  });
+};
+
+export const indexData = async (engineName: string, docs: object[]) => {
+  return await makeRequest('POST', `/api/as/v1/engines/${engineName}/documents`, docs);
+};
diff --git a/x-pack/test/functional_enterprise_search/services/app_search_service.ts b/x-pack/test/functional_enterprise_search/services/app_search_service.ts
new file mode 100644
index 0000000000000..9a43783402f4b
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/services/app_search_service.ts
@@ -0,0 +1,77 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { FtrProviderContext } from '../ftr_provider_context';
+
+const ENTERPRISE_SEARCH_USER = 'enterprise_search';
+const ENTERPRISE_SEARCH_PASSWORD = 'changeme';
+import {
+  createEngine,
+  createMetaEngine,
+  indexData,
+  waitForIndexedDocs,
+  destroyEngine,
+  IEngine,
+} from './app_search_client';
+
+export interface IUser {
+  user: string;
+  password: string;
+}
+export { IEngine };
+
+export class AppSearchService {
+  getEnterpriseSearchUser(): IUser {
+    return {
+      user: ENTERPRISE_SEARCH_USER,
+      password: ENTERPRISE_SEARCH_PASSWORD,
+    };
+  }
+
+  createEngine(): Promise<IEngine> {
+    const engineName = `test-engine-${new Date().getTime()}`;
+    return createEngine(engineName);
+  }
+
+  async createEngineWithDocs(): Promise<IEngine> {
+    const engine = await this.createEngine();
+    const docs = [
+      { id: 1, name: 'doc1' },
+      { id: 2, name: 'doc2' },
+      { id: 3, name: 'doc2' },
+    ];
+    await indexData(engine.name, docs);
+    await waitForIndexedDocs(engine.name);
+    return engine;
+  }
+
+  createMetaEngine(sourceEngines: string[]): Promise<IEngine> {
+    const engineName = `test-meta-engine-${new Date().getTime()}`;
+    return createMetaEngine(engineName, sourceEngines);
+  }
+
+  destroyEngine(engineName: string) {
+    return destroyEngine(engineName);
+  }
+}
+
+export async function AppSearchServiceProvider({ getService }: FtrProviderContext) {
+  const lifecycle = getService('lifecycle');
+  const security = getService('security');
+
+  lifecycle.beforeTests.add(async () => {
+    // The App Search plugin passes through the current user name and password
+    // through on the API call to App Search. Therefore, we need to be signed
+    // in as the enterprise_search user in order for this plugin to work.
+    await security.user.create(ENTERPRISE_SEARCH_USER, {
+      password: ENTERPRISE_SEARCH_PASSWORD,
+      roles: ['kibana_admin'],
+      full_name: ENTERPRISE_SEARCH_USER,
+    });
+  });
+
+  return new AppSearchService();
+}
diff --git a/x-pack/test/functional_enterprise_search/services/index.ts b/x-pack/test/functional_enterprise_search/services/index.ts
new file mode 100644
index 0000000000000..1715c98677ac6
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/services/index.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { services as functionalServices } from '../../functional/services';
+import { AppSearchServiceProvider } from './app_search_service';
+
+export const services = {
+  ...functionalServices,
+  appSearch: AppSearchServiceProvider,
+};
diff --git a/x-pack/test/functional_enterprise_search/with_host_configured.config.ts b/x-pack/test/functional_enterprise_search/with_host_configured.config.ts
new file mode 100644
index 0000000000000..f425f806f4bcd
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/with_host_configured.config.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { resolve } from 'path';
+import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
+
+export default async function ({ readConfigFile }: FtrConfigProviderContext) {
+  const baseConfig = await readConfigFile(require.resolve('./base_config'));
+
+  return {
+    // default to the xpack functional config
+    ...baseConfig.getAll(),
+
+    testFiles: [resolve(__dirname, './apps/enterprise_search/with_host_configured')],
+
+    junit: {
+      reportName: 'X-Pack Enterprise Search Functional Tests with Host Configured',
+    },
+
+    kbnTestServer: {
+      ...baseConfig.get('kbnTestServer'),
+      serverArgs: [
+        ...baseConfig.get('kbnTestServer.serverArgs'),
+        '--enterpriseSearch.host=http://localhost:3002',
+      ],
+    },
+  };
+}
diff --git a/x-pack/test/functional_enterprise_search/without_host_configured.config.ts b/x-pack/test/functional_enterprise_search/without_host_configured.config.ts
new file mode 100644
index 0000000000000..0f2afd214abed
--- /dev/null
+++ b/x-pack/test/functional_enterprise_search/without_host_configured.config.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { resolve } from 'path';
+import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
+
+export default async function ({ readConfigFile }: FtrConfigProviderContext) {
+  const baseConfig = await readConfigFile(require.resolve('./base_config'));
+
+  return {
+    // default to the xpack functional config
+    ...baseConfig.getAll(),
+
+    testFiles: [resolve(__dirname, './apps/enterprise_search/without_host_configured')],
+
+    junit: {
+      reportName: 'X-Pack Enterprise Search Functional Tests without Host Configured',
+    },
+  };
+}
diff --git a/x-pack/test/ui_capabilities/common/nav_links_builder.ts b/x-pack/test/ui_capabilities/common/nav_links_builder.ts
index 405ef4dbdc5b1..b20a499ba7e20 100644
--- a/x-pack/test/ui_capabilities/common/nav_links_builder.ts
+++ b/x-pack/test/ui_capabilities/common/nav_links_builder.ts
@@ -15,6 +15,10 @@ export class NavLinksBuilder {
       management: {
         navLinkId: 'kibana:stack_management',
       },
+      // TODO: Temp until navLinkIds fix is merged in
+      appSearch: {
+        navLinkId: 'appSearch',
+      },
     };
   }
 
diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts
index f8f3f2be2b2ec..0e0d46c6ce2cd 100644
--- a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts
+++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts
@@ -32,17 +32,27 @@ export default function catalogueTests({ getService }: FtrProviderContext) {
             break;
           }
           case 'global_all at everything_space':
-          case 'dual_privileges_all at everything_space':
+          case 'dual_privileges_all at everything_space': {
+            expect(uiCapabilities.success).to.be(true);
+            expect(uiCapabilities.value).to.have.property('catalogue');
+            // everything except ml and monitoring is enabled
+            const expected = mapValues(
+              uiCapabilities.value!.catalogue,
+              (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring'
+            );
+            expect(uiCapabilities.value!.catalogue).to.eql(expected);
+            break;
+          }
           case 'everything_space_all at everything_space':
           case 'global_read at everything_space':
           case 'dual_privileges_read at everything_space':
           case 'everything_space_read at everything_space': {
             expect(uiCapabilities.success).to.be(true);
             expect(uiCapabilities.value).to.have.property('catalogue');
-            // everything except ml and monitoring is enabled
+            // everything except ml and monitoring and enterprise search is enabled
             const expected = mapValues(
               uiCapabilities.value!.catalogue,
-              (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring'
+              (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId)
             );
             expect(uiCapabilities.value!.catalogue).to.eql(expected);
             break;
diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts
index 10ecf5d25d346..08a7d789153e7 100644
--- a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts
+++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts
@@ -38,14 +38,20 @@ export default function navLinksTests({ getService }: FtrProviderContext) {
             break;
           case 'global_all at everything_space':
           case 'dual_privileges_all at everything_space':
-          case 'dual_privileges_read at everything_space':
-          case 'global_read at everything_space':
+            expect(uiCapabilities.success).to.be(true);
+            expect(uiCapabilities.value).to.have.property('navLinks');
+            expect(uiCapabilities.value!.navLinks).to.eql(
+              navLinksBuilder.except('ml', 'monitoring')
+            );
+            break;
           case 'everything_space_all at everything_space':
+          case 'global_read at everything_space':
+          case 'dual_privileges_read at everything_space':
           case 'everything_space_read at everything_space':
             expect(uiCapabilities.success).to.be(true);
             expect(uiCapabilities.value).to.have.property('navLinks');
             expect(uiCapabilities.value!.navLinks).to.eql(
-              navLinksBuilder.except('ml', 'monitoring')
+              navLinksBuilder.except('ml', 'monitoring', 'enterpriseSearch', 'appSearch')
             );
             break;
           case 'superuser at nothing_space':
diff --git a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts
index 52a1f30147b4f..99f91407dc1d2 100644
--- a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts
+++ b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts
@@ -32,9 +32,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) {
             break;
           }
           case 'all':
-          case 'read':
-          case 'dual_privileges_all':
-          case 'dual_privileges_read': {
+          case 'dual_privileges_all': {
             expect(uiCapabilities.success).to.be(true);
             expect(uiCapabilities.value).to.have.property('catalogue');
             // everything except ml and monitoring is enabled
@@ -45,6 +43,18 @@ export default function catalogueTests({ getService }: FtrProviderContext) {
             expect(uiCapabilities.value!.catalogue).to.eql(expected);
             break;
           }
+          case 'read':
+          case 'dual_privileges_read': {
+            expect(uiCapabilities.success).to.be(true);
+            expect(uiCapabilities.value).to.have.property('catalogue');
+            // everything except ml and monitoring and enterprise search is enabled
+            const expected = mapValues(
+              uiCapabilities.value!.catalogue,
+              (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId)
+            );
+            expect(uiCapabilities.value!.catalogue).to.eql(expected);
+            break;
+          }
           case 'foo_all':
           case 'foo_read': {
             expect(uiCapabilities.success).to.be(true);
diff --git a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts
index fe9ffa9286de8..d3bd2e1afd357 100644
--- a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts
+++ b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts
@@ -37,15 +37,21 @@ export default function navLinksTests({ getService }: FtrProviderContext) {
             expect(uiCapabilities.value!.navLinks).to.eql(navLinksBuilder.all());
             break;
           case 'all':
-          case 'read':
           case 'dual_privileges_all':
-          case 'dual_privileges_read':
             expect(uiCapabilities.success).to.be(true);
             expect(uiCapabilities.value).to.have.property('navLinks');
             expect(uiCapabilities.value!.navLinks).to.eql(
               navLinksBuilder.except('ml', 'monitoring')
             );
             break;
+          case 'read':
+          case 'dual_privileges_read':
+            expect(uiCapabilities.success).to.be(true);
+            expect(uiCapabilities.value).to.have.property('navLinks');
+            expect(uiCapabilities.value!.navLinks).to.eql(
+              navLinksBuilder.except('ml', 'monitoring', 'appSearch')
+            );
+            break;
           case 'foo_all':
           case 'foo_read':
             expect(uiCapabilities.success).to.be(true);