Repository: amp-labs/connectors
Pull request #1433 (2025-03-04 04:04:31)

[CON-1324] feat(capsule): Metadata
# Description

* Developed a scraping script similar to `salesloft`.
* Capsule's documentation is well-structured and consistent, making extraction easy and reliable.
* Value options are conveniently preceded by the prefix **"Accepted values are:"**, simplifying the extraction of `singleSelect` `ValueType`.

![image](https://github.com/user-attachments/assets/10ee245e-4efc-489c-bddc-8d3248c20355)
![image](https://github.com/user-attachments/assets/17b3eb4d-54fa-4c87-bb94-f0a2a9dddac7)
![image](https://github.com/user-attachments/assets/a1958fb1-95f7-4f58-b57a-34fdfe5c1fac)
![image](https://github.com/user-attachments/assets/3c6954b8-f620-446a-a48a-b47e8130499f)

# Notes
Renamed `scrapper` parent directory to `scraper` (turns out 2 different words :laughing: ).

Cobalt0s: > [!WARNING]
> <b>This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1433?utm_source=stack-comment-downstack-mergeability-warning" >on Graphite</a>.</b>
> <a href="https://graphite.dev/docs/merge-pull-requests">Learn more</a>

* **#1433** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1433?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1433?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#1432** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1432?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->

Patch:
diff --git a/go.mod b/go.mod
index 59b77a82..120e8d3f 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/PuerkitoBio/goquery v1.10.2
 	github.com/antchfx/xmlquery v1.4.3
 	github.com/brianvoe/gofakeit/v6 v6.28.0
+	github.com/deiu/linkparser v0.0.0-20170608193052-9b6849e15168
 	github.com/gertd/go-pluralize v0.2.1
 	github.com/getkin/kin-openapi v0.129.0
 	github.com/go-playground/validator v9.31.0+incompatible
diff --git a/go.sum b/go.sum
index 28c03d50..611af918 100644
--- a/go.sum
+++ b/go.sum
@@ -13,6 +13,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/deiu/linkparser v0.0.0-20170608193052-9b6849e15168 h1:faQ0lJ7RbfOyHSVkVwmWiUk/+HOA648JNBmwIkFHlxI=
+github.com/deiu/linkparser v0.0.0-20170608193052-9b6849e15168/go.mod h1:EPdXetNGTVpWsQ9wn8LQzqNByQOjMeFhYEvNOZNGtwg=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
diff --git a/internal/httpkit/response.go b/internal/httpkit/response.go
new file mode 100644
index 00000000..4f1a18dc
--- /dev/null
+++ b/internal/httpkit/response.go
@@ -0,0 +1,19 @@
+// nolint:lll
+package httpkit
+
+import (
+	"github.com/amp-labs/connectors/common"
+	lh "github.com/deiu/linkparser"
+)
+
+// HeaderLink extracts and parses Link Header of HTTP response.
+//
+// Example: given the header below we want to return one of the URLs based on the relationship name.
+// <https://api.capsulecrm.com/api/v2/parties?page=3&perPage=1>; rel="next", <https://api.capsulecrm.com/api/v2/parties?page=1&perPage=1>; rel="prev"
+//
+// The implementation is delegated to `linkparser` library.
+func HeaderLink(resp *common.JSONHTTPResponse, relationshipName string) string {
+	link := resp.Headers.Get("Link")
+
+	return lh.ParseHeader(link)[relationshipName]["href"]
+}
diff --git a/providers/ashby/connector.go b/providers/ashby/connector.go
index a0239e91..03657178 100644
--- a/providers/ashby/connector.go
+++ b/providers/ashby/connector.go
@@ -3,7 +3,10 @@ package ashby
 import (
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/components/operations"
+	"github.com/amp-labs/connectors/internal/components/reader"
 	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/internal/components/writer"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/providers/ashby/metadata"
 )
@@ -17,6 +20,8 @@ type Connector struct {
 
 	// supported operations
 	components.SchemaProvider
+	components.Reader
+	components.Writer
 }
 
 func NewConnector(params common.Parameters) (*Connector, error) {
@@ -31,5 +36,32 @@ func constructor(base *components.Connector) (*Connector, error) {
 	// Set the metadata provider for the connector
 	connector.SchemaProvider = schema.NewOpenAPISchemaProvider(connector.ProviderContext.Module(), metadata.Schemas)
 
+	registry, err := components.NewEndpointRegistry(supportedOperations())
+	if err != nil {
+		return nil, err
+	}
+
+	connector.Reader = reader.NewHTTPReader(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.ReadHandlers{
+			BuildRequest:  connector.buildReadRequest,
+			ParseResponse: connector.parseReadResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
+	connector.Writer = writer.NewHTTPWriter(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.WriteHandlers{
+			BuildRequest:  connector.buildWriteRequest,
+			ParseResponse: connector.parseWriteResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
 	return connector, nil
 }
diff --git a/providers/ashby/handlers.go b/providers/ashby/handlers.go
new file mode 100644
index 00000000..e84529b3
--- /dev/null
+++ b/providers/ashby/handlers.go
@@ -0,0 +1,160 @@
+package ashby
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/amp-labs/connectors/providers/ashby/metadata"
+	"github.com/spyzhov/ajson"
+)
+
+const (
+	pageSizeKey = "limit"
+	pageSize    = "100"
+	pageKey     = "cursor"
+	sinceKey    = "createdAfter"
+)
+
+func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadParams) (*http.Request, error) {
+	path, err := metadata.Schemas.LookupURLPath(c.Module(), params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	url, err := urlbuilder.New(c.ProviderInfo().BaseURL, path)
+	if err != nil {
+		return nil, err
+	}
+
+	body := buildRequestbody(params)
+
+	jsonData, err := json.Marshal(body)
+	if err != nil {
+		return nil, err
+	}
+
+	return http.NewRequestWithContext(ctx, http.MethodPost, url.String(), bytes.NewReader(jsonData))
+}
+
+func buildRequestbody(params common.ReadParams) map[string]any {
+	body := make(map[string]any)
+
+	if supportSince.Has(params.ObjectName) && !params.Since.IsZero() {
+		body[sinceKey] = params.Since.UnixMilli()
+	}
+
+	if supportPagination.Has(params.ObjectName) && params.NextPage != "" {
+		body[pageKey] = params.NextPage
+		body[pageSizeKey] = pageSize
+	}
+
+	return body
+}
+
+func (c *Connector) parseReadResponse(
+	ctx context.Context,
+	params common.ReadParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.ReadResult, error) {
+	responseKey := metadata.Schemas.LookupArrayFieldName(c.Module(), params.ObjectName)
+
+	return common.ParseResult(
+		response,
+		common.GetRecordsUnderJSONPath(responseKey),
+		makeNextRecordsURL,
+		common.GetMarshaledData,
+		params.Fields,
+	)
+}
+
+// Note: Ashby API uses POST method for all operations (create/update/delete).
+func (c *Connector) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) {
+	var (
+		url *urlbuilder.URL
+		err error
+	)
+
+	url, err = urlbuilder.New(c.ProviderInfo().BaseURL, params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	jsonData, err := json.Marshal(params.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	return http.NewRequestWithContext(ctx, http.MethodPost, url.String(), bytes.NewReader(jsonData))
+}
+
+func (c *Connector) parseWriteResponse(
+	ctx context.Context,
+	params common.WriteParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.WriteResult, error) {
+	node, ok := response.Body()
+	if !ok {
+		return &common.WriteResult{Success: true}, nil
+	}
+
+	success, err := jsonquery.New(node).BoolWithDefault("success", true)
+	if err != nil {
+		return nil, err
+	}
+
+	if !success {
+		return handleErrorResponse(node)
+	}
+
+	results, err := jsonquery.New(node).ObjectRequired("results")
+	if err != nil {
+		//nolint:nilerr
+		return &common.WriteResult{Success: true}, nil
+	}
+
+	rawID, err := jsonquery.New(node, "results").StringOptional("id")
+	if err != nil {
+		//nolint:nilerr
+		return &common.WriteResult{Success: true}, nil
+	}
+
+	data, err := jsonquery.Convertor.ObjectToMap(results)
+	if err != nil {
+		//nolint:nilerr
+		return &common.WriteResult{
+			Success:  true,
+			RecordId: *rawID,
+		}, nil
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: *rawID,
+		Data:     data,
+	}, nil
+}
+
+func handleErrorResponse(node *ajson.Node) (*common.WriteResult, error) {
+	errors, err := jsonquery.New(node).ArrayOptional("errors")
+	if err != nil {
+		return &common.WriteResult{Success: false}, nil //nolint:nilerr
+	}
+
+	errorArr, err := jsonquery.Convertor.ArrayToObjects(errors)
+	if err != nil {
+		//nolint:nilerr
+		return &common.WriteResult{Success: false}, nil
+	}
+
+	return &common.WriteResult{
+		Success: false,
+		Errors:  errorArr,
+	}, nil
+}
diff --git a/providers/ashby/parse.go b/providers/ashby/parse.go
new file mode 100644
index 00000000..86ff68bc
--- /dev/null
+++ b/providers/ashby/parse.go
@@ -0,0 +1,20 @@
+package ashby
+
+import (
+	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/spyzhov/ajson"
+)
+
+func makeNextRecordsURL(node *ajson.Node) (string, error) {
+	moreDataAvailable, err := jsonquery.New(node).BoolWithDefault("moreDataAvailable", true)
+	if !moreDataAvailable || err != nil {
+		return "", nil //nolint:nilerr
+	}
+
+	cursor, err := jsonquery.New(node).StringOptional("nextCursor")
+	if err != nil {
+		return "", nil //nolint:nilerr
+	}
+
+	return *cursor, nil
+}
diff --git a/providers/ashby/supports.go b/providers/ashby/supports.go
new file mode 100644
index 00000000..70170489
--- /dev/null
+++ b/providers/ashby/supports.go
@@ -0,0 +1,57 @@
+package ashby
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/datautils"
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/providers/ashby/metadata"
+)
+
+var (
+	// nolint:gochecknoglobals,lll
+	supportPagination = datautils.NewSet("application.list", "applicationFeedback.list", "candidate.list",
+		"candidateTag.list", "customField.list", "feedbackFormDefinition.list", "interview.list", "interviewSchedule.list",
+		"feedbackFormDefinition.list", "interviewerPool.list", "job.list", "jobTemplate.list", "offer.list", "opening.list",
+		"project.list", "surveyFormDefinition.list", "user.list")
+
+	//nolint:gochecknoglobals
+	supportSince = datautils.NewSet("application.list", "applicationFeedback.list", "interviewSchedule.list")
+)
+
+func supportedOperations() components.EndpointRegistryInput {
+	readSupport := metadata.Schemas.ObjectNames().GetList(staticschema.RootModuleID)
+
+	//nolint:lll
+	writeSupport := []string{
+		"application.create", "candidate.create", "candidate.createNote", "candidate.addTag",
+		"candidate.addProject", "candidateTag.create", "customField.create", "department.create",
+		"hiringTeam.addMember", "interviewSchedule.create", "interviewerPool.create", "interviewerPool.addUser",
+		"job.create", "location.create", "offer.create", "offerProcess.start", "opening.create", "referral.create",
+		"surveyRequest.create", "surveySubmission.create", "webhook.create",
+
+		"application.change_source", "application.change_stage", "application.transfer", "application.update",
+		"application.updateHistory", "application.addHiringTeamMember", "application.removeHiringTeamMember",
+		"applicationFeedback.submit", "applicationForm.submit", "approvalDefinition.update", "assessment.addCompletedToCandidate",
+		"assessment.start", "assessment.update", "assessment.cancel", "candidate.anonymize", "candidate.update", "customField.setValue",
+		"hiringTeam.addMember", "interviewSchedule.cancel", "interviewSchedule.update", "interviewerPool.update", "interviewerPool.archive",
+		"interviewerPool.restore", "interviewerPool.addUser", "interviewerPool.removeUser", "job.setStatus", "job.update", "jobPosting.update",
+		"offer.start", "opening.addJob", "opening.removeJob", "opening.addLocation", "opening.removeLocation", "opening.setOpeningState", "opening.setArchived",
+		"opening.update", "webhook.update",
+	}
+
+	return components.EndpointRegistryInput{
+		staticschema.RootModuleID: {
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(readSupport, ",")),
+				Support:  components.ReadSupport,
+			},
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(writeSupport, ",")),
+				Support:  components.WriteSupport,
+			},
+		},
+	}
+}
diff --git a/providers/brevo/connector.go b/providers/brevo/connector.go
index 3049518a..cb7f7814 100644
--- a/providers/brevo/connector.go
+++ b/providers/brevo/connector.go
@@ -1,13 +1,12 @@
 package brevo
 
 import (
-	_ "embed"
-
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/internal/components"
 	"github.com/amp-labs/connectors/internal/components/operations"
 	"github.com/amp-labs/connectors/internal/components/reader"
 	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/internal/components/writer"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/providers/brevo/metadata"
 )
@@ -22,6 +21,7 @@ type Connector struct {
 	// supported operations
 	components.SchemaProvider
 	components.Reader
+	components.Writer
 }
 
 func NewConnector(params common.Parameters) (*Connector, error) {
@@ -37,7 +37,6 @@ func constructor(base *components.Connector) (*Connector, error) {
 	if err != nil {
 		return nil, err
 	}
-
 	// Set the metadata provider for the connector
 	connector.SchemaProvider = schema.NewOpenAPISchemaProvider(connector.ProviderContext.Module(), metadata.Schemas)
 
@@ -52,8 +51,16 @@ func constructor(base *components.Connector) (*Connector, error) {
 		},
 	)
 
-	// Set the metadata provider for the connector
-	connector.SchemaProvider = schema.NewOpenAPISchemaProvider(connector.ProviderContext.Module(), metadata.Schemas)
+	connector.Writer = writer.NewHTTPWriter(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.WriteHandlers{
+			BuildRequest:  connector.buildWriteRequest,
+			ParseResponse: connector.parseWriteResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
 
 	return connector, nil
 }
diff --git a/providers/brevo/handlers.go b/providers/brevo/handlers.go
index 7f0c70d9..cade235b 100644
--- a/providers/brevo/handlers.go
+++ b/providers/brevo/handlers.go
@@ -1,12 +1,17 @@
 package brevo
 
 import (
+	"bytes"
 	"context"
+	"encoding/json"
+	"fmt"
 	"net/http"
 	"strconv"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/internal/datautils"
+	"github.com/amp-labs/connectors/internal/jsonquery"
 	"github.com/amp-labs/connectors/providers/brevo/metadata"
 )
 
@@ -58,3 +63,79 @@ func (c *Connector) parseReadResponse(
 		params.Fields,
 	)
 }
+
+func (c *Connector) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) {
+	var (
+		url    *urlbuilder.URL
+		err    error
+		method = http.MethodPost
+	)
+
+	url, err = urlbuilder.New(c.ProviderInfo().BaseURL, apiVersion, params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(params.RecordId) > 0 {
+		url.AddPath(params.RecordId)
+
+		method = http.MethodPatch
+	}
+
+	jsonData, err := json.Marshal(params.RecordData)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal record data: %w", err)
+	}
+
+	return http.NewRequestWithContext(ctx, method, url.String(), bytes.NewReader(jsonData))
+}
+
+func (c *Connector) parseWriteResponse(
+	ctx context.Context,
+	params common.WriteParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.WriteResult, error) {
+	node, ok := response.Body()
+	if !ok {
+		// Handle empty response
+		return &common.WriteResult{
+			Success: true,
+		}, nil
+	}
+
+	recordIDPaths := datautils.NewDefaultMap(map[string]string{ //nolint:gochecknoglobals
+		"smtp/email":                           "messageId",
+		"transactionalSMS/sms":                 "messageId",
+		"whatsapp/sendMessage":                 "messageId",
+		"contacts/export":                      "processId",
+		"contacts/import":                      "processId",
+		"webhooks/export":                      "processId",
+		"corporate/ssoToken":                   "token",
+		"corporate/subAccount/ssoToken":        "token",
+		"corporate/subAccount/key":             "key",
+		"organization/user/invitation/send":    "invoice_id",
+		"organization/user/update/permissions": "invoice_id",
+		"companies/import":                     "processId",
+		"crm/deals/import":                     "processId",
+		"orders/status/batch":                  "batchId",
+	},
+		func(objectName string) string {
+			return "id"
+		},
+	)
+
+	idPath := recordIDPaths.Get(params.ObjectName)
+
+	rawID, err := jsonquery.New(node).TextWithDefault(idPath, "")
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: rawID,
+		Errors:   nil,
+		Data:     nil,
+	}, nil
+}
diff --git a/providers/brevo/parse.go b/providers/brevo/parse.go
index 70fb1f37..282b6968 100644
--- a/providers/brevo/parse.go
+++ b/providers/brevo/parse.go
@@ -35,7 +35,6 @@ func nextRecordsURL(previousURL *url.URL) common.NextPageFunc {
 			}
 		}
 
-		// calcuate the next offset value
 		nextOffset := currentOffset + pageSize
 		// if nextOffset would exceed total count, we've fetched all records
 		if nextOffset >= int(*totalCount) {
diff --git a/providers/brevo/support.go b/providers/brevo/support.go
index 604fb4c2..79a2bc13 100644
--- a/providers/brevo/support.go
+++ b/providers/brevo/support.go
@@ -38,12 +38,39 @@ func supportedOperations() components.EndpointRegistryInput {
 	// We support reading everything under schema.json, so we get all the objects and join it into a pattern.
 	readSupport := metadata.Schemas.ObjectNames().GetList(staticschema.RootModuleID)
 
+	writeSupport := []string{
+		"smtp/email", "smtp/templates", "smtp/blockedDomains",
+		"smtp/deleteHardbounces", "transactionalSMS/sms", "whatsapp/sendMessage",
+		"emailCampaigns", "emailCampaigns/images", "smsCampaigns", "whatsappCampaigns",
+		"whatsappCampaigns/template", "contacts", "contacts/doubleOptinConfirmation", "contacts/folders",
+		"contacts/export", "contacts/import", "events", "senders", "senders/domains", "webhooks", "webhooks/export",
+		"corporate/subAccount", "corporate/ssoToken", "corporate/subAccount/ssoToken", "corporate/subAccount/key",
+
+		"corporate/group", "corporate/user/invitation/send",
+		"organization/user/invitation/send", "organization/user/update/permissions",
+
+		"feeds", "companies", "crm/attributes", "companies/import",
+		"crm/deals", "crm/deals/import", "crm/tasks", "crm/notes",
+		"conversations/messages",
+
+		"conversations/pushedMessages", "conversations/agentOnlinePing",
+		"ecommerce/activate", "ecommerce/config/displayCurrency",
+
+		"orders/status", "orders/status/batch", "categories", "categories/batch", "products", "couponCollections",
+		"coupons", "payments/requests", "loyalty/config/programs",
+	}
+
 	return components.EndpointRegistryInput{
 		staticschema.RootModuleID: {
 			{
 				Endpoint: fmt.Sprintf("{%s}", strings.Join(readSupport, ",")),
 				Support:  components.ReadSupport,
 			},
+
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(writeSupport, ",")),
+				Support:  components.WriteSupport,
+			},
 		},
 	}
 }
diff --git a/providers/capsule/connector.go b/providers/capsule/connector.go
new file mode 100644
index 00000000..29e9f97b
--- /dev/null
+++ b/providers/capsule/connector.go
@@ -0,0 +1,35 @@
+package capsule
+
+import (
+	_ "embed"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/capsule/metadata"
+)
+
+type Connector struct {
+	// Basic connector
+	*components.Connector
+
+	// Require authenticated client
+	common.RequireAuthenticatedClient
+
+	// Supported operations
+	components.SchemaProvider
+}
+
+func NewConnector(params common.Parameters) (*Connector, error) {
+	return components.Initialize(providers.Capsule, params, constructor)
+}
+
+// nolint:funlen
+func constructor(base *components.Connector) (*Connector, error) {
+	connector := &Connector{Connector: base}
+
+	connector.SchemaProvider = schema.NewOpenAPISchemaProvider(connector.ProviderContext.Module(), metadata.Schemas)
+
+	return connector, nil
+}
diff --git a/providers/capsule/metadata/index.json b/providers/capsule/metadata/index.json
new file mode 100755
index 00000000..22a6a3c6
--- /dev/null
+++ b/providers/capsule/metadata/index.json
@@ -0,0 +1,94 @@
+{
+  "data": [
+    {
+      "name": "activitytypes",
+      "displayName": "activitytypes",
+      "url": "https://developer.capsulecrm.com/v2/models/activity_type"
+    },
+    {
+      "name": "boards",
+      "displayName": "boards",
+      "url": "https://developer.capsulecrm.com/v2/models/board"
+    },
+    {
+      "name": "cases",
+      "displayName": "cases",
+      "url": "https://developer.capsulecrm.com/v2/models/case"
+    },
+    {
+      "name": "categories",
+      "displayName": "categories",
+      "url": "https://developer.capsulecrm.com/v2/models/category"
+    },
+    {
+      "name": "countries",
+      "displayName": "countries",
+      "url": "https://developer.capsulecrm.com/v2/models/country"
+    },
+    {
+      "name": "lostreasons",
+      "displayName": "lostreasons",
+      "url": "https://developer.capsulecrm.com/v2/models/lost_reason"
+    },
+    {
+      "name": "milestones",
+      "displayName": "milestones",
+      "url": "https://developer.capsulecrm.com/v2/models/milestone"
+    },
+    {
+      "name": "opportunities",
+      "displayName": "opportunities",
+      "url": "https://developer.capsulecrm.com/v2/models/opportunity"
+    },
+    {
+      "name": "parties",
+      "displayName": "parties",
+      "url": "https://developer.capsulecrm.com/v2/models/party"
+    },
+    {
+      "name": "pipelines",
+      "displayName": "pipelines",
+      "url": "https://developer.capsulecrm.com/v2/models/pipeline"
+    },
+    {
+      "name": "projects",
+      "displayName": "projects",
+      "url": "https://developer.capsulecrm.com/v2/models/project"
+    },
+    {
+      "name": "resthooks",
+      "displayName": "resthooks",
+      "url": "https://developer.capsulecrm.com/v2/models/rest_hook"
+    },
+    {
+      "name": "stages",
+      "displayName": "stages",
+      "url": "https://developer.capsulecrm.com/v2/models/stage"
+    },
+    {
+      "name": "tasks",
+      "displayName": "tasks",
+      "url": "https://developer.capsulecrm.com/v2/models/task"
+    },
+    {
+      "name": "teams",
+      "displayName": "teams",
+      "url": "https://developer.capsulecrm.com/v2/models/team"
+    },
+    {
+      "name": "titles",
+      "displayName": "titles",
+      "url": "https://developer.capsulecrm.com/v2/models/title"
+    },
+    {
+      "name": "trackdefinitions",
+      "displayName": "trackdefinitions",
+      "url": "https://developer.capsulecrm.com/v2/models/track_definition"
+    },
+    {
+      "name": "users",
+      "displayName": "users",
+      "url": "https://developer.capsulecrm.com/v2/models/user"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/providers/capsule/metadata/metadata.go b/providers/capsule/metadata/metadata.go
new file mode 100644
index 00000000..3ef1d3a5
--- /dev/null
+++ b/providers/capsule/metadata/metadata.go
@@ -0,0 +1,21 @@
+package metadata
+
+import (
+	_ "embed"
+
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/tools/fileconv"
+	"github.com/amp-labs/connectors/tools/scrapper"
+)
+
+var (
+	// Static file containing a list of object metadata is embedded and can be served.
+	//
+	//go:embed schemas.json
+	schemas []byte
+
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV2]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
+
+	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
+)
diff --git a/providers/capsule/metadata/schemas.json b/providers/capsule/metadata/schemas.json
new file mode 100644
index 00000000..542a677a
--- /dev/null
+++ b/providers/capsule/metadata/schemas.json
@@ -0,0 +1,1135 @@
+{
+  "modules": {
+    "root": {
+      "id": "root",
+      "path": "/v2",
+      "objects": {
+        "activitytypes": {
+          "displayName": "Activity Types",
+          "path": "/activitytypes",
+          "responseKey": "activityTypes",
+          "fields": {
+            "icon": {
+              "displayName": "Icon",
+              "valueType": "other",
+              "providerType": "Activity Type Icon"
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "updateLastContacted": {
+              "displayName": "Update Last Contacted",
+              "valueType": "boolean",
+              "providerType": "Boolean"
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/activity_type"
+        },
+        "boards": {
+          "displayName": "Boards",
+          "path": "/boards",
+          "responseKey": "boards",
+          "fields": {
+            "createdAt": {
+              "displayName": "Created At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "deleted": {
+              "displayName": "Deleted",
+              "valueType": "boolean",
+              "providerType": "Boolean"
+            },
+            "description": {
+              "displayName": "Description",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "updatedAt": {
+              "displayName": "Updated At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/board"
+        },
+        "categories": {
+          "displayName": "Categories",
+          "path": "/categories",
+          "responseKey": "categories",
+          "fields": {
+            "colour": {
+              "displayName": "Colour",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String"
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/category"
+        },
+        "countries": {
+          "displayName": "Countries",
+          "path": "/countries",
+          "responseKey": "countries",
+          "fields": {
+            "alpha2Code": {
+              "displayName": "Alpha 2 Code",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "alpha3Code": {
+              "displayName": "Alpha 3 Code",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "numericCode": {
+              "displayName": "Numeric Code",
+              "valueType": "string",
+              "providerType": "String"
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/country"
+        },
+        "lostreasons": {
+          "displayName": "Lost Reasons",
+          "path": "/lostreasons",
+          "responseKey": "lostReasons",
+          "fields": {
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long"
+            },
+            "includedForConversion": {
+              "displayName": "Included for Conversion",
+              "valueType": "boolean",
+              "providerType": "Boolean"
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String"
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/lost_reason"
+        },
+        "milestones": {
+          "displayName": "Milestones",
+          "path": "/milestones",
+          "responseKey": "milestones",
+          "fields": {
+            "complete": {
+              "displayName": "Complete",
+              "valueType": "boolean",
+              "providerType": "Boolean"
+            },
+            "createdAt": {
+              "displayName": "Created At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "daysUntilStale": {
+              "displayName": "Days Until Stale",
+              "valueType": "int",
+              "providerType": "Integer"
+            },
+            "description": {
+              "displayName": "Description",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "pipeline": {
+              "displayName": "Pipeline",
+              "valueType": "other",
+              "providerType": "Nested Pipeline"
+            },
+            "probability": {
+              "displayName": "Probability",
+              "valueType": "int",
+              "providerType": "Integer"
+            },
+            "updatedAt": {
+              "displayName": "Updated At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/milestone"
+        },
+        "opportunities": {
+          "displayName": "Opportunities",
+          "path": "/opportunities",
+          "responseKey": "opportunities",
+          "fields": {
+            "closedOn": {
+              "displayName": "Closed On",
+              "valueType": "date",
+              "providerType": "Date"
+            },
+            "createdAt": {
+              "displayName": "Created At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "description": {
+              "displayName": "Description",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "duration": {
+              "displayName": "Duration",
+              "valueType": "int",
+              "providerType": "Integer"
+            },
+            "durationBasis": {
+              "displayName": "Duration Basis",
+              "valueType": "singleSelect",
+              "providerType": "String",
+              "values": [
+                {
+                  "value": "FIXED",
+                  "displayValue": "FIXED"
+                },
+                {
+                  "value": "HOUR",
+                  "displayValue": "HOUR"
+                },
+                {
+                  "value": "DAY",
+                  "displayValue": "DAY"
+                },
+                {
+                  "value": "WEEK",
+                  "displayValue": "WEEK"
+                },
+                {
+                  "value": "MONTH",
+                  "displayValue": "MONTH"
+                },
+                {
+                  "value": "QUARTER",
+                  "displayValue": "QUARTER"
+                },
+                {
+                  "value": "YEAR",
+                  "displayValue": "YEAR"
+                }
+              ]
+            },
+            "expectedCloseOn": {
+              "displayName": "Expected Close On",
+              "valueType": "date",
+              "providerType": "Date"
+            },
+            "fields": {
+              "displayName": "Fields",
+              "valueType": "other",
+              "providerType": "Array"
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "lastContactedAt": {
+              "displayName": "Last Contacted At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "lastOpenMilestone": {
+              "displayName": "Last Open Milestone",
+              "valueType": "other",
+              "providerType": "Nested Milestone",
+              "readOnly": true
+            },
+            "lastStageChangedAt": {
+              "displayName": "Last Stage Changed At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "lostReason": {
+              "displayName": "Lost Reason",
+              "valueType": "other",
+              "providerType": "Nested Lost Reason"
+            },
+            "milestone": {
+              "displayName": "Milestone",
+              "valueType": "other",
+              "providerType": "Nested Milestone"
+            },
+            "missingImportantFields": {
+              "displayName": "Missing Important Fields",
+              "valueType": "boolean",
+              "providerType": "Boolean"
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "owner": {
+              "displayName": "Owner",
+              "valueType": "other",
+              "providerType": "Nested User"
+            },
+            "party": {
+              "displayName": "Party",
+              "valueType": "other",
+              "providerType": "Nested Party"
+            },
+            "probability": {
+              "displayName": "Probability",
+              "valueType": "int",
+              "providerType": "Long"
+            },
+            "tags": {
+              "displayName": "Tags",
+              "valueType": "other",
+              "providerType": "Array"
+            },
+            "team": {
+              "displayName": "Team",
+              "valueType": "other",
+              "providerType": "Nested Team"
+            },
+            "updatedAt": {
+              "displayName": "Updated At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "value": {
+              "displayName": "Value",
+              "valueType": "other",
+              "providerType": "Opportunity Value"
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/opportunity"
+        },
+        "parties": {
+          "displayName": "Parties",
+          "path": "/parties",
+          "responseKey": "parties",
+          "fields": {
+            "about": {
+              "displayName": "About",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "addresses": {
+              "displayName": "Addresses",
+              "valueType": "other",
+              "providerType": "Array"
+            },
+            "createdAt": {
+              "displayName": "Created At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "emailAddresses": {
+              "displayName": "Email Addresses",
+              "valueType": "other",
+              "providerType": "Array"
+            },
+            "fields": {
+              "displayName": "Fields",
+              "valueType": "other",
+              "providerType": "Array"
+            },
+            "firstName": {
+              "displayName": "First Name",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "jobTitle": {
+              "displayName": "Job Title",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "lastContactedAt": {
+              "displayName": "Last Contacted At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "lastName": {
+              "displayName": "Last Name",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "missingImportantFields": {
+              "displayName": "Missing Important Fields",
+              "valueType": "boolean",
+              "providerType": "Boolean"
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "organisation": {
+              "displayName": "Organisation",
+              "valueType": "other",
+              "providerType": "Nested Party"
+            },
+            "owner": {
+              "displayName": "Owner",
+              "valueType": "other",
+              "providerType": "Nested User"
+            },
+            "phoneNumbers": {
+              "displayName": "Phone Numbers",
+              "valueType": "other",
+              "providerType": "Array"
+            },
+            "pictureURL": {
+              "displayName": "Picture Url",
+              "valueType": "string",
+              "providerType": "String",
+              "readOnly": true
+            },
+            "tags": {
+              "displayName": "Tags",
+              "valueType": "other",
+              "providerType": "Array"
+            },
+            "team": {
+              "displayName": "Team",
+              "valueType": "other",
+              "providerType": "Nested Team"
+            },
+            "title": {
+              "displayName": "Title",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "type": {
+              "displayName": "Type",
+              "valueType": "singleSelect",
+              "providerType": "String",
+              "values": [
+                {
+                  "value": "person",
+                  "displayValue": "person"
+                },
+                {
+                  "value": "organisation",
+                  "displayValue": "organisation"
+                }
+              ]
+            },
+            "updatedAt": {
+              "displayName": "Updated At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "websites": {
+              "displayName": "Websites",
+              "valueType": "other",
+              "providerType": "Array"
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/party"
+        },
+        "pipelines": {
+          "displayName": "Pipelines",
+          "path": "/pipelines",
+          "responseKey": "pipelines",
+          "fields": {
+            "createdAt": {
+              "displayName": "Created At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "deleted": {
+              "displayName": "Deleted",
+              "valueType": "boolean",
+              "providerType": "Boolean"
+            },
+            "description": {
+              "displayName": "Description",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "updatedAt": {
+              "displayName": "Updated At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/pipeline"
+        },
+        "projects": {
+          "displayName": "Projects",
+          "path": "/kases",
+          "responseKey": "kases",
+          "fields": {
+            "closedOn": {
+              "displayName": "Closed On",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "createdAt": {
+              "displayName": "Created At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "description": {
+              "displayName": "Description",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "expectedCloseOn": {
+              "displayName": "Expected Close On",
+              "valueType": "date",
+              "providerType": "Date"
+            },
+            "fields": {
+              "displayName": "Fields",
+              "valueType": "other",
+              "providerType": "Array"
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "lastContactedAt": {
+              "displayName": "Last Contacted At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "missingImportantFields": {
+              "displayName": "Missing Important Fields",
+              "valueType": "boolean",
+              "providerType": "Boolean"
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "opportunity": {
+              "displayName": "Opportunity",
+              "valueType": "other",
+              "providerType": "Nested Opportunity"
+            },
+            "owner": {
+              "displayName": "Owner",
+              "valueType": "other",
+              "providerType": "Nested User"
+            },
+            "party": {
+              "displayName": "Party",
+              "valueType": "other",
+              "providerType": "Nested Party"
+            },
+            "stage": {
+              "displayName": "Stage",
+              "valueType": "other",
+              "providerType": "Nested Stage"
+            },
+            "status": {
+              "displayName": "Status",
+              "valueType": "singleSelect",
+              "providerType": "String",
+              "values": [
+                {
+                  "value": "OPEN",
+                  "displayValue": "OPEN"
+                },
+                {
+                  "value": "CLOSED",
+                  "displayValue": "CLOSED"
+                }
+              ]
+            },
+            "tags": {
+              "displayName": "Tags",
+              "valueType": "other",
+              "providerType": "Array"
+            },
+            "team": {
+              "displayName": "Team",
+              "valueType": "other",
+              "providerType": "Nested Team"
+            },
+            "updatedAt": {
+              "displayName": "Updated At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/project"
+        },
+        "resthooks": {
+          "displayName": "REST Hooks",
+          "path": "/resthooks",
+          "responseKey": "restHooks",
+          "fields": {
+            "clientId": {
+              "displayName": "Client Id",
+              "valueType": "int",
+              "providerType": "Integer",
+              "readOnly": true
+            },
+            "createdAt": {
+              "displayName": "Created At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "description": {
+              "displayName": "Description",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "embed": {
+              "displayName": "Embed",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "event": {
+              "displayName": "Event",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "targetUrl": {
+              "displayName": "Target Url",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "updatedAt": {
+              "displayName": "Updated At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "user": {
+              "displayName": "User",
+              "valueType": "other",
+              "providerType": "Nested User",
+              "readOnly": true
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/rest_hook"
+        },
+        "stages": {
+          "displayName": "Stages",
+          "path": "/stages",
+          "responseKey": "stages",
+          "fields": {
+            "board": {
+              "displayName": "Board",
+              "valueType": "other",
+              "providerType": "Nested Board"
+            },
+            "createdAt": {
+              "displayName": "Created At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "description": {
+              "displayName": "Description",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "displayOrder": {
+              "displayName": "Display Order",
+              "valueType": "int",
+              "providerType": "Integer"
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "updatedAt": {
+              "displayName": "Updated At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/stage"
+        },
+        "tasks": {
+          "displayName": "Tasks",
+          "path": "/tasks",
+          "responseKey": "tasks",
+          "fields": {
+            "category": {
+              "displayName": "Category",
+              "valueType": "other",
+              "providerType": "Category"
+            },
+            "completedAt": {
+              "displayName": "Completed At",
+              "valueType": "date",
+              "providerType": "Date"
+            },
+            "completedBy": {
+              "displayName": "Completed By",
+              "valueType": "string",
+              "providerType": "String",
+              "readOnly": true
+            },
+            "createdAt": {
+              "displayName": "Created At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "daysAfter": {
+              "displayName": "Days After",
+              "valueType": "int",
+              "providerType": "Integer"
+            },
+            "description": {
+              "displayName": "Description",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "detail": {
+              "displayName": "Detail",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "dueOn": {
+              "displayName": "Due On",
+              "valueType": "date",
+              "providerType": "Date"
+            },
+            "dueTime": {
+              "displayName": "Due Time",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "hasTrack": {
+              "displayName": "Has Track",
+              "valueType": "boolean",
+              "providerType": "Boolean"
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "kase": {
+              "displayName": "Kase",
+              "valueType": "other",
+              "providerType": "Nested Project"
+            },
+            "nextTask": {
+              "displayName": "Next Task",
+              "valueType": "other",
+              "providerType": "Nested Task"
+            },
+            "opportunity": {
+              "displayName": "Opportunity",
+              "valueType": "other",
+              "providerType": "Nested Opportunity"
+            },
+            "owner": {
+              "displayName": "Owner",
+              "valueType": "other",
+              "providerType": "Nested User"
+            },
+            "party": {
+              "displayName": "Party",
+              "valueType": "other",
+              "providerType": "Nested Party"
+            },
+            "repeat": {
+              "displayName": "Repeat",
+              "valueType": "other",
+              "providerType": "object"
+            },
+            "status": {
+              "displayName": "Status",
+              "valueType": "singleSelect",
+              "providerType": "String",
+              "values": [
+                {
+                  "value": "OPEN",
+                  "displayValue": "OPEN"
+                },
+                {
+                  "value": "COMPLETED",
+                  "displayValue": "COMPLETED"
+                },
+                {
+                  "value": "PENDING",
+                  "displayValue": "PENDING"
+                }
+              ]
+            },
+            "taskDayDelayRule": {
+              "displayName": "Task Day Delay Rule",
+              "valueType": "singleSelect",
+              "providerType": "String",
+              "values": [
+                {
+                  "value": "TRACK_DAYS",
+                  "displayValue": "TRACK_DAYS"
+                },
+                {
+                  "value": "TRACK_WORKDAYS",
+                  "displayValue": "TRACK_WORKDAYS"
+                },
+                {
+                  "value": "TRACK_WEEKS",
+                  "displayValue": "TRACK_WEEKS"
+                }
+              ]
+            },
+            "taskDelayRule": {
+              "displayName": "Task Delay Rule",
+              "valueType": "singleSelect",
+              "providerType": "String",
+              "values": [
+                {
+                  "value": "TRACK_START",
+                  "displayValue": "TRACK_START"
+                },
+                {
+                  "value": "END_DATE",
+                  "displayValue": "END_DATE"
+                },
+                {
+                  "value": "LAST_TASK",
+                  "displayValue": "LAST_TASK"
+                }
+              ]
+            },
+            "updatedAt": {
+              "displayName": "Updated At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/task"
+        },
+        "teams": {
+          "displayName": "Teams",
+          "path": "/teams",
+          "responseKey": "teams",
+          "fields": {
+            "createdAt": {
+              "displayName": "Created At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String",
+              "readOnly": true
+            },
+            "updatedAt": {
+              "displayName": "Updated At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/team"
+        },
+        "titles": {
+          "displayName": "Titles",
+          "path": "/titles",
+          "responseKey": "personTitles",
+          "fields": {
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "title": {
+              "displayName": "Title",
+              "valueType": "string",
+              "providerType": "String"
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/title"
+        },
+        "trackdefinitions": {
+          "displayName": "Track Definitions",
+          "path": "/trackdefinitions",
+          "responseKey": "trackDefinitions",
+          "fields": {
+            "captureRule": {
+              "displayName": "Capture Rule",
+              "valueType": "singleSelect",
+              "providerType": "String",
+              "values": [
+                {
+                  "value": "CASEFILE",
+                  "displayValue": "CASEFILE"
+                },
+                {
+                  "value": "OPPORTUNITY",
+                  "displayValue": "OPPORTUNITY"
+                }
+              ]
+            },
+            "createdAt": {
+              "displayName": "Created At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            },
+            "description": {
+              "displayName": "Description",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "direction": {
+              "displayName": "Direction",
+              "valueType": "singleSelect",
+              "providerType": "String",
+              "values": [
+                {
+                  "value": "START_DATE",
+                  "displayValue": "START_DATE"
+                },
+                {
+                  "value": "END_DATE",
+                  "displayValue": "END_DATE"
+                }
+              ]
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "tag": {
+              "displayName": "Tag",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "taskDefinitions": {
+              "displayName": "Task Definitions",
+              "valueType": "other",
+              "providerType": "Array"
+            },
+            "updatedAt": {
+              "displayName": "Updated At",
+              "valueType": "date",
+              "providerType": "Date",
+              "readOnly": true
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/track_definition"
+        },
+        "users": {
+          "displayName": "Users",
+          "path": "/users",
+          "responseKey": "users",
+          "fields": {
+            "clickToCallPreference": {
+              "displayName": "Click To Call Preference",
+              "valueType": "singleSelect",
+              "providerType": "String",
+              "values": [
+                {
+                  "value": "SIP",
+                  "displayValue": "SIP"
+                },
+                {
+                  "value": "TEL",
+                  "displayValue": "TEL"
+                },
+                {
+                  "value": "CALLTO",
+                  "displayValue": "CALLTO"
+                },
+                {
+                  "value": "CIRCLELOOP",
+                  "displayValue": "CIRCLELOOP"
+                }
+              ]
+            },
+            "currency": {
+              "displayName": "Currency",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "emailPreference": {
+              "displayName": "Email Preference",
+              "valueType": "singleSelect",
+              "providerType": "String",
+              "values": [
+                {
+                  "value": "GMAIL",
+                  "displayValue": "GMAIL"
+                }
+              ]
+            },
+            "id": {
+              "displayName": "Id",
+              "valueType": "int",
+              "providerType": "Long",
+              "readOnly": true
+            },
+            "locale": {
+              "displayName": "Locale",
+              "valueType": "singleSelect",
+              "providerType": "String",
+              "values": [
+                {
+                  "value": "en_US",
+                  "displayValue": "en_US"
+                },
+                {
+                  "value": "en_GB",
+                  "displayValue": "en_GB"
+                },
+                {
+                  "value": "es_ES",
+                  "displayValue": "es_ES"
+                }
+              ]
+            },
+            "name": {
+              "displayName": "Name",
+              "valueType": "string",
+              "providerType": "String",
+              "readOnly": true
+            },
+            "party": {
+              "displayName": "Party",
+              "valueType": "other",
+              "providerType": "Nested Party"
+            },
+            "preferredEmailFromAddress": {
+              "displayName": "Preferred Email From Address",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "taskReminder": {
+              "displayName": "Task Reminder",
+              "valueType": "boolean",
+              "providerType": "Boolean"
+            },
+            "timezone": {
+              "displayName": "Timezone",
+              "valueType": "string",
+              "providerType": "String"
+            },
+            "username": {
+              "displayName": "Username",
+              "valueType": "string",
+              "providerType": "String"
+            }
+          },
+          "docs": "https://developer.capsulecrm.com/v2/models/user"
+        }
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/clickup/connector.go b/providers/clickup/connector.go
new file mode 100644
index 00000000..775e8b3a
--- /dev/null
+++ b/providers/clickup/connector.go
@@ -0,0 +1,54 @@
+package clickup
+
+import (
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/components/operations"
+	"github.com/amp-labs/connectors/internal/components/reader"
+	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/clickup/metadata"
+)
+
+type Connector struct {
+	// Basic connector
+	*components.Connector
+
+	// Require authenticated client
+	common.RequireAuthenticatedClient
+
+	// supported operations
+	components.SchemaProvider
+	components.Reader
+}
+
+func NewConnector(params common.Parameters) (*Connector, error) {
+	// Create base connector with provider info
+	return components.Initialize(providers.ClickUp, params, constructor)
+}
+
+//nolint:funlen
+func constructor(base *components.Connector) (*Connector, error) {
+	connector := &Connector{Connector: base}
+
+	// Set the metadata provider for the connector
+	connector.SchemaProvider = schema.NewOpenAPISchemaProvider(connector.ProviderContext.Module(), metadata.Schemas)
+
+	registry, err := components.NewEndpointRegistry(supportedOperations())
+	if err != nil {
+		return nil, err
+	}
+
+	connector.Reader = reader.NewHTTPReader(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.ReadHandlers{
+			BuildRequest:  connector.buildReadRequest,
+			ParseResponse: connector.parseReadResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
+	return connector, nil
+}
diff --git a/providers/clickup/handlers.go b/providers/clickup/handlers.go
new file mode 100644
index 00000000..da7a85f9
--- /dev/null
+++ b/providers/clickup/handlers.go
@@ -0,0 +1,51 @@
+package clickup
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/providers/clickup/metadata"
+)
+
+var apiVersion = "api/v2" //nolint:gochecknoglobals
+
+func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadParams) (*http.Request, error) {
+	var (
+		url *urlbuilder.URL
+		err error
+	)
+
+	if params.NextPage != "" {
+		return http.NewRequestWithContext(ctx, http.MethodGet, params.NextPage.String(), nil)
+	}
+
+	// For first page, construct the URL
+	url, err = urlbuilder.New(c.ProviderInfo().BaseURL, apiVersion, params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	url.WithQueryParam("page", "0")
+
+	return http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
+}
+
+func (c *Connector) parseReadResponse(
+	ctx context.Context,
+	params common.ReadParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.ReadResult, error) {
+	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module(), params.ObjectName)
+	requestURL := request.URL
+
+	return common.ParseResult(
+		response,
+		common.GetRecordsUnderJSONPath(responseFieldName),
+		nextRecordsURL(requestURL),
+		common.GetMarshaledData,
+		params.Fields,
+	)
+}
diff --git a/providers/clickup/metadata/schemas.json b/providers/clickup/metadata/schemas.json
new file mode 100644
index 00000000..19248dcd
--- /dev/null
+++ b/providers/clickup/metadata/schemas.json
@@ -0,0 +1,22 @@
+{
+  "modules": {
+    "root": {
+      "id": "",
+      "path": "",
+      "objects": {
+        "team": {
+          "displayName": "Team",
+          "path": "team",
+          "responseKey": "teams",
+          "fields": {
+            "id": "id",
+            "name": "name",
+            "color": "color",
+            "avatar": "avatar",
+            "members": "members"
+          }
+        }
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/clickup/metadata/scraping.go b/providers/clickup/metadata/scraping.go
new file mode 100644
index 00000000..86cebe92
--- /dev/null
+++ b/providers/clickup/metadata/scraping.go
@@ -0,0 +1,19 @@
+package metadata
+
+import (
+	_ "embed"
+
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/tools/fileconv"
+	"github.com/amp-labs/connectors/tools/scrapper"
+)
+
+// nolint:gochecknoglobals
+var (
+	//go:embed schemas.json
+	schemaContent []byte
+	FileManager   = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1](
+		schemaContent, fileconv.NewSiblingFileLocator())
+
+	Schemas = FileManager.MustLoadSchemas()
+)
diff --git a/providers/clickup/parse.go b/providers/clickup/parse.go
new file mode 100644
index 00000000..4ce75d98
--- /dev/null
+++ b/providers/clickup/parse.go
@@ -0,0 +1,44 @@
+package clickup
+
+import (
+	"net/url"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/spyzhov/ajson"
+)
+
+func nextRecordsURL(previousURL *url.URL) common.NextPageFunc {
+	return func(node *ajson.Node) (string, error) {
+		lastPage, err := jsonquery.New(node).BoolOptional("last_page")
+		if err != nil {
+			return "", err
+		}
+
+		if lastPage == nil || *lastPage {
+			return "", nil
+		}
+
+		nextURL := *previousURL
+
+		// parse the current page value form previous request url
+		query := nextURL.Query()
+		currentPage := 0
+		currentPageStr := query.Get("page")
+
+		if currentPageStr != "" {
+			currentPage, err = strconv.Atoi(currentPageStr)
+			if err != nil {
+				return "", nil //nolint:nilerr
+			}
+		}
+
+		nextPage := currentPage + 1
+
+		query.Set("page", strconv.Itoa(nextPage))
+		nextURL.RawQuery = query.Encode()
+
+		return nextURL.String(), nil
+	}
+}
diff --git a/providers/clickup/supports.go b/providers/clickup/supports.go
new file mode 100644
index 00000000..06007930
--- /dev/null
+++ b/providers/clickup/supports.go
@@ -0,0 +1,24 @@
+package clickup
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/providers/clickup/metadata"
+)
+
+func supportedOperations() components.EndpointRegistryInput {
+	// We support reading everything under schema.json, so we get all the objects and join it into a pattern.
+	readSupport := metadata.Schemas.ObjectNames().GetList(staticschema.RootModuleID)
+
+	return components.EndpointRegistryInput{
+		staticschema.RootModuleID: {
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(readSupport, ",")),
+				Support:  components.ReadSupport,
+			},
+		},
+	}
+}
diff --git a/scripts/scraper/capsule/metadata/main.go b/scripts/scraper/capsule/metadata/main.go
new file mode 100644
index 00000000..3d5c17f6
--- /dev/null
+++ b/scripts/scraper/capsule/metadata/main.go
@@ -0,0 +1,269 @@
+// nolint:forbidigo
+package main
+
+import (
+	"fmt"
+	"log"
+	"log/slog"
+	"regexp"
+	"strings"
+
+	"github.com/PuerkitoBio/goquery"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/naming"
+	"github.com/amp-labs/connectors/internal/datautils"
+	"github.com/amp-labs/connectors/internal/goutils"
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/providers/capsule/metadata"
+	"github.com/amp-labs/connectors/tools/fileconv/api3"
+	"github.com/amp-labs/connectors/tools/scrapper"
+)
+
+const (
+	Arrow = "→"
+	// CapsuleDocsPrefixURL documentation root.
+	CapsuleDocsPrefixURL = "https://developer.capsulecrm.com"
+	// ModelIndexURL - on this page all links to dedicated Models can be found.
+	ModelIndexURL = "https://developer.capsulecrm.com/v2/models/party"
+	ModelsPrefix  = "/v2/models/"
+)
+
+var (
+	ignoreModels = datautils.NewSet( // nolint:gochecknoglobals
+		"entry",
+		"filter",
+		"track",
+		"tag",
+		"field_definition", // custom fields
+		"attachment",
+		"currency",
+		"site",
+		"tag_definition",
+	)
+	modelNameToObjectName = datautils.NewDefaultMap(map[string]string{ // nolint:gochecknoglobals
+		"activity_types":    "activitytypes",
+		"lost_reasons":      "lostreasons",
+		"rest_hooks":        "resthooks",
+		"track_definitions": "trackdefinitions",
+	}, func(objectName string) string {
+		return objectName
+	})
+	displayNameMapping = datautils.NewDefaultMap(map[string]string{ // nolint:gochecknoglobals
+		"activitytypes":    "Activity Types",
+		"lostreasons":      "Lost Reasons",
+		"resthooks":        "REST Hooks",
+		"trackdefinitions": "Track Definitions",
+	}, formatDisplay)
+	objectNameToURLPath = datautils.NewDefaultMap(map[string]string{ // nolint:gochecknoglobals
+		"parties":          "/v2/parties",
+		"opportunities":    "/v2/opportunities",
+		"projects":         "/v2/kases",
+		"tasks":            "/v2/tasks",
+		"users":            "/v2/users",
+		"teams":            "/v2/teams",
+		"pipelines":        "/v2/pipelines",
+		"milestones":       "/v2/milestones",
+		"lostreasons":      "/v2/lostreasons",
+		"boards":           "/v2/boards",
+		"stages":           "/v2/stages",
+		"resthooks":        "/v2/resthooks",
+		"trackdefinitions": "/v2/trackdefinitions",
+		"categories":       "/v2/categories",
+		"activitytypes":    "/v2/activitytypes",
+		"countries":        "/v2/countries",
+		"titles":           "/v2/titles",
+	}, func(objectName string) string {
+		fmt.Println("No matching URL path for object", objectName)
+
+		return objectName
+	})
+	objectNameToResponseKey = datautils.NewDefaultMap(map[string]string{ // nolint:gochecknoglobals
+		"parties":          "parties",
+		"opportunities":    "opportunities",
+		"projects":         "kases",
+		"tasks":            "tasks",
+		"users":            "users",
+		"teams":            "teams",
+		"pipelines":        "pipelines",
+		"milestones":       "milestones",
+		"lostreasons":      "lostReasons",
+		"boards":           "boards",
+		"stages":           "stages",
+		"resthooks":        "restHooks",
+		"trackdefinitions": "trackDefinitions",
+		"categories":       "categories",
+		"activitytypes":    "activityTypes",
+		"countries":        "countries",
+		"titles":           "personTitles",
+	}, func(objectName string) string {
+		fmt.Println("No matching response key for object", objectName)
+
+		return objectName
+	})
+)
+
+func main() {
+	createIndex()
+	createSchemas()
+
+	slog.Info("Completed.")
+}
+
+func createIndex() {
+	tabs := getTabLinks()
+	registry := scrapper.NewModelURLRegistry()
+
+	for _, tab := range tabs {
+		name, _ := strings.CutPrefix(tab, ModelsPrefix)
+		if !ignoreModels.Has(name) {
+			name = naming.NewPluralString(name).String()
+			name = modelNameToObjectName.Get(name)
+			registry.AddModelByName(name, CapsuleDocsPrefixURL+tab)
+		}
+	}
+
+	goutils.MustBeNil(metadata.FileManager.SaveIndex(registry))
+}
+
+func createSchemas() {
+	index, err := metadata.FileManager.LoadIndex()
+	goutils.MustBeNil(err)
+
+	schemas := newScrappedSchemas()
+
+	for i := range index.ModelDocs { // nolint:varnamelen
+		model := index.ModelDocs[i]
+		doc := scrapper.QueryHTML(model.URL)
+
+		doc.Find(`.object-table tbody tr`).
+			Each(func(_ int, tableRow *goquery.Selection) {
+				var (
+					fieldName   string
+					fieldType   string
+					specialTag  string
+					description string
+				)
+
+				// Table has 4 columns.
+				// Ex: https://developer.capsulecrm.com/v2/models/user.
+				tableRow.Find(`td`).Each(func(columnIndex int, cell *goquery.Selection) {
+					text := cleanText(cell.Text())
+					// nolint:mnd
+					switch columnIndex {
+					case 0:
+						fieldName = text
+					case 1:
+						fieldType = text
+					case 2:
+						specialTag = text
+					case 3:
+						description = text
+					}
+				})
+
+				if len(fieldName) == 0 {
+					return
+				}
+
+				if strings.Contains(specialTag, "Write only") {
+					// This field cannot be returned by ListObjectMetadata
+					// Omitting field.
+					return
+				}
+
+				schemas.SaveData(model, fieldName, fieldType, specialTag, description)
+			})
+
+		log.Printf("Schemas completed %.2f%% [%v]\n", getPercentage(i, len(index.ModelDocs)), model.Name)
+	}
+
+	goutils.MustBeNil(metadata.FileManager.SaveSchemas(schemas.Metadata))
+}
+
+func getTabLinks() []string {
+	doc := scrapper.QueryHTML(ModelIndexURL)
+
+	links := make([]string, 0)
+
+	doc.Find(".sidebar-section__title").Each(func(i int, s *goquery.Selection) {
+		cell := s.Find("a")
+		link, _ := cell.Attr("href")
+
+		if strings.HasPrefix(link, ModelsPrefix) {
+			links = append(links, link)
+		}
+	})
+
+	return links
+}
+
+func cleanText(text string) string {
+	re := regexp.MustCompile(`^\s+|\s+$`)
+	// Replace matches with an empty string
+	return re.ReplaceAllString(text, "")
+}
+
+func implyValueOptions(description string) []string {
+	re := regexp.MustCompile(`Accepted values are: ([\w, _]+)`)
+	match := re.FindStringSubmatch(description)
+
+	if len(match) == 0 {
+		return nil
+	}
+
+	return regexp.MustCompile(`\s*,\s*`).Split(match[1], -1)
+}
+
+func formatFieldType(fieldType string) string {
+	if strings.HasPrefix(fieldType, "Array") {
+		return "Array"
+	}
+
+	return fieldType
+}
+
+func getPercentage(i int, i2 int) float64 {
+	return (float64(i+1) / float64(i2)) * 100 // nolint:gomnd,mnd
+}
+
+func formatDisplay(name string) string {
+	name = api3.CamelCaseToSpaceSeparated(name)
+
+	return api3.CapitalizeFirstLetterEveryWord(name)
+}
+
+func getFieldValueType(fieldType string, fieldOptions []string) common.ValueType {
+	switch strings.ToLower(fieldType) {
+	case "integer", "long":
+		return common.ValueTypeInt
+	case "boolean":
+		return common.ValueTypeBoolean
+	case "date":
+		return common.ValueTypeDate
+	case "string":
+		if len(fieldOptions) != 0 {
+			return common.ValueTypeSingleSelect
+		}
+
+		return common.ValueTypeString
+	default:
+		// object, array
+		return common.ValueTypeOther
+	}
+}
+
+func getFieldValueOptions(fieldValueOptions []string) staticschema.FieldValues {
+	if len(fieldValueOptions) == 0 {
+		return nil
+	}
+
+	values := make(staticschema.FieldValues, len(fieldValueOptions))
+	for index, option := range fieldValueOptions {
+		values[index] = staticschema.FieldValue{
+			Value:        option,
+			DisplayValue: option,
+		}
+	}
+
+	return values
+}
diff --git a/scripts/scraper/capsule/metadata/schemas.go b/scripts/scraper/capsule/metadata/schemas.go
new file mode 100644
index 00000000..56009825
--- /dev/null
+++ b/scripts/scraper/capsule/metadata/schemas.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+	"strings"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/tools/scrapper"
+)
+
+type scrappedSchemas struct {
+	Metadata *staticschema.Metadata[staticschema.FieldMetadataMapV2, any]
+}
+
+func newScrappedSchemas() scrappedSchemas {
+	return scrappedSchemas{
+		Metadata: staticschema.NewMetadata[staticschema.FieldMetadataMapV2](),
+	}
+}
+
+func (s scrappedSchemas) SaveData(model scrapper.ModelDocLink, fieldName, fieldType, specialTag, description string) {
+	modelDisplayName := displayNameMapping.Get(model.Name)
+	fieldDisplayName := formatDisplay(fieldName)
+
+	isReadOnly := strings.Contains(specialTag, "Read only")
+	fieldValueOptions := filterOutExceptions(implyValueOptions(description))
+	fieldType = formatFieldType(fieldType)
+
+	var fields staticschema.FieldMetadataMapV2
+
+	if strings.Contains(fieldName, Arrow) {
+		// Special case. We do not care about nested fields.
+		// The parent node should be saved as an object.
+		// Example:
+		// https://developer.capsulecrm.com/v2/models/task
+		fieldName = strings.Split(fieldName, Arrow)[0]
+
+		fields = staticschema.FieldMetadataMapV2{
+			fieldName: staticschema.FieldMetadata{
+				DisplayName:  formatDisplay(fieldName),
+				ValueType:    common.ValueTypeOther,
+				ProviderType: "object",
+			},
+		}
+	} else {
+		fields = staticschema.FieldMetadataMapV2{
+			fieldName: staticschema.FieldMetadata{
+				DisplayName:  fieldDisplayName,
+				ValueType:    getFieldValueType(fieldType, fieldValueOptions),
+				ProviderType: fieldType,
+				ReadOnly:     isReadOnly,
+				Values:       getFieldValueOptions(fieldValueOptions),
+			},
+		}
+	}
+
+	responseKey := objectNameToResponseKey.Get(model.Name)
+	urlPath := objectNameToURLPath.Get(model.Name)
+
+	s.Metadata.Add(
+		staticschema.RootModuleID, model.Name, modelDisplayName,
+		urlPath, responseKey, fields, &model.URL, nil)
+}
+
+func filterOutExceptions(fieldValueOptions []string) []string {
+	// Title is an exception which we shouldn't imply value options.
+	// https://developer.capsulecrm.com/v2/models/party.
+	// The title options should be coming from the CustomTitle object.
+	// https://developer.capsulecrm.com/v2/operations/Custom_Title
+	if len(fieldValueOptions) == 1 && fieldValueOptions[0] == "existing custom titles" {
+		fieldValueOptions = nil
+	}
+
+	return fieldValueOptions
+}
diff --git a/scripts/scrapper/salesloft/metadata/main.go b/scripts/scraper/salesloft/metadata/main.go
similarity index 93%
rename from scripts/scrapper/salesloft/metadata/main.go
rename to scripts/scraper/salesloft/metadata/main.go
index e217af2b..86356a49 100644
--- a/scripts/scrapper/salesloft/metadata/main.go
+++ b/scripts/scraper/salesloft/metadata/main.go
@@ -51,7 +51,7 @@ func createIndex() {
 	registry := scrapper.NewModelURLRegistry()
 
 	for i, section := range sections {
-		doc := scrapper.QueryHTML(SalesloftDocsPrefixURL + "/" + section)
+		doc := queryHTML(SalesloftDocsPrefixURL + "/" + section)
 
 		doc.Find(".theme-doc-markdown article").Each(func(i int, s *goquery.Selection) {
 			cell := s.Find("a")
@@ -74,7 +74,7 @@ func createSchemas() {
 	filteredListDocs := getFilteredListDocs(index)
 	for i := range filteredListDocs { // nolint:varnamelen
 		model := filteredListDocs[i]
-		doc := scrapper.QueryHTML(model.URL)
+		doc := queryHTML(model.URL)
 
 		// There are 2 unordered lists that describe response schema
 		modelName := strcase.ToSnake(model.Name)
@@ -113,7 +113,7 @@ func createQueryParamStats() {
 	numObjects := len(filteredListDocs)
 
 	for i, model := range filteredListDocs { // nolint:varnamelen
-		doc := scrapper.QueryHTML(model.URL)
+		doc := queryHTML(model.URL)
 
 		modelName := strcase.ToSnake(model.Name)
 
@@ -157,7 +157,7 @@ func getFilteredListDocs(index *scrapper.ModelURLRegistry) scrapper.ModelDocLink
 }
 
 func getSectionLinks() []string {
-	doc := scrapper.QueryHTML(ModelIndexURL)
+	doc := queryHTML(ModelIndexURL)
 
 	links := make([]string, 0)
 
@@ -199,3 +199,12 @@ func handleDisplayName(name string) (displayName string, isListResource bool) {
 
 	return name, true
 }
+
+func queryHTML(sourceURL string) *goquery.Document {
+	// must end with `/` to avoid stupid redirect with then without then again with and without slash
+	if !strings.HasSuffix(sourceURL, "/") {
+		sourceURL += "/"
+	}
+
+	return scrapper.QueryHTML(sourceURL)
+}
diff --git a/tools/scrapper/models.go b/tools/scrapper/models.go
index e27da0eb..565ccfae 100644
--- a/tools/scrapper/models.go
+++ b/tools/scrapper/models.go
@@ -56,6 +56,16 @@ func (r *ModelURLRegistry) Add(displayName, url string) {
 	})
 }
 
+func (r *ModelURLRegistry) AddModelByName(name, url string) {
+	url, _ = strings.CutSuffix(url, "/")
+
+	r.ModelDocs = append(r.ModelDocs, ModelDocLink{
+		DisplayName: name,
+		Name:        name,
+		URL:         url,
+	})
+}
+
 func (r *ModelURLRegistry) Sort() {
 	sort.Slice(r.ModelDocs, func(i, j int) bool {
 		return r.ModelDocs[i].Name < r.ModelDocs[j].Name
diff --git a/tools/scrapper/utils.go b/tools/scrapper/utils.go
index 06582d29..1b8bb4ad 100644
--- a/tools/scrapper/utils.go
+++ b/tools/scrapper/utils.go
@@ -7,7 +7,6 @@ import (
 	"log"
 	"net/http"
 	"os"
-	"strings"
 
 	"github.com/PuerkitoBio/goquery"
 )
@@ -37,11 +36,6 @@ func QueryHTML(url string) *goquery.Document { //nolint:gocritic
 }
 
 func makeRequest(sourceURL string) (*http.Response, error) {
-	// must end with `/` to avoid stupid redirect with then without then again with and without slash
-	if !strings.HasSuffix(sourceURL, "/") {
-		sourceURL += "/"
-	}
-
 	client := &http.Client{
 		CheckRedirect: func() func(req *http.Request, via []*http.Request) error {
 			redirects := 0


Test patch:
diff --git a/providers/capsule/metadata_test.go b/providers/capsule/metadata_test.go
new file mode 100644
index 00000000..da1f2f08
--- /dev/null
+++ b/providers/capsule/metadata_test.go
@@ -0,0 +1,95 @@
+package capsule
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+)
+
+func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	tests := []testroutines.Metadata{
+		{
+			Name:       "Successful metadata for multiple objects",
+			Input:      []string{"activitytypes", "projects"},
+			Server:     mockserver.Dummy(),
+			Comparator: testroutines.ComparatorSubsetMetadata,
+			Expected: &common.ListObjectMetadataResult{
+				Result: map[string]common.ObjectMetadata{
+					"activitytypes": {
+						DisplayName: "Activity Types",
+						Fields: map[string]common.FieldMetadata{
+							"id": {
+								DisplayName:  "Id",
+								ValueType:    "int",
+								ProviderType: "Long",
+								ReadOnly:     true,
+							},
+							"updateLastContacted": {
+								DisplayName:  "Update Last Contacted",
+								ValueType:    "boolean",
+								ProviderType: "Boolean",
+							},
+						},
+					},
+					"projects": {
+						DisplayName: "Projects",
+						Fields: map[string]common.FieldMetadata{
+							"createdAt": {
+								DisplayName:  "Created At",
+								ValueType:    "date",
+								ProviderType: "Date",
+								ReadOnly:     true,
+							},
+							"status": {
+								DisplayName:  "Status",
+								ValueType:    "singleSelect",
+								ProviderType: "String",
+								Values: common.FieldValues{{
+									Value:        "OPEN",
+									DisplayValue: "OPEN",
+								}, {
+									Value:        "CLOSED",
+									DisplayValue: "CLOSED",
+								}},
+							},
+						},
+					},
+				},
+				Errors: map[string]error{},
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.ObjectMetadataConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
+
+func constructTestConnector(serverURL string) (*Connector, error) {
+	connector, err := NewConnector(common.Parameters{
+		Module:              staticschema.RootModuleID,
+		AuthenticatedClient: http.DefaultClient,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	connector.SetBaseURL(serverURL)
+
+	return connector, nil
+}
diff --git a/test/ashby/read/main.go b/test/ashby/read/main.go
new file mode 100644
index 00000000..ba26ffa4
--- /dev/null
+++ b/test/ashby/read/main.go
@@ -0,0 +1,54 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	connTest "github.com/amp-labs/connectors/test/ashby"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetAshbyConnector(ctx)
+
+	sinceTime := time.Date(2024, 12, 2, 6, 14, 0, 0, time.UTC)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "application.list",
+		Fields:     connectors.Fields("id", "createdAt", "archivedAt", "source", "status"),
+		Since:      sinceTime,
+	})
+	if err != nil {
+		utils.Fail("error reading from Ashby", "error", err)
+	}
+
+	slog.Info("Reading application list..")
+	utils.DumpJSON(res, os.Stdout)
+
+	res, err = conn.Read(ctx, common.ReadParams{
+		ObjectName: "user.list",
+		Fields:     connectors.Fields("id", "firstName", "lastName", "email"),
+		Since:      sinceTime,
+		NextPage:   res.NextPage,
+	})
+	if err != nil {
+		utils.Fail("error reading from Ashby", "error", err)
+	}
+
+	slog.Info("Reading user list")
+	utils.DumpJSON(res, os.Stdout)
+
+}
diff --git a/test/ashby/write/main.go b/test/ashby/write/main.go
new file mode 100644
index 00000000..8be940ee
--- /dev/null
+++ b/test/ashby/write/main.go
@@ -0,0 +1,90 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers/ashby"
+	hs "github.com/amp-labs/connectors/test/ashby"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := hs.GetAshbyConnector(ctx)
+
+	slog.Info("> TEST Create Application")
+
+	if err := createApplication(ctx, conn); err != nil {
+		slog.Error(err.Error())
+	}
+
+	slog.Info("> TEST Create Candidate Note")
+	if err := createCandidateNote(ctx, conn); err != nil {
+		slog.Error(err.Error())
+	}
+	slog.Info("Done")
+
+}
+
+func createApplication(ctx context.Context, conn *ashby.Connector) error {
+	config := common.WriteParams{
+		ObjectName: "application.create",
+		RecordData: map[string]any{
+			"candidateId": "bd7229a1-be3e-4e30-a538-0b95a41602d7",
+			"jobId":       "783338ea-e6ac-406a-853b-964fa75a5d62",
+		},
+	}
+
+	result, err := conn.Write(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	fmt.Println(string(jsonStr))
+
+	return nil
+}
+
+func createCandidateNote(ctx context.Context, conn *ashby.Connector) error {
+	config := common.WriteParams{
+		ObjectName: "candidate.createNote",
+		RecordData: map[string]any{
+			"candidateId":       "bd7229a1-be3e-4e30-a538-0b95a41602d7",
+			"sendNotifications": false,
+			"note":              "this is a not what else ",
+		},
+	}
+
+	result, err := conn.Write(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	fmt.Println(string(jsonStr))
+
+	return nil
+}
diff --git a/test/brevo/write/main.go b/test/brevo/write/main.go
new file mode 100644
index 00000000..d8356908
--- /dev/null
+++ b/test/brevo/write/main.go
@@ -0,0 +1,127 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers/brevo"
+	hs "github.com/amp-labs/connectors/test/brevo"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := hs.GetBrevoConnector(ctx)
+
+	if err := createSmtpEmail(ctx, conn); err != nil {
+		slog.Error(err.Error())
+	}
+
+	if err := createSmtpTemplates(ctx, conn); err != nil {
+		slog.Error(err.Error())
+	}
+
+	if err := AddBlockedDomains(ctx, conn); err != nil {
+		slog.Error(err.Error())
+	}
+}
+
+func createSmtpEmail(ctx context.Context, conn *brevo.Connector) error {
+	config := common.WriteParams{
+		ObjectName: "smtp/email",
+		RecordData: map[string]any{
+			"subject":     "Login Email confirmation",
+			"htmlContent": "<!DOCTYPE html> <html> <body> <h1>This is sample HTML</h1> </html>",
+			"sender": map[string]string{
+				"name":  "Mary from MyShop",
+				"email": "no-reply@myshop.com",
+			},
+			"to": []map[string]any{
+				{
+					"email": "dipu@withampersand.com",
+					"name":  "this is a test lol",
+				},
+			},
+		},
+	}
+
+	result, err := conn.Write(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	fmt.Println(string(jsonStr))
+
+	return nil
+}
+
+func createSmtpTemplates(ctx context.Context, conn *brevo.Connector) error {
+	config := common.WriteParams{
+		ObjectName: "smtp/templates",
+		RecordData: map[string]any{
+			"sender": map[string]string{
+				"name":  "This is test",
+				"email": "integration.user+Brevo@withampersand.com",
+			},
+			"subject":      "Thanks for your purchase !",
+			"templateName": "Order Confirmation - EN",
+			"htmlContent":  "The order n°xxxxx has been confirmed. Thanks for your purchase",
+		},
+	}
+
+	result, err := conn.Write(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	fmt.Println(string(jsonStr))
+
+	return nil
+}
+
+func AddBlockedDomains(ctx context.Context, conn *brevo.Connector) error {
+	config := common.WriteParams{
+		ObjectName: "smtp/blockedDomains",
+		RecordData: map[string]any{
+			"domain": "chaurfdfdasdfdiy.com",
+		},
+	}
+
+	result, err := conn.Write(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	fmt.Println(string(jsonStr))
+
+	return nil
+}
diff --git a/test/capsule/connector.go b/test/capsule/connector.go
new file mode 100644
index 00000000..53ee6abb
--- /dev/null
+++ b/test/capsule/connector.go
@@ -0,0 +1,44 @@
+package smartlead
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/scanning/credscanning"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/capsule"
+	"github.com/amp-labs/connectors/test/utils"
+	"golang.org/x/oauth2"
+)
+
+func GetCapsuleConnector(ctx context.Context) *capsule.Connector {
+	filePath := credscanning.LoadPath(providers.Capsule)
+	reader := utils.MustCreateProvCredJSON(filePath, false, false)
+
+	conn, err := capsule.NewConnector(
+		common.Parameters{
+			AuthenticatedClient: utils.NewOauth2Client(ctx, reader, getConfig),
+		},
+	)
+	if err != nil {
+		utils.Fail("error creating connector", "error", err)
+	}
+
+	return conn
+}
+
+func getConfig(reader *credscanning.ProviderCredentials) *oauth2.Config {
+	return &oauth2.Config{
+		ClientID:     reader.Get(credscanning.Fields.ClientId),
+		ClientSecret: reader.Get(credscanning.Fields.ClientSecret),
+		RedirectURL:  "http://localhost:8080/callbacks/v1/oauth",
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   "https://api.capsulecrm.com/oauth/authorise",
+			TokenURL:  "https://api.capsulecrm.com/oauth/token",
+			AuthStyle: oauth2.AuthStyleAutoDetect,
+		},
+		Scopes: []string{
+			"read", "write",
+		},
+	}
+}
diff --git a/test/capsule/metadata/main.go b/test/capsule/metadata/main.go
new file mode 100644
index 00000000..4d0ed614
--- /dev/null
+++ b/test/capsule/metadata/main.go
@@ -0,0 +1,35 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/signal"
+	"syscall"
+
+	connTest "github.com/amp-labs/connectors/test/capsule"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+var objectName = "parties"
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetCapsuleConnector(ctx)
+
+	metadata, err := conn.ListObjectMetadata(ctx, []string{
+		objectName,
+	})
+	if err != nil {
+		utils.Fail("error listing metadata", "error", err)
+	}
+
+	fmt.Println("Metadata...")
+	utils.DumpJSON(metadata, os.Stdout)
+}
diff --git a/test/clickup/connector.go b/test/clickup/connector.go
new file mode 100644
index 00000000..87e4603b
--- /dev/null
+++ b/test/clickup/connector.go
@@ -0,0 +1,56 @@
+package clickup
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/scanning/credscanning"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/clickup"
+	"github.com/amp-labs/connectors/test/utils"
+	"golang.org/x/oauth2"
+)
+
+func GetClickupConnector(ctx context.Context) *clickup.Connector {
+	filePath := credscanning.LoadPath(providers.ClickUp)
+	reader := utils.MustCreateProvCredJSON(filePath, true, false)
+
+	options := []common.OAuthOption{
+		common.WithOAuthClient(http.DefaultClient),
+		common.WithOAuthConfig(getConfig(reader)),
+		common.WithOAuthToken(&oauth2.Token{
+			AccessToken:  reader.Get(credscanning.Fields.AccessToken),
+			RefreshToken: reader.Get(credscanning.Fields.RefreshToken),
+		}),
+	}
+
+	client, err := common.NewOAuthHTTPClient(ctx, options...)
+	if err != nil {
+		utils.Fail(err.Error())
+	}
+
+	conn, err := clickup.NewConnector(
+		common.Parameters{
+			AuthenticatedClient: client,
+		},
+	)
+	if err != nil {
+		utils.Fail("error creating clickup connector", "error", err)
+	}
+
+	return conn
+}
+
+func getConfig(reader *credscanning.ProviderCredentials) *oauth2.Config {
+	return &oauth2.Config{
+		ClientID:     reader.Get(credscanning.Fields.ClientId),
+		ClientSecret: reader.Get(credscanning.Fields.ClientSecret),
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   "https://app.clickup.com/api",
+			TokenURL:  "https://api.clickup.com/api/v2/oauth/token",
+			AuthStyle: oauth2.AuthStyleInHeader,
+		},
+		RedirectURL: "http://localhost:8080/callbacks/v1/oauth",
+	}
+}
diff --git a/test/clickup/metadata/main.go b/test/clickup/metadata/main.go
new file mode 100644
index 00000000..55390dce
--- /dev/null
+++ b/test/clickup/metadata/main.go
@@ -0,0 +1,25 @@
+package main
+
+import (
+	"context"
+	"log"
+	"os"
+
+	"github.com/amp-labs/connectors/test/clickup"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	ctx := context.Background()
+
+	conn := clickup.GetClickupConnector(ctx) // nolint
+
+	// nolint
+	m, err := conn.ListObjectMetadata(ctx, []string{"team"})
+
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	utils.DumpJSON(m, os.Stdout)
+}
diff --git a/test/clickup/read/main.go b/test/clickup/read/main.go
new file mode 100644
index 00000000..48cfec3a
--- /dev/null
+++ b/test/clickup/read/main.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	connTest "github.com/amp-labs/connectors/test/clickup"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetClickupConnector(ctx)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "team",
+		Fields:     connectors.Fields("id", "name", "color", "avatar"),
+	})
+	if err != nil {
+		utils.Fail("error reading from Clickup", "error", err)
+	}
+
+	slog.Info("Reading team..")
+	utils.DumpJSON(res, os.Stdout)
+}
diff --git a/test/keap/metadata/main.go b/test/keap/metadata/main.go
index 1e274edb..64481791 100644
--- a/test/keap/metadata/main.go
+++ b/test/keap/metadata/main.go
@@ -13,8 +13,6 @@ import (
 
 var objectName = "contacts"
 
-// we want to compare fields returned by read and schema properties provided by metadata methods
-// they must match for all such objects
 func main() {
 	// Handle Ctrl-C gracefully.
 	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@@ -33,6 +31,6 @@ func main() {
 		utils.Fail("error listing metadata for Keap", "error", err)
 	}
 
-	fmt.Println("Contacts metadata...")
+	fmt.Println("Metadata...")
 	utils.DumpJSON(metadata, os.Stdout)
 }
diff --git a/test/utils/creds.go b/test/utils/creds.go
index 21c16eb2..64c51507 100644
--- a/test/utils/creds.go
+++ b/test/utils/creds.go
@@ -1,7 +1,12 @@
 package utils
 
 import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/scanning/credscanning"
+	"golang.org/x/oauth2"
 )
 
 func MustCreateProvCredJSON(filePath string,
@@ -26,3 +31,20 @@ func MustCreateProvCredENV(providerName string,
 
 	return reader
 }
+
+func NewOauth2Client(
+	ctx context.Context,
+	reader *credscanning.ProviderCredentials,
+	configProvider func(*credscanning.ProviderCredentials) *oauth2.Config,
+) common.AuthenticatedHTTPClient {
+	client, err := common.NewOAuthHTTPClient(ctx,
+		common.WithOAuthClient(http.DefaultClient),
+		common.WithOAuthConfig(configProvider(reader)),
+		common.WithOAuthToken(reader.GetOauthToken()),
+	)
+	if err != nil {
+		Fail("failure", "error", err)
+	}
+
+	return client
+}