mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(harmonic): add contact workflow integration (#6902)
* feat(harmonic): add contact workflow integration * fix(harmonic): sync docs manifest * fix(harmonic): address integration review findings * feat(harmonic): add the missing people endpoints and fix two error paths Extends the integration from 4 to 13 tools, covering every non-deprecated people-scoped Harmonic endpoint, and repairs two defects found by validating the existing tools against Harmonic's OpenAPI and API reference. New tools: - Enrich Person (POST /persons) — the only path from a LinkedIn URL or email a workflow already holds to a Harmonic contact. - Get Person, Get Company Employees — account-based sourcing; employees returns URNs that chain into Batch Get People. - Saved-search net-new results and their acknowledgement, so a monitor stops reprocessing the entire result set on every poll. - Bulk email enrichment: submit, poll, and quota, plus Get Enrichment Status. Fixes: - The error extractor dropped Harmonic's string and object `detail` envelopes. A tool that names an extractor gets no fallback chain, so every FastAPI abort surfaced as "Request failed with status 403". The enrichment 404 also carries the scheduled `enrichment_urn`, which was being discarded — that URN is the only handle on the job, so it is now kept in the message. - The saved-search selector failed the whole dropdown instead of degrading: the response cap was half the sibling value on an endpoint that is unpaginated and returns every saved search with its full query object, and the option ceiling threw rather than truncating. Raised to 1MB and switched to truncate-and-warn, matching the other data-driven selectors. Clearing net-new results now requires an explicit scope. Harmonic treats an absent `entity_urns` as "clear everything", so an empty field would have silently discarded the backlog. Scope deliberately excludes company-side, deal, typeahead, network, and Scout streaming endpoints, and every endpoint retiring on 2026-11-05. --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Waleed Latif <walif6@gmail.com>
This commit is contained in:
co-authored by
Bill Leoutsakos
Waleed Latif
parent
893e729b3f
commit
ea70f8dcf1
@@ -563,6 +563,28 @@ export function ChartBarIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function HarmonicIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 1 26 30.1' fill='none' role='img' xmlns='http://www.w3.org/2000/svg'>
|
||||
{/** svg-path-precision-exception: Preserves Harmonic's supplied brand-mark coordinates. */}
|
||||
<path
|
||||
d='M6.49743 1.08252L12.9949 4.83381V12.3364L6.49743 16.0877L0 12.3364V4.83381L6.49743 1.08252Z'
|
||||
fill='#FE5D45'
|
||||
/>
|
||||
{/** svg-path-precision-exception: Preserves Harmonic's supplied brand-mark coordinates. */}
|
||||
<path
|
||||
d='M6.49743 16.0874L12.9949 19.8387V27.3413L6.49743 31.0926L0 27.3413V19.8387L6.49743 16.0874Z'
|
||||
fill='#FE5D45'
|
||||
/>
|
||||
{/** svg-path-precision-exception: Preserves Harmonic's supplied brand-mark coordinates. */}
|
||||
<path
|
||||
d='M19.5026 8.58496L26 12.3363V19.8388L19.5026 23.5901L13.0051 19.8388V12.3363L19.5026 8.58496Z'
|
||||
fill='#FE5D45'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HubspotIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -113,6 +113,7 @@ import {
|
||||
GranolaIcon,
|
||||
GreenhouseIcon,
|
||||
GreptileIcon,
|
||||
HarmonicIcon,
|
||||
HexIcon,
|
||||
HubspotIcon,
|
||||
HuggingFaceIcon,
|
||||
@@ -395,6 +396,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
granola: GranolaIcon,
|
||||
greenhouse: GreenhouseIcon,
|
||||
greptile: GreptileIcon,
|
||||
harmonic: HarmonicIcon,
|
||||
hex: HexIcon,
|
||||
hubspot: HubspotIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
---
|
||||
title: Harmonic
|
||||
description: Search and enrich private-market contacts
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="harmonic"
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Harmonic](https://harmonic.ai/) is a private-market intelligence platform for researching companies, people, and investors. Its Scout agent accepts a natural-language sourcing request—such as “find forward-deployed engineers in enterprise software”—and the Harmonic block converts the result into a predictable `contacts` table for downstream scoring, review, storage in Sim tables, or delivery to CRM and other integration blocks.
|
||||
|
||||
### Authentication
|
||||
|
||||
This integration uses a reusable Harmonic **team API key** connection. It does not use delegated OAuth. You need an existing Harmonic workspace with API access; ask your Harmonic workspace administrator or [Harmonic support](mailto:support@harmonic.ai) for the team key, then create a Harmonic connection from the block's **Harmonic Account** field. Sim validates and stores the key once, then sends it only in Harmonic's `apikey` request header. The same connection can be reused across Harmonic blocks and replaced or revoked from the credentials settings.
|
||||
|
||||
### Working with people data
|
||||
|
||||
- **Scout search:** **Search People with Scout** uses an integration-owned schema so every successful request returns the same normalized contact fields. Scout task errors, timeouts, interruptions, and malformed structured results stop the workflow instead of returning an ambiguous partial result.
|
||||
- **Saved searches:** A team API key can read only saved searches shared with the team. Make a private people search shared in the Harmonic console, then choose it with the basic selector. Advanced mode accepts a numeric saved-search ID or full URN when a search is not shown. Follow `pageInfo.nextCursor` while `pageInfo.hasNext` is true.
|
||||
- **URN-only rows:** Saved-search pages may contain person URNs without full profiles. Pass `personUrns` to **Batch Get People** to hydrate them into the common contact shape. **Get Company Employees** returns URNs the same way, so account-based sourcing chains through the same step.
|
||||
- **Starting from an identifier:** **Enrich Person** turns a LinkedIn profile URL or an email address into a contact. When Harmonic has no record yet it returns an error naming the enrichment it just scheduled; poll that URN with **Get Enrichment Status** and read the person once it completes.
|
||||
- **Net-new monitoring:** **Get People Saved Search Net-New Results** returns only people who newly matched, which avoids reprocessing the whole result set on every poll. It requires a saved search you have subscribed to in the Harmonic console — there is no API to subscribe. Acknowledge what you processed with **Clear People Saved Search Net-New Results** so the next poll starts clean.
|
||||
- **Email enrichment:** **Submit Email Enrichment Job** queues up to 5,000 people from either person URNs or LinkedIn URLs (one list or the other, never both). Poll **Get Email Enrichment Job** until `isTerminal` is true; the per-person rows carry a status but never an address, so pass `succeededPersonUrns` to **Batch Get People** to read the resolved emails. Check **Get Email Enrichment Usage** first to avoid exhausting the monthly quota.
|
||||
- **Downstream workflows:** Pass `contacts` directly into Sim tables, scoring or approval steps, and other integrations such as a CRM. Every contact-producing action uses the same camelCase output shape. A nullable array means Harmonic did not return that collection for the record; an empty array means Harmonic returned the collection with no values.
|
||||
- **Large batches:** Batch Get People accepts at most 500 combined IDs and URNs and requests only the fields used by the normalized contact output. Exceptionally large profiles can still exceed Sim's response limit; retry with smaller batches if that occurs.
|
||||
- **Workspace and list APIs:** This first version intentionally omits Harmonic's retiring V1 workspace and people-list endpoints. Harmonic says those APIs stop serving traffic on November 5, 2026 and already fail after a workspace completes its V2 migration. See Harmonic's [Workspace API migration guide](https://console.harmonic.ai/docs/api-reference/workspace/migration) for the V2 GraphQL replacement.
|
||||
|
||||
Harmonic recommends no more than 100 saved-search results per page. Its general API limit is 10 requests per second, while Scout task creation is limited to 10 requests per minute and 100 per hour. Endpoint and field availability can also depend on your Harmonic subscription.
|
||||
|
||||
Harmonic does not publish a webhook-registration contract for this workflow surface, so the integration has no native triggers. Use a **Schedule** block to poll a shared saved search when recurring synchronization is needed.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect a reusable Harmonic team API key, use Scout to find people with natural-language criteria, select team-visible people saved searches, and hydrate person identifiers into normalized contacts for downstream tables, CRM, scoring, and outreach workflows.
|
||||
|
||||
|
||||
|
||||
## Actions
|
||||
|
||||
### Harmonic Search People with Scout
|
||||
|
||||
Ask Harmonic Scout to find people using natural language and return a stable, workflow-ready contacts table.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `query` | string | Yes | Natural-language people research request, e.g. "Find forward-deployed engineers in enterprise software" |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts` | array | People matching the Scout request, normalized for downstream workflow use |
|
||||
| ↳ `personUrn` | string | Harmonic person URN |
|
||||
| ↳ `personId` | number | Numeric Harmonic person ID |
|
||||
| ↳ `fullName` | string | Full name |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `headline` | string | LinkedIn headline or current title |
|
||||
| ↳ `currentTitles` | array | Current job titles |
|
||||
| ↳ `currentCompanyNames` | array | Current company names |
|
||||
| ↳ `currentCompanyUrns` | array | Current Harmonic company URNs |
|
||||
| ↳ `primaryEmail` | string | Primary known email address |
|
||||
| ↳ `emails` | array | Known email addresses |
|
||||
| ↳ `phoneNumbers` | array | Known phone numbers |
|
||||
| ↳ `linkedinUrl` | string | LinkedIn profile URL |
|
||||
| ↳ `formattedLocation` | string | Formatted location |
|
||||
| ↳ `city` | string | City |
|
||||
| ↳ `state` | string | State or region |
|
||||
| ↳ `country` | string | Country |
|
||||
| ↳ `profilePictureUrl` | string | Profile picture URL |
|
||||
| ↳ `summary` | string | Scout-generated contact summary |
|
||||
| ↳ `isRedacted` | boolean | Whether Harmonic marks the person record as redacted |
|
||||
| `taskId` | string | Harmonic Scout task identifier |
|
||||
| `status` | string | Final Scout task status \(success\) |
|
||||
| `count` | number | Number of contacts returned |
|
||||
|
||||
### Harmonic Enrich Person
|
||||
|
||||
Resolve a LinkedIn profile URL or email address into a normalized Harmonic contact, queueing enrichment when the person is not yet in Harmonic.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `linkedinUrl` | string | No | LinkedIn profile URL, e.g. https://www.linkedin.com/in/example |
|
||||
| `email` | string | No | Email address used as a fallback when the LinkedIn URL is absent or unmatched |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | object | Normalized Harmonic contact, or null when the person is not yet in Harmonic |
|
||||
| ↳ `personUrn` | string | Harmonic person URN |
|
||||
| ↳ `personId` | number | Numeric Harmonic person ID |
|
||||
| ↳ `fullName` | string | Full name |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `headline` | string | LinkedIn headline or current title |
|
||||
| ↳ `currentTitles` | array | Current job titles |
|
||||
| ↳ `currentCompanyNames` | array | Current company names |
|
||||
| ↳ `currentCompanyUrns` | array | Current Harmonic company URNs |
|
||||
| ↳ `primaryEmail` | string | Primary known email address |
|
||||
| ↳ `emails` | array | Known email addresses |
|
||||
| ↳ `phoneNumbers` | array | Known phone numbers |
|
||||
| ↳ `linkedinUrl` | string | LinkedIn profile URL |
|
||||
| ↳ `formattedLocation` | string | Formatted location |
|
||||
| ↳ `city` | string | City |
|
||||
| ↳ `state` | string | State or region |
|
||||
| ↳ `country` | string | Country |
|
||||
| ↳ `profilePictureUrl` | string | Profile picture URL |
|
||||
| ↳ `summary` | string | Scout-generated contact summary |
|
||||
| ↳ `isRedacted` | boolean | Whether Harmonic marks the person record as redacted |
|
||||
| `enrichmentUrn` | string | Enrichment URN to poll with Get Enrichment Status when Harmonic queued a refresh |
|
||||
| `mergedPersonUrn` | string | URN this person was merged into, when Harmonic deduplicated the record |
|
||||
| `requestedEntityUrn` | string | Person URN Harmonic matched the request to |
|
||||
| `found` | boolean | Whether Harmonic returned a person profile |
|
||||
| `enrichmentQueued` | boolean | Whether Harmonic queued a background refresh \(HTTP 201\) for this person |
|
||||
|
||||
### Harmonic Get Person
|
||||
|
||||
Fetch one Harmonic person by numeric ID or URN, including any email resolved by a completed enrichment job.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `personId` | string | Yes | Harmonic person ID or full person URN |
|
||||
| `companyContextUrns` | json | No | Company URNs used to scope the returned experience context; may be a JSON-array string |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | object | Normalized Harmonic contact, or null when Harmonic has no such person |
|
||||
| ↳ `personUrn` | string | Harmonic person URN |
|
||||
| ↳ `personId` | number | Numeric Harmonic person ID |
|
||||
| ↳ `fullName` | string | Full name |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `headline` | string | LinkedIn headline or current title |
|
||||
| ↳ `currentTitles` | array | Current job titles |
|
||||
| ↳ `currentCompanyNames` | array | Current company names |
|
||||
| ↳ `currentCompanyUrns` | array | Current Harmonic company URNs |
|
||||
| ↳ `primaryEmail` | string | Primary known email address |
|
||||
| ↳ `emails` | array | Known email addresses |
|
||||
| ↳ `phoneNumbers` | array | Known phone numbers |
|
||||
| ↳ `linkedinUrl` | string | LinkedIn profile URL |
|
||||
| ↳ `formattedLocation` | string | Formatted location |
|
||||
| ↳ `city` | string | City |
|
||||
| ↳ `state` | string | State or region |
|
||||
| ↳ `country` | string | Country |
|
||||
| ↳ `profilePictureUrl` | string | Profile picture URL |
|
||||
| ↳ `summary` | string | Scout-generated contact summary |
|
||||
| ↳ `isRedacted` | boolean | Whether Harmonic marks the person record as redacted |
|
||||
| `found` | boolean | Whether Harmonic returned a person profile |
|
||||
|
||||
### Harmonic Batch Get People
|
||||
|
||||
Fetch full Harmonic person profiles for up to 500 combined numeric IDs and person URNs.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `personIds` | json | No | Array of numeric Harmonic person IDs; may be a JSON-array string |
|
||||
| `personUrns` | json | No | Array of Harmonic person URNs; may be a JSON-array string |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts` | array | Fetched Harmonic person profiles normalized as contacts |
|
||||
| ↳ `personUrn` | string | Harmonic person URN |
|
||||
| ↳ `personId` | number | Numeric Harmonic person ID |
|
||||
| ↳ `fullName` | string | Full name |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `headline` | string | LinkedIn headline or current title |
|
||||
| ↳ `currentTitles` | array | Current job titles |
|
||||
| ↳ `currentCompanyNames` | array | Current company names |
|
||||
| ↳ `currentCompanyUrns` | array | Current Harmonic company URNs |
|
||||
| ↳ `primaryEmail` | string | Primary known email address |
|
||||
| ↳ `emails` | array | Known email addresses |
|
||||
| ↳ `phoneNumbers` | array | Known phone numbers |
|
||||
| ↳ `linkedinUrl` | string | LinkedIn profile URL |
|
||||
| ↳ `formattedLocation` | string | Formatted location |
|
||||
| ↳ `city` | string | City |
|
||||
| ↳ `state` | string | State or region |
|
||||
| ↳ `country` | string | Country |
|
||||
| ↳ `profilePictureUrl` | string | Profile picture URL |
|
||||
| ↳ `summary` | string | Scout-generated contact summary |
|
||||
| ↳ `isRedacted` | boolean | Whether Harmonic marks the person record as redacted |
|
||||
| `count` | number | Number of contacts returned |
|
||||
|
||||
### Harmonic Get Company Employees
|
||||
|
||||
List person URNs for a company, filtered by role group and employment status. Pair with Batch Get People to hydrate contacts.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `companyId` | string | Yes | Harmonic company ID or full company URN |
|
||||
| `employeeGroupType` | string | No | Role group: CEO, FOUNDERS_AND_CEO, EXECUTIVES, FOUNDERS, LEADERSHIP, NON_LEADERSHIP, ALL, ADVISORS, NON_PARTNERS \(default ALL\) |
|
||||
| `employeeStatus` | string | No | Employment status: ACTIVE, NOT_ACTIVE, or ACTIVE_AND_NOT_ACTIVE \(default ACTIVE\) |
|
||||
| `userConnectionStatus` | string | No | Connection filter: TEAM_CONNECTION or NO_CONNECTION. Harmonic documents per-user connection filtering as unsupported via the API |
|
||||
| `size` | number | No | Results to return; Sim caps this at 100 per page \(default 50\) |
|
||||
| `cursor` | string | No | Opaque next-page cursor from a previous response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `personUrns` | array | Person URNs for the matching employees; Harmonic returns URNs only |
|
||||
| `totalCount` | number | Total matching employees |
|
||||
| `pageInfo` | object | Cursor pagination metadata |
|
||||
| ↳ `nextCursor` | string | Cursor for the next page |
|
||||
| ↳ `currentCursor` | string | Cursor for the current page |
|
||||
| ↳ `hasNext` | boolean | Whether another page is available |
|
||||
|
||||
### Harmonic List People Saved Searches
|
||||
|
||||
List the team-shared Harmonic saved searches that target people. Use a returned ID or URN to fetch results.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `savedSearches` | array | Team-accessible Harmonic saved searches that target people |
|
||||
| ↳ `savedSearchId` | number | Saved search ID |
|
||||
| ↳ `savedSearchUrn` | string | Saved search URN |
|
||||
| ↳ `name` | string | Saved search name |
|
||||
| ↳ `isPrivate` | boolean | Whether the search is private |
|
||||
| ↳ `savedSearchType` | string | Saved search entity type \(PERSONS\) |
|
||||
| ↳ `userSavedSearchType` | string | User-facing saved search type |
|
||||
| ↳ `creatorUrn` | string | Creator user URN |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `updatedAt` | string | Last update timestamp |
|
||||
| `count` | number | Number of people saved searches returned |
|
||||
|
||||
### Harmonic Get People Saved Search Results
|
||||
|
||||
Get one page of a Harmonic people saved search. Full records become contacts; URN-only rows are exposed for Batch Get People.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `savedSearchId` | string | Yes | People saved-search ID or full Harmonic saved-search URN |
|
||||
| `size` | number | No | Results to return; Sim caps this at 100 per page \(default 50\) |
|
||||
| `cursor` | string | No | Opaque next-page cursor from a previous response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts` | array | Full person records returned by the saved search, normalized as contacts |
|
||||
| ↳ `personUrn` | string | Harmonic person URN |
|
||||
| ↳ `personId` | number | Numeric Harmonic person ID |
|
||||
| ↳ `fullName` | string | Full name |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `headline` | string | LinkedIn headline or current title |
|
||||
| ↳ `currentTitles` | array | Current job titles |
|
||||
| ↳ `currentCompanyNames` | array | Current company names |
|
||||
| ↳ `currentCompanyUrns` | array | Current Harmonic company URNs |
|
||||
| ↳ `primaryEmail` | string | Primary known email address |
|
||||
| ↳ `emails` | array | Known email addresses |
|
||||
| ↳ `phoneNumbers` | array | Known phone numbers |
|
||||
| ↳ `linkedinUrl` | string | LinkedIn profile URL |
|
||||
| ↳ `formattedLocation` | string | Formatted location |
|
||||
| ↳ `city` | string | City |
|
||||
| ↳ `state` | string | State or region |
|
||||
| ↳ `country` | string | Country |
|
||||
| ↳ `profilePictureUrl` | string | Profile picture URL |
|
||||
| ↳ `summary` | string | Scout-generated contact summary |
|
||||
| ↳ `isRedacted` | boolean | Whether Harmonic marks the person record as redacted |
|
||||
| `personUrns` | array | All person URNs in the page, including rows returned without full profiles |
|
||||
| `totalCount` | number | Total matching people |
|
||||
| `pageInfo` | object | Cursor pagination metadata |
|
||||
| ↳ `nextCursor` | string | Cursor for the next page |
|
||||
| ↳ `currentCursor` | string | Cursor for the current page |
|
||||
| ↳ `hasNext` | boolean | Whether another page is available |
|
||||
|
||||
### Harmonic Get People Saved Search Net-New Results
|
||||
|
||||
Get only the people newly matching a subscribed Harmonic people saved search, so a monitor does not reprocess the whole result set.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `savedSearchId` | string | Yes | People saved-search ID or full Harmonic saved-search URN |
|
||||
| `size` | number | No | Results to return; Sim caps this at 100 per page \(default 50\) |
|
||||
| `cursor` | string | No | Opaque next-page cursor from a previous response |
|
||||
| `newResultsSince` | string | No | Only return matches after this UTC point, as YYYY-MM-DD or YYYY-MM-DDTHH:00:00Z |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts` | array | Newly matching people returned as full profiles, normalized as contacts |
|
||||
| ↳ `personUrn` | string | Harmonic person URN |
|
||||
| ↳ `personId` | number | Numeric Harmonic person ID |
|
||||
| ↳ `fullName` | string | Full name |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `headline` | string | LinkedIn headline or current title |
|
||||
| ↳ `currentTitles` | array | Current job titles |
|
||||
| ↳ `currentCompanyNames` | array | Current company names |
|
||||
| ↳ `currentCompanyUrns` | array | Current Harmonic company URNs |
|
||||
| ↳ `primaryEmail` | string | Primary known email address |
|
||||
| ↳ `emails` | array | Known email addresses |
|
||||
| ↳ `phoneNumbers` | array | Known phone numbers |
|
||||
| ↳ `linkedinUrl` | string | LinkedIn profile URL |
|
||||
| ↳ `formattedLocation` | string | Formatted location |
|
||||
| ↳ `city` | string | City |
|
||||
| ↳ `state` | string | State or region |
|
||||
| ↳ `country` | string | Country |
|
||||
| ↳ `profilePictureUrl` | string | Profile picture URL |
|
||||
| ↳ `summary` | string | Scout-generated contact summary |
|
||||
| ↳ `isRedacted` | boolean | Whether Harmonic marks the person record as redacted |
|
||||
| `personUrns` | array | All newly matching person URNs, including rows returned without full profiles |
|
||||
| `cursor` | string | Cursor echoed by Harmonic |
|
||||
| `pageInfo` | object | Cursor pagination metadata |
|
||||
| ↳ `nextCursor` | string | Cursor for the next page |
|
||||
| ↳ `currentCursor` | string | Cursor for the current page |
|
||||
| ↳ `hasNext` | boolean | Whether another page is available |
|
||||
|
||||
### Harmonic Clear People Saved Search Net-New Results
|
||||
|
||||
Acknowledge net-new people on a saved search so the next poll returns only fresh matches. Clearing everything requires setting the scope explicitly.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `savedSearchId` | string | Yes | People saved-search ID or full Harmonic saved-search URN |
|
||||
| `personUrns` | json | No | Person URNs to acknowledge when clearScope is "selected". May be a JSON-array string |
|
||||
| `clearScope` | string | No | Either "selected" \(default, acknowledge only the listed URNs\) or "all" \(clear every net-new result\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `cleared` | boolean | Whether Harmonic accepted the acknowledgement |
|
||||
| `clearedPersonUrns` | array | Person URNs acknowledged, or null when every net-new result was cleared |
|
||||
|
||||
### Harmonic Submit Email Enrichment Job
|
||||
|
||||
Queue bulk email enrichment for up to 5,000 people, given either person URNs or LinkedIn profile URLs.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `personUrns` | json | No | Array of Harmonic person URNs, 1-5000; may be a JSON-array string. Mutually exclusive with personLinkedinUrls |
|
||||
| `personLinkedinUrls` | json | No | Array of LinkedIn profile URLs, 1-5000; may be a JSON-array string. Mutually exclusive with personUrns |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `jobId` | string | Job identifier to poll with Get Email Enrichment Job |
|
||||
| `status` | string | Job status \(PENDING, IN_PROGRESS, COMPLETED, FAILED\) |
|
||||
| `acceptedCount` | number | People accepted into the job |
|
||||
| `monthlyRemaining` | number | Email enrichments left in the team monthly quota |
|
||||
| `createdAt` | string | Job creation timestamp |
|
||||
| `dropped` | array | Identifiers Harmonic dropped before queueing, with the reason for each |
|
||||
| ↳ `submittedIdentifier` | string | Identifier submitted to Harmonic |
|
||||
| ↳ `reason` | string | Why Harmonic dropped it \(NOT_FOUND, INVALID_URL, ALREADY_HAS_EMAIL, RECENTLY_ATTEMPTED\) |
|
||||
|
||||
### Harmonic Get Email Enrichment Job
|
||||
|
||||
Check a Harmonic bulk email enrichment job. Per-person results appear once the job is terminal; fetch the emails with Get Person or Batch Get People.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `jobId` | string | Yes | Job ID returned by Submit Email Enrichment Job |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `jobId` | string | Job identifier |
|
||||
| `status` | string | Job status \(PENDING, IN_PROGRESS, COMPLETED, FAILED\) |
|
||||
| `isTerminal` | boolean | Whether the job finished, meaning results will no longer change |
|
||||
| `counts` | object | Per-outcome tallies for the job |
|
||||
| ↳ `totalProcessed` | number | People processed |
|
||||
| ↳ `totalSucceeded` | number | People with an email found |
|
||||
| ↳ `totalFailed` | number | People whose enrichment failed |
|
||||
| ↳ `totalSkipped` | number | People skipped |
|
||||
| ↳ `totalNotFound` | number | People Harmonic could not resolve |
|
||||
| `results` | array | Per-person outcomes; null until the job reaches a terminal status |
|
||||
| ↳ `personUrn` | string | Harmonic person URN |
|
||||
| ↳ `status` | string | Per-person job status \(PENDING, SUCCESS, NOT_FOUND, FAILED, SKIPPED\) |
|
||||
| `succeededPersonUrns` | array | Person URNs whose email was found; pass these to Batch Get People |
|
||||
| `createdAt` | string | Job creation timestamp |
|
||||
| `completedAt` | string | Job completion timestamp |
|
||||
|
||||
### Harmonic Get Email Enrichment Usage
|
||||
|
||||
Read the team monthly email-enrichment quota. Check this before a large batch to avoid a quota rejection.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `monthlyUsage` | number | Emails enriched so far this month |
|
||||
| `monthlyLimit` | number | Monthly email enrichment allowance |
|
||||
| `monthlyRemaining` | number | Enrichments left this month |
|
||||
|
||||
### Harmonic Get Enrichment Status
|
||||
|
||||
Check enrichment jobs Harmonic queued for people it did not already have, and read the person URN each one produced.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `enrichmentUrns` | json | Yes | Array of Harmonic enrichment URNs or bare enrichment UUIDs from Enrich Person; may be a JSON-array string |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `enrichments` | array | Status of each requested enrichment job |
|
||||
| ↳ `enrichmentUrn` | string | Harmonic enrichment URN |
|
||||
| ↳ `status` | string | Enrichment job status \(QUEUED, IN_PROGRESS, COMPLETE, FAILED, NOT_FOUND, EXPERIENCES_HIDDEN\) |
|
||||
| ↳ `message` | string | Provider status message |
|
||||
| ↳ `enrichedEntityUrn` | string | Resulting company or person URN once enrichment completes |
|
||||
| `count` | number | Number of enrichment statuses returned |
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"granola",
|
||||
"greenhouse",
|
||||
"greptile",
|
||||
"harmonic",
|
||||
"hex",
|
||||
"hubspot",
|
||||
"hubspot-service-account",
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS } from '@/lib/api/contracts/selectors/harmonic'
|
||||
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
|
||||
|
||||
const {
|
||||
mockAuthorizeCredentialUse,
|
||||
mockCheckSessionOrInternalAuth,
|
||||
mockFetch,
|
||||
mockResolveCredentialAccessToken,
|
||||
mockResolveOAuthAccountId,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthorizeCredentialUse: vi.fn(),
|
||||
mockCheckSessionOrInternalAuth: vi.fn(),
|
||||
mockFetch: vi.fn(),
|
||||
mockResolveCredentialAccessToken: vi.fn(),
|
||||
mockResolveOAuthAccountId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/credential-access', () => ({
|
||||
authorizeCredentialUse: mockAuthorizeCredentialUse,
|
||||
}))
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
}))
|
||||
vi.mock('@/lib/oauth/credential-service', () => ({
|
||||
resolveCredentialAccessToken: mockResolveCredentialAccessToken,
|
||||
resolveOAuthAccountId: mockResolveOAuthAccountId,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/tools/harmonic/saved-searches/route'
|
||||
|
||||
const URL = 'http://localhost:3000/api/tools/harmonic/saved-searches'
|
||||
const REQUEST_BODY = { credential: 'credential-1', workflowId: 'workflow-1' } as const
|
||||
|
||||
function request(
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
headers: Record<string, string> = {}
|
||||
): NextRequest {
|
||||
return new NextRequest(URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
function providerResponse(
|
||||
body: unknown,
|
||||
status = 200,
|
||||
headers: Record<string, string> = {}
|
||||
): Response {
|
||||
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
})
|
||||
}
|
||||
|
||||
function peopleSearch(id: number, name = `Search ${id}`) {
|
||||
return {
|
||||
id,
|
||||
entity_urn: `urn:harmonic:saved_search:${id}`,
|
||||
name,
|
||||
type: 'PERSONS',
|
||||
query: { confidential: 'not returned' },
|
||||
}
|
||||
}
|
||||
|
||||
async function json(response: Response): Promise<Record<string, unknown>> {
|
||||
return (await response.json()) as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('POST /api/tools/harmonic/saved-searches', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'caller-1' })
|
||||
mockAuthorizeCredentialUse.mockResolvedValue({
|
||||
ok: true,
|
||||
credentialOwnerUserId: 'owner-1',
|
||||
resolvedCredentialId: 'resolved-credential-1',
|
||||
credentialType: 'service_account',
|
||||
})
|
||||
mockResolveOAuthAccountId.mockResolvedValue({
|
||||
credentialType: 'service_account',
|
||||
providerId: 'harmonic-service-account',
|
||||
})
|
||||
mockResolveCredentialAccessToken.mockResolvedValue({ accessToken: 'server-only-api-key' })
|
||||
mockFetch.mockResolvedValue(providerResponse([]))
|
||||
})
|
||||
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
it.each([
|
||||
['unauthenticated malformed input', '{not-json', {}, 'Unauthorized'],
|
||||
[
|
||||
'external API-key caller',
|
||||
REQUEST_BODY,
|
||||
{ 'x-api-key': 'external-key' },
|
||||
'API key access not allowed for this endpoint',
|
||||
],
|
||||
])('authenticates before parsing an %s', async (_label, body, headers, error) => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false, error })
|
||||
|
||||
const response = await POST(request(body, undefined, headers), {})
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await json(response)).toMatchObject({ error })
|
||||
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
|
||||
expect(mockResolveOAuthAccountId).not.toHaveBeenCalled()
|
||||
expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['invalid JSON', '{not-json', 400],
|
||||
['missing credential', { workflowId: 'workflow-1' }, 400],
|
||||
['blank workflow', { credential: 'credential-1', workflowId: ' ' }, 400],
|
||||
['unknown property', { ...REQUEST_BODY, apiKey: 'must-not-be-accepted' }, 400],
|
||||
['oversized request', { ...REQUEST_BODY, padding: 'x'.repeat(9 * 1024) }, 413],
|
||||
])('rejects %s before credential access', async (_label, body, expectedStatus) => {
|
||||
const response = await POST(request(body), {})
|
||||
|
||||
expect(response.status).toBe(expectedStatus)
|
||||
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
|
||||
expect(mockResolveOAuthAccountId).not.toHaveBeenCalled()
|
||||
expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('authorizes the exact workflow credential before metadata, secret resolution, and egress', async () => {
|
||||
const response = await POST(request(REQUEST_BODY), {})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockAuthorizeCredentialUse).toHaveBeenCalledWith(expect.any(NextRequest), {
|
||||
credentialId: 'credential-1',
|
||||
workflowId: 'workflow-1',
|
||||
callerUserId: 'caller-1',
|
||||
})
|
||||
expect(mockResolveOAuthAccountId).toHaveBeenCalledWith('resolved-credential-1')
|
||||
expect(mockResolveCredentialAccessToken).toHaveBeenCalledWith(
|
||||
'resolved-credential-1',
|
||||
'owner-1',
|
||||
expect.any(String)
|
||||
)
|
||||
expect(mockAuthorizeCredentialUse.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockResolveOAuthAccountId.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(mockResolveOAuthAccountId.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockResolveCredentialAccessToken.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(mockResolveCredentialAccessToken.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockFetch.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed on credential authorization before metadata, secrets, or egress', async () => {
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Forbidden' })
|
||||
|
||||
const response = await POST(request(REQUEST_BODY), {})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(mockResolveOAuthAccountId).not.toHaveBeenCalled()
|
||||
expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'non-service-account authorization',
|
||||
() =>
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
credentialOwnerUserId: 'owner-1',
|
||||
resolvedCredentialId: 'resolved-credential-1',
|
||||
credentialType: 'oauth',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'wrong service-account provider',
|
||||
() =>
|
||||
mockResolveOAuthAccountId.mockResolvedValueOnce({
|
||||
credentialType: 'service_account',
|
||||
providerId: 'snowflake-service-account',
|
||||
}),
|
||||
],
|
||||
['missing credential metadata', () => mockResolveOAuthAccountId.mockResolvedValueOnce(null)],
|
||||
])('rejects a %s before secret resolution or provider egress', async (_label, arrange) => {
|
||||
arrange()
|
||||
|
||||
const response = await POST(request(REQUEST_BODY), {})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses only the fixed Harmonic origin and apikey header and returns no secret data', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
providerResponse([
|
||||
peopleSearch(2, 'Zeta search'),
|
||||
{
|
||||
id: 99,
|
||||
entity_urn: 'urn:harmonic:saved_search:99',
|
||||
name: 'Companies',
|
||||
type: 'COMPANIES',
|
||||
},
|
||||
peopleSearch(1, 'Alpha search'),
|
||||
peopleSearch(1, 'Alpha search'),
|
||||
])
|
||||
)
|
||||
|
||||
const response = await POST(request(REQUEST_BODY), {})
|
||||
const body = await json(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.harmonic.ai/savedSearches',
|
||||
expect.objectContaining({ method: 'GET', redirect: 'error' })
|
||||
)
|
||||
const init = mockFetch.mock.calls[0]?.[1] as RequestInit
|
||||
expect(new Headers(init.headers).get('apikey')).toBe('server-only-api-key')
|
||||
expect(new Headers(init.headers).get('authorization')).toBeNull()
|
||||
expect(body).toEqual({
|
||||
savedSearches: [
|
||||
{ id: '1', urn: 'urn:harmonic:saved_search:1', name: 'Alpha search' },
|
||||
{ id: '2', urn: 'urn:harmonic:saved_search:2', name: 'Zeta search' },
|
||||
],
|
||||
})
|
||||
expect(JSON.stringify(body)).not.toContain('server-only-api-key')
|
||||
expect(JSON.stringify(body)).not.toContain('confidential')
|
||||
})
|
||||
|
||||
it('preserves a signed safe-integer ID without inventing an OpenAPI minimum', async () => {
|
||||
mockFetch.mockResolvedValueOnce(providerResponse([peopleSearch(-7, 'Signed ID search')]))
|
||||
|
||||
const response = await POST(request(REQUEST_BODY), {})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await json(response)).toEqual({
|
||||
savedSearches: [
|
||||
{
|
||||
id: '-7',
|
||||
urn: 'urn:harmonic:saved_search:-7',
|
||||
name: 'Signed ID search',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[401, 401, true],
|
||||
[403, 401, true],
|
||||
[404, 400, undefined],
|
||||
[429, 429, undefined],
|
||||
[500, 502, undefined],
|
||||
])(
|
||||
'maps provider status %s without reflecting provider details',
|
||||
async (status, expected, auth) => {
|
||||
mockFetch.mockResolvedValueOnce(providerResponse({ error: 'provider secret detail' }, status))
|
||||
|
||||
const response = await POST(request(REQUEST_BODY), {})
|
||||
const body = await json(response)
|
||||
|
||||
expect(response.status).toBe(expected)
|
||||
expect(body.authRequired).toBe(auth)
|
||||
expect(JSON.stringify(body)).not.toContain('provider secret detail')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['non-array root', { data: [] }],
|
||||
['non-object row', [null]],
|
||||
['missing required person identity', [{ type: 'PERSONS', name: 'Broken' }]],
|
||||
['conflicting numeric identity', [peopleSearch(1), { ...peopleSearch(2), id: 1 }]],
|
||||
['too many raw rows', Array.from({ length: 2_001 }, () => ({ type: 'COMPANIES' }))],
|
||||
])('fails closed on a %s provider response', async (_label, providerBody) => {
|
||||
mockFetch.mockResolvedValueOnce(providerResponse(providerBody))
|
||||
|
||||
const response = await POST(request(REQUEST_BODY), {})
|
||||
|
||||
expect(response.status).toBe(502)
|
||||
expect(await json(response)).toEqual({
|
||||
error: 'Harmonic returned an invalid saved-search response.',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed and oversized provider bodies', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(providerResponse('{not-json'))
|
||||
.mockResolvedValueOnce(
|
||||
providerResponse('[]', 200, { 'content-length': String(1024 * 1024 + 1) })
|
||||
)
|
||||
|
||||
for (let requestNumber = 0; requestNumber < 2; requestNumber++) {
|
||||
const response = await POST(request(REQUEST_BODY), {})
|
||||
expect(response.status).toBe(502)
|
||||
expect(mockResolveCredentialAccessToken).toHaveBeenCalledTimes(requestNumber + 1)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts the exact people-option ceiling after filtering and deduplication', async () => {
|
||||
const searches = Array.from(
|
||||
{ length: HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS },
|
||||
(_, index) => peopleSearch(index + 1)
|
||||
)
|
||||
searches.push(peopleSearch(1), {
|
||||
id: 9999,
|
||||
entity_urn: 'urn:harmonic:saved_search:9999',
|
||||
name: 'Company search',
|
||||
type: 'COMPANIES',
|
||||
query: { confidential: 'not returned' },
|
||||
})
|
||||
mockFetch.mockResolvedValueOnce(providerResponse(searches))
|
||||
|
||||
const response = await POST(request(REQUEST_BODY), {})
|
||||
const body = await json(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.savedSearches).toHaveLength(HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS)
|
||||
})
|
||||
|
||||
it('truncates to the option ceiling instead of failing the whole selector', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
providerResponse(
|
||||
Array.from({ length: HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS + 25 }, (_, index) =>
|
||||
peopleSearch(index + 1)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const response = await POST(request(REQUEST_BODY), {})
|
||||
const body = await json(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.savedSearches).toHaveLength(HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[null, 401],
|
||||
[new TokenServiceAccountValidationError('invalid_credentials', 401), 401],
|
||||
[new TokenServiceAccountValidationError('provider_unavailable', 502), 502],
|
||||
])(
|
||||
'keeps credential-resolution failure %s away from provider egress',
|
||||
async (failure, status) => {
|
||||
if (failure) mockResolveCredentialAccessToken.mockRejectedValueOnce(failure)
|
||||
else mockResolveCredentialAccessToken.mockResolvedValueOnce(null)
|
||||
|
||||
const response = await POST(request(REQUEST_BODY), {})
|
||||
|
||||
expect(response.status).toBe(status)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps unexpected credential infrastructure errors generic', async () => {
|
||||
mockResolveCredentialAccessToken.mockRejectedValueOnce(
|
||||
new Error('secret credential infrastructure detail')
|
||||
)
|
||||
|
||||
const response = await POST(request(REQUEST_BODY), {})
|
||||
const body = await json(response)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(body.error).toBe('Internal server error')
|
||||
expect(JSON.stringify(body)).not.toContain('secret credential infrastructure detail')
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps provider network failures and timeouts without leaking the thrown message', async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error('network secret detail'))
|
||||
.mockRejectedValueOnce(new DOMException('timeout secret detail', 'TimeoutError'))
|
||||
|
||||
const networkResponse = await POST(request(REQUEST_BODY), {})
|
||||
expect(networkResponse.status).toBe(502)
|
||||
expect(JSON.stringify(await json(networkResponse))).not.toContain('network secret detail')
|
||||
|
||||
const timeoutResponse = await POST(request(REQUEST_BODY), {})
|
||||
expect(timeoutResponse.status).toBe(504)
|
||||
expect(JSON.stringify(await json(timeoutResponse))).not.toContain('timeout secret detail')
|
||||
})
|
||||
|
||||
it('propagates client cancellation through provider egress', async () => {
|
||||
const controller = new AbortController()
|
||||
mockFetch.mockImplementationOnce((_url: string, init: RequestInit) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true })
|
||||
})
|
||||
})
|
||||
|
||||
const pending = POST(request(REQUEST_BODY, controller.signal), {})
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
|
||||
controller.abort(new Error('caller cancelled'))
|
||||
const response = await pending
|
||||
|
||||
expect(response.status).toBe(499)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isPlainRecord } from '@sim/utils/object'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS,
|
||||
type HarmonicSavedSearchesSelectorResponse,
|
||||
harmonicPeopleSavedSearchProviderSchema,
|
||||
harmonicSavedSearchesSelectorContract,
|
||||
} from '@/lib/api/contracts/selectors/harmonic'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors'
|
||||
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
|
||||
import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('HarmonicSavedSearchesAPI')
|
||||
const HARMONIC_SAVED_SEARCHES_URL = 'https://api.harmonic.ai/savedSearches'
|
||||
const SELECTOR_REQUEST_MAX_BYTES = 8 * 1024
|
||||
const PROVIDER_RESPONSE_MAX_BYTES = 1024 * 1024
|
||||
const PROVIDER_RESPONSE_MAX_ROWS = 2_000
|
||||
const PROVIDER_FETCH_TIMEOUT_MS = 10_000
|
||||
|
||||
type SavedSearchOption = HarmonicSavedSearchesSelectorResponse['savedSearches'][number]
|
||||
|
||||
function throwIfAborted(signal: AbortSignal): void {
|
||||
if (!signal.aborted) return
|
||||
throw signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException('Harmonic selector request was cancelled', 'AbortError')
|
||||
}
|
||||
|
||||
async function discardResponseBody(response: Response): Promise<void> {
|
||||
await response.body?.cancel().catch(() => {})
|
||||
}
|
||||
|
||||
async function providerFailureResponse(response: Response): Promise<NextResponse> {
|
||||
await discardResponseBody(response)
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Harmonic rejected this credential. Reconnect it and try again.',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
if (response.status === 429) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Harmonic rate-limited saved-search discovery. Try again shortly.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Harmonic could not list saved searches for this request.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Harmonic saved-search discovery failed.' }, { status: 502 })
|
||||
}
|
||||
|
||||
function credentialFailureResponse(error?: unknown): NextResponse {
|
||||
if (
|
||||
error instanceof TokenServiceAccountValidationError &&
|
||||
error.code === 'provider_unavailable'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'The Harmonic credential service is temporarily unavailable.' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not resolve the Harmonic credential. Reconnect it and try again.',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSavedSearches(value: unknown): SavedSearchOption[] {
|
||||
if (!Array.isArray(value) || value.length > PROVIDER_RESPONSE_MAX_ROWS) {
|
||||
throw new Error('Harmonic returned an invalid saved-search collection')
|
||||
}
|
||||
|
||||
const byUrn = new Map<string, SavedSearchOption>()
|
||||
const urnById = new Map<string, string>()
|
||||
for (const item of value) {
|
||||
if (!isPlainRecord(item)) {
|
||||
throw new Error('Harmonic returned a malformed saved-search entry')
|
||||
}
|
||||
if (item.type !== 'PERSONS') continue
|
||||
|
||||
const parsed = harmonicPeopleSavedSearchProviderSchema.safeParse(item)
|
||||
if (!parsed.success) {
|
||||
throw new Error('Harmonic returned a malformed people saved search')
|
||||
}
|
||||
const option = {
|
||||
id: String(parsed.data.id),
|
||||
urn: parsed.data.entity_urn,
|
||||
name: parsed.data.name,
|
||||
}
|
||||
const existingByUrn = byUrn.get(option.urn)
|
||||
const existingUrnForId = urnById.get(option.id)
|
||||
if (
|
||||
(existingByUrn && (existingByUrn.id !== option.id || existingByUrn.name !== option.name)) ||
|
||||
(existingUrnForId && existingUrnForId !== option.urn)
|
||||
) {
|
||||
throw new Error('Harmonic returned conflicting saved-search identities')
|
||||
}
|
||||
if (existingByUrn) continue
|
||||
if (byUrn.size >= HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS) {
|
||||
/**
|
||||
* `GET /savedSearches` is unpaginated, so this ceiling bounds customer data
|
||||
* rather than a provider catalog. Every sibling selector with a data-driven
|
||||
* bound truncates and warns; failing here would leave the dropdown dead with
|
||||
* no in-place recovery.
|
||||
*/
|
||||
logger.warn('Harmonic saved-search list hit the option ceiling; list may be incomplete', {
|
||||
cap: HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS,
|
||||
})
|
||||
break
|
||||
}
|
||||
byUrn.set(option.urn, option)
|
||||
urnById.set(option.id, option.urn)
|
||||
}
|
||||
|
||||
return [...byUrn.values()].sort(
|
||||
(left, right) => left.name.localeCompare(right.name) || left.urn.localeCompare(right.urn)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the bounded people saved searches used by `harmonic.savedSearches`.
|
||||
* This editor/executor selector follows the established Bitbucket and NetSuite
|
||||
* selector route pattern: surface authentication happens first, then the
|
||||
* shared workflow-scoped credential authorization helper runs before provider
|
||||
* metadata, secret resolution, or external egress.
|
||||
*/
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
const caller = await checkSessionOrInternalAuth(request, { requireWorkflowId: true })
|
||||
if (!caller.success || !caller.userId) {
|
||||
return NextResponse.json({ error: caller.error || 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
harmonicSavedSearchesSelectorContract,
|
||||
request,
|
||||
{},
|
||||
{ maxBodyBytes: SELECTOR_REQUEST_MAX_BYTES }
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { credential, workflowId } = parsed.data.body
|
||||
|
||||
const authorization = await authorizeCredentialUse(request, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
callerUserId: caller.userId,
|
||||
})
|
||||
if (!authorization.ok || !authorization.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authorization.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const resolvedCredentialId = authorization.resolvedCredentialId ?? credential
|
||||
const credentialMetadata = await resolveOAuthAccountId(resolvedCredentialId)
|
||||
if (
|
||||
authorization.credentialType !== 'service_account' ||
|
||||
credentialMetadata?.credentialType !== 'service_account' ||
|
||||
credentialMetadata.providerId !== HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
) {
|
||||
return NextResponse.json({ error: 'Select a Harmonic API-key account.' }, { status: 400 })
|
||||
}
|
||||
|
||||
throwIfAborted(request.signal)
|
||||
let token
|
||||
try {
|
||||
token = await resolveCredentialAccessToken(
|
||||
resolvedCredentialId,
|
||||
authorization.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
} catch (error) {
|
||||
throwIfAborted(request.signal)
|
||||
logger.warn('Failed to resolve Harmonic selector credential', {
|
||||
credentialId: resolvedCredentialId,
|
||||
errorType: error instanceof Error ? error.name : 'unknown',
|
||||
})
|
||||
if (error instanceof TokenServiceAccountValidationError) {
|
||||
return credentialFailureResponse(error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throwIfAborted(request.signal)
|
||||
if (!token?.accessToken) return credentialFailureResponse()
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS)
|
||||
const providerSignal = AbortSignal.any([request.signal, timeoutSignal])
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(HARMONIC_SAVED_SEARCHES_URL, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', apikey: token.accessToken },
|
||||
redirect: 'error',
|
||||
signal: providerSignal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (request.signal.aborted) throw error
|
||||
if (timeoutSignal.aborted || (error instanceof DOMException && error.name === 'TimeoutError')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Harmonic saved-search discovery timed out.' },
|
||||
{ status: 504 }
|
||||
)
|
||||
}
|
||||
logger.warn('Harmonic saved-search request failed', {
|
||||
errorType: error instanceof Error ? error.name : 'unknown',
|
||||
})
|
||||
return NextResponse.json({ error: 'Harmonic saved-search discovery failed.' }, { status: 502 })
|
||||
}
|
||||
|
||||
if (!response.ok) return providerFailureResponse(response)
|
||||
|
||||
try {
|
||||
const providerBody = await readResponseJsonWithLimit(response, {
|
||||
label: 'Harmonic saved-search response',
|
||||
maxBytes: PROVIDER_RESPONSE_MAX_BYTES,
|
||||
signal: providerSignal,
|
||||
})
|
||||
throwIfAborted(providerSignal)
|
||||
return NextResponse.json({ savedSearches: normalizeSavedSearches(providerBody) })
|
||||
} catch (error) {
|
||||
if (request.signal.aborted) throw error
|
||||
if (timeoutSignal.aborted || (error instanceof DOMException && error.name === 'TimeoutError')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Harmonic saved-search discovery timed out.' },
|
||||
{ status: 504 }
|
||||
)
|
||||
}
|
||||
logger.warn('Harmonic saved-search response was invalid', {
|
||||
errorType: error instanceof Error ? error.name : 'unknown',
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Harmonic returned an invalid saved-search response.' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.unmock('@/tools/registry')
|
||||
|
||||
import { HarmonicBlock, HarmonicBlockMeta } from '@/blocks/blocks/harmonic'
|
||||
import { BLOCK_META_REGISTRY, BLOCK_REGISTRY } from '@/blocks/registry-maps'
|
||||
import { tools } from '@/tools/registry'
|
||||
|
||||
describe('HarmonicBlock', () => {
|
||||
const buildParams = HarmonicBlock.tools.config!.params!
|
||||
const selectTool = HarmonicBlock.tools.config!.tool!
|
||||
const resolve = (inputs: Record<string, unknown>) => ({ ...inputs, ...buildParams(inputs) })
|
||||
|
||||
const operationSubBlock = HarmonicBlock.subBlocks.find((subBlock) => subBlock.id === 'operation')
|
||||
const operationIds =
|
||||
operationSubBlock?.options?.map((option) => (option as { id: string }).id) ?? []
|
||||
|
||||
it('maps every dropdown operation onto exactly one registered tool', () => {
|
||||
expect(operationIds).toEqual([
|
||||
'harmonic_search_people_scout',
|
||||
'harmonic_enrich_person',
|
||||
'harmonic_get_person',
|
||||
'harmonic_batch_get_people',
|
||||
'harmonic_get_company_employees',
|
||||
'harmonic_list_people_saved_searches',
|
||||
'harmonic_get_people_saved_search_results',
|
||||
'harmonic_get_people_saved_search_net_new_results',
|
||||
'harmonic_clear_people_saved_search_net_new_results',
|
||||
'harmonic_submit_email_enrichment_job',
|
||||
'harmonic_get_email_enrichment_job',
|
||||
'harmonic_get_email_enrichment_usage',
|
||||
'harmonic_get_enrichment_status',
|
||||
])
|
||||
expect(operationIds.map((id) => selectTool({ operation: id }))).toEqual(operationIds)
|
||||
expect(new Set(operationIds)).toEqual(new Set(HarmonicBlock.tools.access))
|
||||
})
|
||||
|
||||
it('keeps block, tool registry, and operation-specific output contracts in lockstep', () => {
|
||||
expect(BLOCK_REGISTRY.harmonic).toBe(HarmonicBlock)
|
||||
expect(BLOCK_META_REGISTRY.harmonic).toBe(HarmonicBlockMeta)
|
||||
|
||||
for (const operation of operationIds) {
|
||||
const tool = tools[operation]
|
||||
expect(tool?.id, `missing registry entry ${operation}`).toBe(operation)
|
||||
|
||||
const blockOutputs = Object.entries(HarmonicBlock.outputs)
|
||||
.filter(([, output]) => {
|
||||
if (!output.condition) return true
|
||||
const values = Array.isArray(output.condition.value)
|
||||
? output.condition.value
|
||||
: [output.condition.value]
|
||||
return values.includes(operation)
|
||||
})
|
||||
.map(([name, output]) => [name, output.type])
|
||||
|
||||
const toolOutputs = Object.entries(tool.outputs ?? {}).map(([name, output]) => [
|
||||
name,
|
||||
output.type === 'object' ? 'json' : output.type,
|
||||
])
|
||||
|
||||
expect(new Map(blockOutputs), `${operation} block outputs`).toEqual(new Map(toolOutputs))
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults to Scout search and rejects unregistered operations', () => {
|
||||
expect(operationSubBlock?.value?.({})).toBe('harmonic_search_people_scout')
|
||||
expect(() => selectTool({ operation: 'harmonic_delete_people_list' })).toThrow(
|
||||
/Invalid Harmonic operation/
|
||||
)
|
||||
})
|
||||
|
||||
it('gives every subblock a unique id', () => {
|
||||
const ids = HarmonicBlock.subBlocks.map((subBlock) => subBlock.id)
|
||||
expect(ids).toHaveLength(new Set(ids).size)
|
||||
})
|
||||
|
||||
it('does not expose legacy people-list operations, fields, or outputs', () => {
|
||||
expect(HarmonicBlock.tools.access.some((id) => id.includes('people_list'))).toBe(false)
|
||||
|
||||
const subBlockIds = HarmonicBlock.subBlocks.map((subBlock) => subBlock.id)
|
||||
expect(subBlockIds).not.toEqual(
|
||||
expect.arrayContaining(['peopleListId', 'listName', 'sharedWithTeam', 'entries'])
|
||||
)
|
||||
|
||||
expect(Object.keys(HarmonicBlock.outputs)).not.toEqual(
|
||||
expect.arrayContaining(['peopleLists', 'entries', 'listUrn', 'importUrn'])
|
||||
)
|
||||
})
|
||||
|
||||
it('uses one reusable service-account credential with a canonical manual fallback', () => {
|
||||
const credential = HarmonicBlock.subBlocks.find((subBlock) => subBlock.id === 'credential')
|
||||
const manualCredential = HarmonicBlock.subBlocks.find(
|
||||
(subBlock) => subBlock.id === 'manualCredential'
|
||||
)
|
||||
|
||||
expect(credential).toMatchObject({
|
||||
type: 'oauth-input',
|
||||
serviceId: 'harmonic',
|
||||
credentialKind: 'service-account',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
})
|
||||
expect(manualCredential).toMatchObject({
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
})
|
||||
expect(HarmonicBlock.subBlocks.some((subBlock) => subBlock.id === 'apiKey')).toBe(false)
|
||||
})
|
||||
|
||||
it('pairs saved-search discovery with a canonical manual ID or URN fallback', () => {
|
||||
const selector = HarmonicBlock.subBlocks.find(
|
||||
(subBlock) => subBlock.id === 'savedSearchSelector'
|
||||
)
|
||||
const manual = HarmonicBlock.subBlocks.find((subBlock) => subBlock.id === 'savedSearchIdManual')
|
||||
|
||||
expect(selector).toMatchObject({
|
||||
type: 'project-selector',
|
||||
serviceId: 'harmonic',
|
||||
selectorKey: 'harmonic.savedSearches',
|
||||
canonicalParamId: 'savedSearchId',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
})
|
||||
expect(manual).toMatchObject({
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'savedSearchId',
|
||||
mode: 'advanced',
|
||||
})
|
||||
})
|
||||
|
||||
it('declares the complete canonical input contract with precise types', () => {
|
||||
expect(HarmonicBlock.inputs).toEqual({
|
||||
operation: { type: 'string', description: 'Harmonic operation to perform' },
|
||||
oauthCredential: {
|
||||
type: 'string',
|
||||
description: 'Reusable Harmonic team API-key credential',
|
||||
},
|
||||
query: { type: 'string', description: 'Natural-language Harmonic Scout people query' },
|
||||
linkedinUrl: { type: 'string', description: 'LinkedIn profile URL to enrich' },
|
||||
email: { type: 'string', description: 'Email address used as an enrichment fallback' },
|
||||
personId: { type: 'string', description: 'Harmonic person ID or full person URN' },
|
||||
companyContextUrns: {
|
||||
type: 'array',
|
||||
description: 'Company URNs scoping the returned experience context',
|
||||
},
|
||||
companyId: { type: 'string', description: 'Harmonic company ID or full company URN' },
|
||||
employeeGroupType: { type: 'string', description: 'Employee role group filter' },
|
||||
employeeStatus: { type: 'string', description: 'Employment status filter' },
|
||||
userConnectionStatus: { type: 'string', description: 'Team or user connection filter' },
|
||||
savedSearchId: { type: 'string', description: 'People saved-search ID or full URN' },
|
||||
newResultsSince: {
|
||||
type: 'string',
|
||||
description: 'UTC cutoff for net-new saved-search matches',
|
||||
},
|
||||
personIds: { type: 'array', description: 'Numeric Harmonic person IDs to retrieve' },
|
||||
personUrns: {
|
||||
type: 'array',
|
||||
description: 'Harmonic person URNs to retrieve or acknowledge',
|
||||
},
|
||||
personLinkedinUrls: {
|
||||
type: 'array',
|
||||
description: 'LinkedIn profile URLs to submit for email enrichment',
|
||||
},
|
||||
clearScope: {
|
||||
type: 'string',
|
||||
description: 'Whether to clear only the listed person URNs or every net-new result',
|
||||
},
|
||||
jobId: { type: 'string', description: 'Harmonic email enrichment job ID' },
|
||||
enrichmentUrns: { type: 'array', description: 'Harmonic enrichment URNs to check' },
|
||||
size: { type: 'number', description: 'Page size, clamped to 1-100' },
|
||||
cursor: { type: 'string', description: 'Opaque pagination cursor' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses advanced required fields only as canonical fallbacks for required basic fields', () => {
|
||||
const advancedRequired = HarmonicBlock.subBlocks.filter(
|
||||
(subBlock) => subBlock.mode === 'advanced' && subBlock.required
|
||||
)
|
||||
|
||||
expect(advancedRequired.map((subBlock) => subBlock.id)).toEqual([
|
||||
'manualCredential',
|
||||
'savedSearchIdManual',
|
||||
])
|
||||
for (const advanced of advancedRequired) {
|
||||
expect(advanced.canonicalParamId).toBeTruthy()
|
||||
expect(
|
||||
HarmonicBlock.subBlocks.some(
|
||||
(subBlock) =>
|
||||
subBlock.mode === 'basic' &&
|
||||
subBlock.required &&
|
||||
subBlock.canonicalParamId === advanced.canonicalParamId
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards only the Scout query and reusable credential for natural-language search', () => {
|
||||
const params = resolve({
|
||||
operation: 'harmonic_search_people_scout',
|
||||
oauthCredential: 'credential-id',
|
||||
apiKey: 'retired-inline-key',
|
||||
query: 'Find FDEs in enterprise software',
|
||||
savedSearchId: 'stale-search',
|
||||
savedSearchSelector: 'stale-selector-value',
|
||||
savedSearchIdManual: 'stale-manual-value',
|
||||
personIds: '[22]',
|
||||
personUrns: '["urn:harmonic:person:22"]',
|
||||
size: '50',
|
||||
cursor: 'stale-cursor',
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({
|
||||
oauthCredential: 'credential-id',
|
||||
query: 'Find FDEs in enterprise software',
|
||||
})
|
||||
expect(params.apiKey).toBeUndefined()
|
||||
expect(params.operation).toBeUndefined()
|
||||
expect(params.savedSearchId).toBeUndefined()
|
||||
expect(params.savedSearchSelector).toBeUndefined()
|
||||
expect(params.savedSearchIdManual).toBeUndefined()
|
||||
expect(params.personIds).toBeUndefined()
|
||||
expect(params.personUrns).toBeUndefined()
|
||||
expect(params.size).toBeUndefined()
|
||||
expect(params.cursor).toBeUndefined()
|
||||
})
|
||||
|
||||
it('coerces pagination only for paged reads', () => {
|
||||
const savedSearch = resolve({
|
||||
operation: 'harmonic_get_people_saved_search_results',
|
||||
oauthCredential: 'credential-id',
|
||||
savedSearchId: 'urn:harmonic:saved_search:123',
|
||||
size: '75',
|
||||
cursor: 'opaque-token',
|
||||
})
|
||||
expect(savedSearch.savedSearchId).toBe('urn:harmonic:saved_search:123')
|
||||
expect(savedSearch.size).toBe('75')
|
||||
expect(savedSearch.cursor).toBe('opaque-token')
|
||||
|
||||
const list = resolve({
|
||||
operation: 'harmonic_list_people_saved_searches',
|
||||
oauthCredential: 'credential-id',
|
||||
size: '75',
|
||||
cursor: 'opaque-token',
|
||||
})
|
||||
expect(list.size).toBeUndefined()
|
||||
expect(list.cursor).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes batch identifier strings to the secret-safe tool boundary and preserves arrays', () => {
|
||||
const parsed = resolve({
|
||||
operation: 'harmonic_batch_get_people',
|
||||
oauthCredential: 'credential-id',
|
||||
personIds: '[22,1690]',
|
||||
personUrns: '["urn:harmonic:person:44"]',
|
||||
})
|
||||
expect(parsed.personIds).toBe('[22,1690]')
|
||||
expect(parsed.personUrns).toBe('["urn:harmonic:person:44"]')
|
||||
|
||||
const direct = resolve({
|
||||
operation: 'harmonic_batch_get_people',
|
||||
oauthCredential: 'credential-id',
|
||||
personIds: [22],
|
||||
personUrns: ['urn:harmonic:person:44'],
|
||||
})
|
||||
expect(direct.personIds).toEqual([22])
|
||||
expect(direct.personUrns).toEqual(['urn:harmonic:person:44'])
|
||||
})
|
||||
|
||||
it('does not parse malformed batch JSON before the secret-safe tool boundary', () => {
|
||||
expect(
|
||||
resolve({
|
||||
operation: 'harmonic_batch_get_people',
|
||||
oauthCredential: 'credential-id',
|
||||
personUrns: '[not-json]',
|
||||
}).personUrns
|
||||
).toBe('[not-json]')
|
||||
})
|
||||
|
||||
it('declares stable contact outputs for every contact-producing operation', () => {
|
||||
const contactCondition = HarmonicBlock.outputs.contacts.condition as {
|
||||
value: string[]
|
||||
}
|
||||
expect(new Set(contactCondition.value)).toEqual(
|
||||
new Set([
|
||||
'harmonic_search_people_scout',
|
||||
'harmonic_get_people_saved_search_results',
|
||||
'harmonic_get_people_saved_search_net_new_results',
|
||||
'harmonic_batch_get_people',
|
||||
])
|
||||
)
|
||||
expect(HarmonicBlock.outputs.contacts.description).toContain('personUrn')
|
||||
expect(HarmonicBlock.outputs.contacts.description).toContain('linkedinUrl')
|
||||
expect(HarmonicBlock.outputs.contacts.type).toBe('array')
|
||||
expect(HarmonicBlock.outputs.savedSearches.type).toBe('array')
|
||||
expect(HarmonicBlock.outputs.personUrns.type).toBe('array')
|
||||
expect(HarmonicBlock.outputs.pageInfo.type).toBe('json')
|
||||
})
|
||||
|
||||
it('ships research-grounded metadata with concrete templates and skills', () => {
|
||||
expect(HarmonicBlockMeta.url).toBe('https://harmonic.ai')
|
||||
expect(HarmonicBlockMeta.templates.length).toBeGreaterThanOrEqual(7)
|
||||
expect(HarmonicBlockMeta.skills.length).toBeGreaterThanOrEqual(5)
|
||||
expect(new Set(HarmonicBlockMeta.skills.map((skill) => skill.name)).size).toBe(
|
||||
HarmonicBlockMeta.skills.length
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,944 @@
|
||||
import { HarmonicIcon } from '@/components/icons'
|
||||
import type { BlockConfig, BlockMeta } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
|
||||
const HARMONIC_OPERATIONS = [
|
||||
'harmonic_search_people_scout',
|
||||
'harmonic_enrich_person',
|
||||
'harmonic_get_person',
|
||||
'harmonic_batch_get_people',
|
||||
'harmonic_get_company_employees',
|
||||
'harmonic_list_people_saved_searches',
|
||||
'harmonic_get_people_saved_search_results',
|
||||
'harmonic_get_people_saved_search_net_new_results',
|
||||
'harmonic_clear_people_saved_search_net_new_results',
|
||||
'harmonic_submit_email_enrichment_job',
|
||||
'harmonic_get_email_enrichment_job',
|
||||
'harmonic_get_email_enrichment_usage',
|
||||
'harmonic_get_enrichment_status',
|
||||
] as const
|
||||
|
||||
/** Operations that accept Harmonic's shared `size` + `cursor` pagination. */
|
||||
const PAGED_OPERATIONS = [
|
||||
'harmonic_get_people_saved_search_results',
|
||||
'harmonic_get_people_saved_search_net_new_results',
|
||||
'harmonic_get_company_employees',
|
||||
] as const
|
||||
|
||||
/** Operations addressed by a people saved-search ID or URN. */
|
||||
const SAVED_SEARCH_OPERATIONS = [
|
||||
'harmonic_get_people_saved_search_results',
|
||||
'harmonic_get_people_saved_search_net_new_results',
|
||||
'harmonic_clear_people_saved_search_net_new_results',
|
||||
] as const
|
||||
|
||||
/** Operations that take a list of person URNs. */
|
||||
const PERSON_URN_OPERATIONS = [
|
||||
'harmonic_batch_get_people',
|
||||
'harmonic_clear_people_saved_search_net_new_results',
|
||||
'harmonic_submit_email_enrichment_job',
|
||||
] as const
|
||||
|
||||
/** Operations returning the shared `contacts` collection. */
|
||||
const CONTACT_OPERATIONS = [
|
||||
'harmonic_search_people_scout',
|
||||
'harmonic_get_people_saved_search_results',
|
||||
'harmonic_get_people_saved_search_net_new_results',
|
||||
'harmonic_batch_get_people',
|
||||
] as const
|
||||
|
||||
/** Operations returning a single `contact`. */
|
||||
const SINGLE_CONTACT_OPERATIONS = ['harmonic_enrich_person', 'harmonic_get_person'] as const
|
||||
|
||||
/** Operations returning `personUrns`. */
|
||||
const PERSON_URN_OUTPUT_OPERATIONS = [
|
||||
'harmonic_get_people_saved_search_results',
|
||||
'harmonic_get_people_saved_search_net_new_results',
|
||||
'harmonic_get_company_employees',
|
||||
] as const
|
||||
|
||||
type HarmonicOperation = (typeof HARMONIC_OPERATIONS)[number]
|
||||
|
||||
function isHarmonicOperation(value: unknown): value is HarmonicOperation {
|
||||
return (HARMONIC_OPERATIONS as readonly unknown[]).includes(value)
|
||||
}
|
||||
|
||||
function optionalValue(value: unknown): unknown {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
return value
|
||||
}
|
||||
|
||||
export const HarmonicBlock: BlockConfig = {
|
||||
type: 'harmonic',
|
||||
name: 'Harmonic',
|
||||
description: 'Search and enrich private-market contacts',
|
||||
longDescription:
|
||||
'Connect a reusable Harmonic team API key, use Scout to find people with natural-language criteria, select team-visible people saved searches, and hydrate person identifiers into normalized contacts for downstream tables, CRM, scoring, and outreach workflows.',
|
||||
docsLink: 'https://docs.sim.ai/integrations/harmonic',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.Sales,
|
||||
bgColor: '#FFFFFF',
|
||||
icon: HarmonicIcon,
|
||||
authMode: AuthMode.ApiKey,
|
||||
canvasPresentation: {
|
||||
defaultTitle: 'Harmonic',
|
||||
sentences: {
|
||||
byOperation: {
|
||||
harmonic_search_people_scout: [{ text: 'Search people for', field: 'query', core: true }],
|
||||
harmonic_enrich_person: [
|
||||
'Enrich person',
|
||||
{ text: 'from', field: 'linkedinUrl' },
|
||||
{ text: 'or', field: 'email' },
|
||||
],
|
||||
harmonic_get_person: [{ text: 'Get person', field: 'personId', core: true }],
|
||||
harmonic_batch_get_people: [
|
||||
'Get people in batch',
|
||||
{ text: 'by URNs', field: 'personUrns' },
|
||||
{ text: 'or IDs', field: 'personIds' },
|
||||
],
|
||||
harmonic_get_company_employees: [
|
||||
{ text: 'List employees of', field: 'companyId', core: true },
|
||||
],
|
||||
harmonic_list_people_saved_searches: ['List people saved searches'],
|
||||
harmonic_get_people_saved_search_results: [
|
||||
{
|
||||
text: 'Read contacts from saved search',
|
||||
field: ['savedSearchSelector', 'savedSearchIdManual'],
|
||||
core: true,
|
||||
},
|
||||
],
|
||||
harmonic_get_people_saved_search_net_new_results: [
|
||||
{
|
||||
text: 'Read net-new contacts from saved search',
|
||||
field: ['savedSearchSelector', 'savedSearchIdManual'],
|
||||
core: true,
|
||||
},
|
||||
],
|
||||
harmonic_clear_people_saved_search_net_new_results: [
|
||||
{
|
||||
text: 'Clear net-new results on saved search',
|
||||
field: ['savedSearchSelector', 'savedSearchIdManual'],
|
||||
core: true,
|
||||
},
|
||||
],
|
||||
harmonic_submit_email_enrichment_job: [
|
||||
'Enrich emails',
|
||||
{ text: 'for', field: 'personUrns' },
|
||||
{ text: 'or', field: 'personLinkedinUrls' },
|
||||
],
|
||||
harmonic_get_email_enrichment_job: [
|
||||
{ text: 'Check email enrichment job', field: 'jobId', core: true },
|
||||
],
|
||||
harmonic_get_email_enrichment_usage: ['Check email enrichment usage'],
|
||||
harmonic_get_enrichment_status: [
|
||||
{ text: 'Check enrichment status for', field: 'enrichmentUrns', core: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'Harmonic Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'harmonic',
|
||||
credentialKind: 'service-account',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select Harmonic credential',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Harmonic Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Search People with Scout', id: 'harmonic_search_people_scout' },
|
||||
{ label: 'Enrich Person', id: 'harmonic_enrich_person' },
|
||||
{ label: 'Get Person', id: 'harmonic_get_person' },
|
||||
{ label: 'Batch Get People', id: 'harmonic_batch_get_people' },
|
||||
{ label: 'Get Company Employees', id: 'harmonic_get_company_employees' },
|
||||
{ label: 'List People Saved Searches', id: 'harmonic_list_people_saved_searches' },
|
||||
{
|
||||
label: 'Get People Saved Search Results',
|
||||
id: 'harmonic_get_people_saved_search_results',
|
||||
},
|
||||
{
|
||||
label: 'Get People Saved Search Net-New Results',
|
||||
id: 'harmonic_get_people_saved_search_net_new_results',
|
||||
},
|
||||
{
|
||||
label: 'Clear People Saved Search Net-New Results',
|
||||
id: 'harmonic_clear_people_saved_search_net_new_results',
|
||||
},
|
||||
{ label: 'Submit Email Enrichment Job', id: 'harmonic_submit_email_enrichment_job' },
|
||||
{ label: 'Get Email Enrichment Job', id: 'harmonic_get_email_enrichment_job' },
|
||||
{ label: 'Get Email Enrichment Usage', id: 'harmonic_get_email_enrichment_usage' },
|
||||
{ label: 'Get Enrichment Status', id: 'harmonic_get_enrichment_status' },
|
||||
],
|
||||
value: () => 'harmonic_search_people_scout',
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
title: 'Search Query',
|
||||
canvasNoun: 'a search query',
|
||||
type: 'long-input',
|
||||
rows: 4,
|
||||
placeholder: 'Find forward-deployed engineers at enterprise software companies',
|
||||
condition: { field: 'operation', value: 'harmonic_search_people_scout' },
|
||||
required: { field: 'operation', value: 'harmonic_search_people_scout' },
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'linkedinUrl',
|
||||
title: 'LinkedIn Profile URL',
|
||||
canvasNoun: 'a LinkedIn profile',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://www.linkedin.com/in/example',
|
||||
description: 'Enrich Person requires a LinkedIn profile URL or an email address',
|
||||
condition: { field: 'operation', value: 'harmonic_enrich_person' },
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: 'Email',
|
||||
canvasNoun: 'an email address',
|
||||
type: 'short-input',
|
||||
placeholder: 'person@example.com',
|
||||
description: 'Used as a fallback when the LinkedIn URL is absent or does not match',
|
||||
condition: { field: 'operation', value: 'harmonic_enrich_person' },
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'personId',
|
||||
title: 'Person ID or URN',
|
||||
canvasNoun: 'a person',
|
||||
type: 'short-input',
|
||||
placeholder: '22 or urn:harmonic:person:22',
|
||||
condition: { field: 'operation', value: 'harmonic_get_person' },
|
||||
required: { field: 'operation', value: 'harmonic_get_person' },
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'companyContextUrns',
|
||||
title: 'Company Context URNs',
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: '["urn:harmonic:company:1"]',
|
||||
description: 'Scopes the returned experience context to these companies',
|
||||
condition: { field: 'operation', value: 'harmonic_get_person' },
|
||||
mode: 'advanced',
|
||||
paramVisibility: 'user-or-llm',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Return ONLY a JSON array of Harmonic company URNs from the provided input. Preserve each URN exactly and omit duplicates.',
|
||||
generationType: 'json-array',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'companyId',
|
||||
title: 'Company ID or URN',
|
||||
canvasNoun: 'a company',
|
||||
type: 'short-input',
|
||||
placeholder: '1 or urn:harmonic:company:1',
|
||||
condition: { field: 'operation', value: 'harmonic_get_company_employees' },
|
||||
required: { field: 'operation', value: 'harmonic_get_company_employees' },
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'employeeGroupType',
|
||||
title: 'Employee Group',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All', id: 'ALL' },
|
||||
{ label: 'Founders', id: 'FOUNDERS' },
|
||||
{ label: 'Founders and CEO', id: 'FOUNDERS_AND_CEO' },
|
||||
{ label: 'CEO', id: 'CEO' },
|
||||
{ label: 'Executives', id: 'EXECUTIVES' },
|
||||
{ label: 'Leadership', id: 'LEADERSHIP' },
|
||||
{ label: 'Non-leadership', id: 'NON_LEADERSHIP' },
|
||||
{ label: 'Advisors', id: 'ADVISORS' },
|
||||
{ label: 'Non-partners', id: 'NON_PARTNERS' },
|
||||
],
|
||||
value: () => 'ALL',
|
||||
condition: { field: 'operation', value: 'harmonic_get_company_employees' },
|
||||
mode: 'advanced',
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'employeeStatus',
|
||||
title: 'Employment Status',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Active', id: 'ACTIVE' },
|
||||
{ label: 'Not active', id: 'NOT_ACTIVE' },
|
||||
{ label: 'Active and not active', id: 'ACTIVE_AND_NOT_ACTIVE' },
|
||||
],
|
||||
value: () => 'ACTIVE',
|
||||
condition: { field: 'operation', value: 'harmonic_get_company_employees' },
|
||||
mode: 'advanced',
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'userConnectionStatus',
|
||||
title: 'Connection Status',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Any', id: '' },
|
||||
{ label: 'Connected to the team', id: 'TEAM_CONNECTION' },
|
||||
{ label: 'Not connected', id: 'NO_CONNECTION' },
|
||||
],
|
||||
value: () => '',
|
||||
condition: { field: 'operation', value: 'harmonic_get_company_employees' },
|
||||
mode: 'advanced',
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'savedSearchSelector',
|
||||
title: 'Saved Search',
|
||||
canvasNoun: 'a saved search',
|
||||
type: 'project-selector',
|
||||
serviceId: 'harmonic',
|
||||
selectorKey: 'harmonic.savedSearches',
|
||||
canonicalParamId: 'savedSearchId',
|
||||
placeholder: 'Select a people saved search',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: [...SAVED_SEARCH_OPERATIONS] },
|
||||
required: { field: 'operation', value: [...SAVED_SEARCH_OPERATIONS] },
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'savedSearchIdManual',
|
||||
title: 'Saved Search ID or URN',
|
||||
canvasNoun: 'a saved search',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'savedSearchId',
|
||||
placeholder: 'Saved search ID or urn:harmonic:saved_search:...',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: [...SAVED_SEARCH_OPERATIONS] },
|
||||
required: { field: 'operation', value: [...SAVED_SEARCH_OPERATIONS] },
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'newResultsSince',
|
||||
title: 'New Results Since',
|
||||
type: 'short-input',
|
||||
placeholder: '2026-01-31 or 2026-01-31T00:00:00Z',
|
||||
description: 'Only return people matched after this UTC point',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'harmonic_get_people_saved_search_net_new_results',
|
||||
},
|
||||
mode: 'advanced',
|
||||
paramVisibility: 'user-or-llm',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Convert the described moment into a UTC timestamp formatted as YYYY-MM-DDTHH:00:00Z. Return ONLY the timestamp - no explanations, no extra text.',
|
||||
generationType: 'timestamp',
|
||||
placeholder: 'Describe the cutoff, e.g. "the start of last week"',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'personUrns',
|
||||
title: 'Person URNs',
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: '["urn:harmonic:person:22", "urn:harmonic:person:1690"]',
|
||||
description:
|
||||
'Batch Get requires at least one Person URN or Person ID. Clear Net-New Results clears everything when omitted',
|
||||
condition: { field: 'operation', value: [...PERSON_URN_OPERATIONS] },
|
||||
paramVisibility: 'user-or-llm',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Return ONLY a JSON array of Harmonic person URNs from the provided input. Preserve each URN exactly and omit duplicates.',
|
||||
generationType: 'json-array',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'clearScope',
|
||||
title: 'Clear Scope',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Only the person URNs below', id: 'selected' },
|
||||
{ label: 'Every net-new result', id: 'all' },
|
||||
],
|
||||
value: () => 'selected',
|
||||
description: 'Clearing every net-new result discards the whole backlog for this saved search',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'harmonic_clear_people_saved_search_net_new_results',
|
||||
},
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'personIds',
|
||||
title: 'Person IDs',
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: '[22, 1690]',
|
||||
description:
|
||||
'Numeric IDs for Batch Get People; IDs and URNs combined may contain 1-500 people',
|
||||
condition: { field: 'operation', value: 'harmonic_batch_get_people' },
|
||||
mode: 'advanced',
|
||||
paramVisibility: 'user-or-llm',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Return ONLY a JSON array of numeric Harmonic person IDs from the provided input. Omit duplicates.',
|
||||
generationType: 'json-array',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'personLinkedinUrls',
|
||||
title: 'LinkedIn Profile URLs',
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: '["https://www.linkedin.com/in/example"]',
|
||||
description:
|
||||
'Alternative to Person URNs for email enrichment; supply one list or the other, 1-5000 entries',
|
||||
condition: { field: 'operation', value: 'harmonic_submit_email_enrichment_job' },
|
||||
mode: 'advanced',
|
||||
paramVisibility: 'user-or-llm',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Return ONLY a JSON array of LinkedIn profile URLs from the provided input. Omit duplicates.',
|
||||
generationType: 'json-array',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'jobId',
|
||||
title: 'Job ID',
|
||||
canvasNoun: 'a job',
|
||||
type: 'short-input',
|
||||
placeholder: 'Job ID from Submit Email Enrichment Job',
|
||||
condition: { field: 'operation', value: 'harmonic_get_email_enrichment_job' },
|
||||
required: { field: 'operation', value: 'harmonic_get_email_enrichment_job' },
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'enrichmentUrns',
|
||||
title: 'Enrichment URNs',
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: '["urn:harmonic:enrichment:1"]',
|
||||
description: 'Enrichment URNs returned by Enrich Person',
|
||||
condition: { field: 'operation', value: 'harmonic_get_enrichment_status' },
|
||||
required: { field: 'operation', value: 'harmonic_get_enrichment_status' },
|
||||
paramVisibility: 'user-or-llm',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Return ONLY a JSON array of Harmonic enrichment URNs from the provided input. Preserve each URN exactly and omit duplicates.',
|
||||
generationType: 'json-array',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
title: 'Page Size',
|
||||
type: 'short-input',
|
||||
placeholder: '1-100',
|
||||
description: 'Number of records to return; defaults to 50 and Sim caps each page at 100',
|
||||
value: () => '50',
|
||||
condition: { field: 'operation', value: [...PAGED_OPERATIONS] },
|
||||
mode: 'advanced',
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
title: 'Cursor',
|
||||
type: 'short-input',
|
||||
placeholder: 'Next cursor from a previous response',
|
||||
condition: { field: 'operation', value: [...PAGED_OPERATIONS] },
|
||||
mode: 'advanced',
|
||||
paramVisibility: 'user-or-llm',
|
||||
},
|
||||
],
|
||||
|
||||
tools: {
|
||||
access: [
|
||||
'harmonic_search_people_scout',
|
||||
'harmonic_enrich_person',
|
||||
'harmonic_get_person',
|
||||
'harmonic_batch_get_people',
|
||||
'harmonic_get_company_employees',
|
||||
'harmonic_list_people_saved_searches',
|
||||
'harmonic_get_people_saved_search_results',
|
||||
'harmonic_get_people_saved_search_net_new_results',
|
||||
'harmonic_clear_people_saved_search_net_new_results',
|
||||
'harmonic_submit_email_enrichment_job',
|
||||
'harmonic_get_email_enrichment_job',
|
||||
'harmonic_get_email_enrichment_usage',
|
||||
'harmonic_get_enrichment_status',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
if (!isHarmonicOperation(params.operation)) {
|
||||
throw new Error(`Invalid Harmonic operation: ${String(params.operation)}`)
|
||||
}
|
||||
return params.operation
|
||||
},
|
||||
/**
|
||||
* The generic executor merges raw subblock state under this object. Every
|
||||
* operation-specific key is therefore assigned explicitly; `undefined`
|
||||
* is what removes a stale value after the operation changes.
|
||||
*/
|
||||
params: (params) => {
|
||||
const operation = String(params.operation ?? '')
|
||||
const isPaged = (PAGED_OPERATIONS as readonly string[]).includes(operation)
|
||||
const usesSavedSearch = (SAVED_SEARCH_OPERATIONS as readonly string[]).includes(operation)
|
||||
const usesPersonUrns = (PERSON_URN_OPERATIONS as readonly string[]).includes(operation)
|
||||
const isEmployees = operation === 'harmonic_get_company_employees'
|
||||
|
||||
return {
|
||||
operation: undefined,
|
||||
apiKey: undefined,
|
||||
credential: undefined,
|
||||
manualCredential: undefined,
|
||||
savedSearchSelector: undefined,
|
||||
savedSearchIdManual: undefined,
|
||||
oauthCredential: params.oauthCredential,
|
||||
query: operation === 'harmonic_search_people_scout' ? params.query : undefined,
|
||||
linkedinUrl: operation === 'harmonic_enrich_person' ? params.linkedinUrl : undefined,
|
||||
email: operation === 'harmonic_enrich_person' ? params.email : undefined,
|
||||
personId: operation === 'harmonic_get_person' ? params.personId : undefined,
|
||||
companyContextUrns:
|
||||
operation === 'harmonic_get_person'
|
||||
? optionalValue(params.companyContextUrns)
|
||||
: undefined,
|
||||
companyId: isEmployees ? params.companyId : undefined,
|
||||
employeeGroupType: isEmployees ? optionalValue(params.employeeGroupType) : undefined,
|
||||
employeeStatus: isEmployees ? optionalValue(params.employeeStatus) : undefined,
|
||||
userConnectionStatus: isEmployees
|
||||
? optionalValue(params.userConnectionStatus)
|
||||
: undefined,
|
||||
savedSearchId: usesSavedSearch ? params.savedSearchId : undefined,
|
||||
newResultsSince:
|
||||
operation === 'harmonic_get_people_saved_search_net_new_results'
|
||||
? optionalValue(params.newResultsSince)
|
||||
: undefined,
|
||||
size: isPaged ? optionalValue(params.size) : undefined,
|
||||
cursor: isPaged ? optionalValue(params.cursor) : undefined,
|
||||
personIds:
|
||||
operation === 'harmonic_batch_get_people' ? optionalValue(params.personIds) : undefined,
|
||||
personUrns: usesPersonUrns ? optionalValue(params.personUrns) : undefined,
|
||||
clearScope:
|
||||
operation === 'harmonic_clear_people_saved_search_net_new_results'
|
||||
? (optionalValue(params.clearScope) ?? 'selected')
|
||||
: undefined,
|
||||
personLinkedinUrls:
|
||||
operation === 'harmonic_submit_email_enrichment_job'
|
||||
? optionalValue(params.personLinkedinUrls)
|
||||
: undefined,
|
||||
jobId: operation === 'harmonic_get_email_enrichment_job' ? params.jobId : undefined,
|
||||
enrichmentUrns:
|
||||
operation === 'harmonic_get_enrichment_status'
|
||||
? optionalValue(params.enrichmentUrns)
|
||||
: undefined,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Harmonic operation to perform' },
|
||||
oauthCredential: {
|
||||
type: 'string',
|
||||
description: 'Reusable Harmonic team API-key credential',
|
||||
},
|
||||
query: { type: 'string', description: 'Natural-language Harmonic Scout people query' },
|
||||
linkedinUrl: { type: 'string', description: 'LinkedIn profile URL to enrich' },
|
||||
email: { type: 'string', description: 'Email address used as an enrichment fallback' },
|
||||
personId: { type: 'string', description: 'Harmonic person ID or full person URN' },
|
||||
companyContextUrns: {
|
||||
type: 'array',
|
||||
description: 'Company URNs scoping the returned experience context',
|
||||
},
|
||||
companyId: { type: 'string', description: 'Harmonic company ID or full company URN' },
|
||||
employeeGroupType: { type: 'string', description: 'Employee role group filter' },
|
||||
employeeStatus: { type: 'string', description: 'Employment status filter' },
|
||||
userConnectionStatus: { type: 'string', description: 'Team or user connection filter' },
|
||||
savedSearchId: { type: 'string', description: 'People saved-search ID or full URN' },
|
||||
newResultsSince: {
|
||||
type: 'string',
|
||||
description: 'UTC cutoff for net-new saved-search matches',
|
||||
},
|
||||
personIds: { type: 'array', description: 'Numeric Harmonic person IDs to retrieve' },
|
||||
personUrns: { type: 'array', description: 'Harmonic person URNs to retrieve or acknowledge' },
|
||||
personLinkedinUrls: {
|
||||
type: 'array',
|
||||
description: 'LinkedIn profile URLs to submit for email enrichment',
|
||||
},
|
||||
clearScope: {
|
||||
type: 'string',
|
||||
description: 'Whether to clear only the listed person URNs or every net-new result',
|
||||
},
|
||||
jobId: { type: 'string', description: 'Harmonic email enrichment job ID' },
|
||||
enrichmentUrns: { type: 'array', description: 'Harmonic enrichment URNs to check' },
|
||||
size: { type: 'number', description: 'Page size, clamped to 1-100' },
|
||||
cursor: { type: 'string', description: 'Opaque pagination cursor' },
|
||||
},
|
||||
|
||||
outputs: {
|
||||
contacts: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Normalized contacts with personUrn, personId, fullName, firstName, lastName, headline, currentTitles, currentCompanyNames, currentCompanyUrns, primaryEmail, emails, phoneNumbers, linkedinUrl, formattedLocation, city, state, country, profilePictureUrl, summary, and isRedacted; unavailable array fields are null',
|
||||
condition: { field: 'operation', value: [...CONTACT_OPERATIONS] },
|
||||
},
|
||||
contact: {
|
||||
type: 'json',
|
||||
description:
|
||||
'A single normalized contact with the same fields as contacts, or null when Harmonic has no such person',
|
||||
condition: { field: 'operation', value: [...SINGLE_CONTACT_OPERATIONS] },
|
||||
},
|
||||
found: {
|
||||
type: 'boolean',
|
||||
description: 'Whether Harmonic returned a person profile',
|
||||
condition: { field: 'operation', value: [...SINGLE_CONTACT_OPERATIONS] },
|
||||
},
|
||||
enrichmentUrn: {
|
||||
type: 'string',
|
||||
description: 'Enrichment URN to poll with Get Enrichment Status',
|
||||
condition: { field: 'operation', value: 'harmonic_enrich_person' },
|
||||
},
|
||||
mergedPersonUrn: {
|
||||
type: 'string',
|
||||
description: 'URN this person was merged into, when Harmonic deduplicated the record',
|
||||
condition: { field: 'operation', value: 'harmonic_enrich_person' },
|
||||
},
|
||||
requestedEntityUrn: {
|
||||
type: 'string',
|
||||
description: 'Person URN Harmonic matched the request to',
|
||||
condition: { field: 'operation', value: 'harmonic_enrich_person' },
|
||||
},
|
||||
enrichmentQueued: {
|
||||
type: 'boolean',
|
||||
description: 'Whether Harmonic queued a background refresh for this person',
|
||||
condition: { field: 'operation', value: 'harmonic_enrich_person' },
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Harmonic Scout task identifier',
|
||||
condition: { field: 'operation', value: 'harmonic_search_people_scout' },
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Terminal Harmonic Scout task status, or an email enrichment job status',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'harmonic_search_people_scout',
|
||||
'harmonic_submit_email_enrichment_job',
|
||||
'harmonic_get_email_enrichment_job',
|
||||
],
|
||||
},
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'Number of contacts, saved searches, or enrichment statuses returned',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'harmonic_search_people_scout',
|
||||
'harmonic_list_people_saved_searches',
|
||||
'harmonic_batch_get_people',
|
||||
'harmonic_get_enrichment_status',
|
||||
],
|
||||
},
|
||||
},
|
||||
savedSearches: {
|
||||
type: 'array',
|
||||
description:
|
||||
'People saved searches with savedSearchId, savedSearchUrn, name, isPrivate, savedSearchType, userSavedSearchType, creatorUrn, createdAt, and updatedAt',
|
||||
condition: { field: 'operation', value: 'harmonic_list_people_saved_searches' },
|
||||
},
|
||||
personUrns: {
|
||||
type: 'array',
|
||||
description: 'Harmonic person URNs returned by the saved search or company employee list',
|
||||
condition: { field: 'operation', value: [...PERSON_URN_OUTPUT_OPERATIONS] },
|
||||
},
|
||||
totalCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of matching results',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['harmonic_get_people_saved_search_results', 'harmonic_get_company_employees'],
|
||||
},
|
||||
},
|
||||
pageInfo: {
|
||||
type: 'json',
|
||||
description: 'Pagination metadata with currentCursor, nextCursor, and hasNext',
|
||||
condition: { field: 'operation', value: [...PAGED_OPERATIONS] },
|
||||
},
|
||||
cursor: {
|
||||
type: 'string',
|
||||
description: 'Cursor echoed by the net-new results endpoint',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'harmonic_get_people_saved_search_net_new_results',
|
||||
},
|
||||
},
|
||||
cleared: {
|
||||
type: 'boolean',
|
||||
description: 'Whether Harmonic accepted the net-new acknowledgement',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'harmonic_clear_people_saved_search_net_new_results',
|
||||
},
|
||||
},
|
||||
clearedPersonUrns: {
|
||||
type: 'array',
|
||||
description: 'Person URNs acknowledged, or null when every net-new result was cleared',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'harmonic_clear_people_saved_search_net_new_results',
|
||||
},
|
||||
},
|
||||
jobId: {
|
||||
type: 'string',
|
||||
description: 'Harmonic email enrichment job identifier',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['harmonic_submit_email_enrichment_job', 'harmonic_get_email_enrichment_job'],
|
||||
},
|
||||
},
|
||||
acceptedCount: {
|
||||
type: 'number',
|
||||
description: 'People accepted into the email enrichment job',
|
||||
condition: { field: 'operation', value: 'harmonic_submit_email_enrichment_job' },
|
||||
},
|
||||
dropped: {
|
||||
type: 'array',
|
||||
description: 'Dropped identifiers with submittedIdentifier and reason',
|
||||
condition: { field: 'operation', value: 'harmonic_submit_email_enrichment_job' },
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
description: 'Email enrichment job creation timestamp',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['harmonic_submit_email_enrichment_job', 'harmonic_get_email_enrichment_job'],
|
||||
},
|
||||
},
|
||||
completedAt: {
|
||||
type: 'string',
|
||||
description: 'Email enrichment job completion timestamp',
|
||||
condition: { field: 'operation', value: 'harmonic_get_email_enrichment_job' },
|
||||
},
|
||||
isTerminal: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the email enrichment job finished',
|
||||
condition: { field: 'operation', value: 'harmonic_get_email_enrichment_job' },
|
||||
},
|
||||
counts: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Email enrichment tallies with totalProcessed, totalSucceeded, totalFailed, totalSkipped, and totalNotFound',
|
||||
condition: { field: 'operation', value: 'harmonic_get_email_enrichment_job' },
|
||||
},
|
||||
results: {
|
||||
type: 'array',
|
||||
description: 'Per-person email enrichment outcomes with personUrn and status',
|
||||
condition: { field: 'operation', value: 'harmonic_get_email_enrichment_job' },
|
||||
},
|
||||
succeededPersonUrns: {
|
||||
type: 'array',
|
||||
description: 'Person URNs whose email was found; pass these to Batch Get People',
|
||||
condition: { field: 'operation', value: 'harmonic_get_email_enrichment_job' },
|
||||
},
|
||||
monthlyUsage: {
|
||||
type: 'number',
|
||||
description: 'Emails enriched so far this month',
|
||||
condition: { field: 'operation', value: 'harmonic_get_email_enrichment_usage' },
|
||||
},
|
||||
monthlyLimit: {
|
||||
type: 'number',
|
||||
description: 'Monthly email enrichment allowance',
|
||||
condition: { field: 'operation', value: 'harmonic_get_email_enrichment_usage' },
|
||||
},
|
||||
monthlyRemaining: {
|
||||
type: 'number',
|
||||
description: 'Enrichments left this month',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['harmonic_submit_email_enrichment_job', 'harmonic_get_email_enrichment_usage'],
|
||||
},
|
||||
},
|
||||
enrichments: {
|
||||
type: 'array',
|
||||
description: 'Enrichment statuses with enrichmentUrn, status, message, and enrichedEntityUrn',
|
||||
condition: { field: 'operation', value: 'harmonic_get_enrichment_status' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const HarmonicBlockMeta = {
|
||||
tags: ['enrichment', 'automation', 'agentic'],
|
||||
url: 'https://harmonic.ai',
|
||||
skills: [
|
||||
{
|
||||
name: 'search-people-with-scout',
|
||||
description:
|
||||
'Turn natural-language sourcing criteria into a normalized contact table with Harmonic Scout.',
|
||||
content:
|
||||
'# Search People with Scout\n\nUse Harmonic Scout when the request describes the people to find rather than supplying identifiers.\n\n## Steps\n1. Translate the request into a precise query that states role, company profile, geography, and any exclusions.\n2. Run Search People with Scout once; do not retry a timed-out task automatically.\n3. Review the returned contacts and keep the structured fields needed downstream.\n4. Preserve personUrn whenever Harmonic supplies it so Batch Get People can hydrate the record later.\n\n## Output\nReturn a contact table and the Scout task ID and status. Call out missing email or LinkedIn values instead of guessing them.',
|
||||
},
|
||||
{
|
||||
name: 'export-people-saved-search',
|
||||
description:
|
||||
'Resolve a team-visible people saved search and page its contacts into a downstream dataset.',
|
||||
content:
|
||||
'# Export People Saved Search\n\nRead a Harmonic people saved search into a workflow.\n\n## Steps\n1. Run List People Saved Searches and match the requested name to one search.\n2. Run Get People Saved Search Results with that ID or URN.\n3. Follow pageInfo.nextCursor while pageInfo.hasNext is true, using a page size no greater than 100.\n4. Deduplicate rows by personUrn and retain any URN-only results for hydration.\n\n## Output\nReturn the saved-search identity, total count, normalized contacts, unresolved person URNs, and whether pagination completed.',
|
||||
},
|
||||
{
|
||||
name: 'hydrate-person-urns',
|
||||
description:
|
||||
'Expand Harmonic person IDs or URNs into consistent contact records for scoring and routing.',
|
||||
content:
|
||||
'# Hydrate Person URNs\n\nUse Batch Get People when an upstream Harmonic result contains identifiers without complete contact fields.\n\n## Steps\n1. Collect the person IDs and URNs from the upstream rows.\n2. Deduplicate identifiers and split requests so each batch contains at most 500 identifiers.\n3. Run Batch Get People for each batch.\n4. Join normalized contacts back to the source rows by personUrn, falling back to personId only when necessary.\n\n## Output\nReturn the hydrated contacts and list any input identifiers that produced no contact.',
|
||||
},
|
||||
{
|
||||
name: 'rank-scout-shortlist',
|
||||
description:
|
||||
'Score Harmonic Scout contacts against explicit sourcing criteria and produce a review-ready shortlist.',
|
||||
content:
|
||||
'# Rank Scout Shortlist\n\nTurn a broad people search into a transparent shortlist.\n\n## Steps\n1. Run Search People with Scout using the requested role, company, industry, geography, and exclusion criteria.\n2. Score each normalized contact only on fields present in the result, such as title, company, location, and summary.\n3. Keep personUrn on every scored row and separate missing evidence from a negative match.\n4. Sort the qualifying contacts by score and retain the rejected rows with their reasons.\n\n## Output\nReturn a ranked contact table with score, evidence, and rejection reason. Do not infer missing contact attributes.',
|
||||
},
|
||||
{
|
||||
name: 'monitor-saved-search-snapshot',
|
||||
description:
|
||||
'Compare a team-visible people saved search with a stored snapshot to identify newly seen contacts.',
|
||||
content:
|
||||
'# Monitor Saved Search Snapshot\n\nDetect changes in a Harmonic people saved search without relying on provider triggers.\n\n## Steps\n1. Run List People Saved Searches and resolve the requested team-visible search.\n2. Page Get People Saved Search Results until pageInfo.hasNext is false.\n3. Deduplicate by personUrn and compare the complete set with the previously stored snapshot.\n4. Store the new snapshot only after every page succeeds.\n\n## Output\nReturn newly seen and no-longer-seen person URNs, the current total, and whether the pagination run completed.',
|
||||
},
|
||||
{
|
||||
name: 'audit-contact-coverage',
|
||||
description:
|
||||
'Audit a Harmonic people cohort for missing email, LinkedIn, company, and role data before outreach.',
|
||||
content:
|
||||
'# Audit Contact Coverage\n\nCheck whether a saved-search cohort is ready for scoring or outreach.\n\n## Steps\n1. Resolve the search with List People Saved Searches and page Get People Saved Search Results.\n2. Send any URN-only results through Batch Get People in batches of at most 500.\n3. Deduplicate by personUrn and flag contacts missing email, LinkedIn URL, current company, or current title.\n4. Calculate coverage rates per field without filling missing values from assumptions.\n\n## Output\nReturn the normalized contact table, field coverage rates, duplicate count, and rows requiring manual review.',
|
||||
},
|
||||
{
|
||||
name: 'enrich-known-identifiers',
|
||||
description:
|
||||
'Turn LinkedIn URLs or email addresses a workflow already holds into Harmonic contacts.',
|
||||
content:
|
||||
'# Enrich Known Identifiers\n\nUse Enrich Person when the workflow already has an identifier rather than a description of who to find.\n\n## Steps\n1. Prefer the LinkedIn profile URL; supply the email only as a fallback identifier.\n2. Run Enrich Person once per identifier and keep personUrn from every match.\n3. When Harmonic reports the person is not on file, capture the enrichment it scheduled and poll Get Enrichment Status until it is COMPLETE or FAILED.\n4. Read the resulting person with Get Person or Batch Get People once enrichment completes.\n\n## Output\nReturn the hydrated contacts, the identifiers still pending enrichment, and the identifiers Harmonic could not resolve. Do not invent contact fields for unresolved rows.',
|
||||
},
|
||||
{
|
||||
name: 'source-company-employees',
|
||||
description:
|
||||
'Build an account-based contact list from a company by role group and employment status.',
|
||||
content:
|
||||
'# Source Company Employees\n\nUse Get Company Employees when the request names an account rather than a person.\n\n## Steps\n1. Resolve the company ID or URN, then run Get Company Employees with the requested role group, such as FOUNDERS or EXECUTIVES.\n2. Follow pageInfo.nextCursor while pageInfo.hasNext is true, using a page size no greater than 100.\n3. Harmonic returns person URNs only, so hydrate them with Batch Get People in batches of at most 500.\n4. Deduplicate by personUrn before scoring or outreach.\n\n## Output\nReturn the company, the role group used, the hydrated contacts, and the total employee count Harmonic reported.',
|
||||
},
|
||||
{
|
||||
name: 'monitor-saved-search-net-new',
|
||||
description:
|
||||
'Poll only the newly matching people on a subscribed saved search and acknowledge them.',
|
||||
content:
|
||||
'# Monitor Saved Search Net-New\n\nUse the net-new feed instead of re-reading a whole saved search on every run.\n\n## Steps\n1. Confirm the saved search is subscribed in the Harmonic console; net-new results are unavailable otherwise and there is no API to subscribe.\n2. Run Get People Saved Search Net-New Results, optionally bounding the window with newResultsSince.\n3. Page with pageInfo.nextCursor until pageInfo.hasNext is false, collecting contacts and URN-only rows.\n4. Only after every page and every downstream write succeeds, run Clear People Saved Search Net-New Results for the person URNs you processed.\n\n## Output\nReturn the newly matching contacts, the URNs acknowledged, and whether the run completed before anything was cleared.',
|
||||
},
|
||||
{
|
||||
name: 'enrich-contact-emails',
|
||||
description:
|
||||
'Run Harmonic bulk email enrichment and read the resolved addresses back onto contacts.',
|
||||
content:
|
||||
'# Enrich Contact Emails\n\nUse bulk email enrichment when a cohort lacks addresses.\n\n## Steps\n1. Run Get Email Enrichment Usage and stop if monthlyRemaining is below the batch size.\n2. Submit person URNs or LinkedIn URLs, never both in one job, at most 5000 per job.\n3. Record the dropped identifiers and their reasons; RECENTLY_ATTEMPTED and ALREADY_HAS_EMAIL are not failures.\n4. Poll Get Email Enrichment Job until isTerminal is true, then pass succeededPersonUrns to Batch Get People, because the job rows never contain the address itself.\n\n## Output\nReturn the hydrated contacts with their emails, the per-status counts, the dropped identifiers, and the remaining monthly quota.',
|
||||
},
|
||||
],
|
||||
templates: [
|
||||
{
|
||||
icon: HarmonicIcon,
|
||||
title: 'Harmonic contact finder',
|
||||
prompt:
|
||||
'Build a chat-driven workflow that turns a sourcing request such as "find FDEs in enterprise software" into a Harmonic Scout search and writes the normalized contacts to a table for review.',
|
||||
modules: ['agent', 'tables', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'research', 'enrichment'],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
icon: HarmonicIcon,
|
||||
title: 'Harmonic saved search sync',
|
||||
prompt:
|
||||
'Create a scheduled workflow that resolves a team-visible Harmonic people saved search, pages every result, compares person URNs with the prior table snapshot, and posts newly seen contacts to Slack.',
|
||||
modules: ['scheduled', 'tables', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'monitoring', 'automation'],
|
||||
alsoIntegrations: ['slack'],
|
||||
},
|
||||
{
|
||||
icon: HarmonicIcon,
|
||||
title: 'Harmonic contact hydrator',
|
||||
prompt:
|
||||
'Build a workflow that accepts Harmonic person URNs from an earlier search, batches them in groups of 500, retrieves normalized contact records, and writes names, roles, companies, emails, and LinkedIn URLs to a table.',
|
||||
modules: ['tables', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['data', 'enrichment', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: HarmonicIcon,
|
||||
title: 'Harmonic sourcing shortlist',
|
||||
prompt:
|
||||
'Create an agent that searches Harmonic Scout for a sourcing thesis, scores the normalized contacts against explicit role, company, and location criteria, and writes the ranked shortlist with evidence to a review table.',
|
||||
modules: ['agent', 'tables', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'research', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: HarmonicIcon,
|
||||
title: 'Harmonic CRM prospect route',
|
||||
prompt:
|
||||
'Build a workflow that searches Harmonic Scout for technical buyers at target accounts, filters contacts with a usable email or LinkedIn URL, deduplicates them by person URN, and writes qualified prospects to Salesforce.',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'enrichment'],
|
||||
alsoIntegrations: ['salesforce'],
|
||||
},
|
||||
{
|
||||
icon: HarmonicIcon,
|
||||
title: 'Harmonic contact coverage audit',
|
||||
prompt:
|
||||
'Create a workflow that resolves a team-visible Harmonic people saved search, pages all results, hydrates URN-only records in batches, flags duplicates and missing contact fields, and writes the review queue to Google Sheets.',
|
||||
modules: ['tables', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['data', 'quality', 'automation'],
|
||||
alsoIntegrations: ['google_sheets'],
|
||||
},
|
||||
{
|
||||
icon: HarmonicIcon,
|
||||
title: 'Harmonic talent scout',
|
||||
prompt:
|
||||
'Build an agent that uses Harmonic Scout to find candidates with a requested title, industry background, and geography, ranks the normalized contacts, and sends the shortlist to a Slack hiring channel.',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['hiring', 'research', 'automation'],
|
||||
alsoIntegrations: ['slack'],
|
||||
},
|
||||
{
|
||||
icon: HarmonicIcon,
|
||||
title: 'Harmonic batch enrichment',
|
||||
prompt:
|
||||
'Create a workflow that accepts Harmonic person IDs and URNs from a table, deduplicates and splits them into batches of at most 500, retrieves normalized contacts with Batch Get People, and writes hydrated and unmatched rows separately.',
|
||||
modules: ['tables', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'data', 'automation'],
|
||||
},
|
||||
],
|
||||
} as const satisfies BlockMeta
|
||||
@@ -143,6 +143,7 @@ import { GranolaBlock, GranolaBlockMeta } from '@/blocks/blocks/granola'
|
||||
import { GreenhouseBlock, GreenhouseBlockMeta } from '@/blocks/blocks/greenhouse'
|
||||
import { GreptileBlock, GreptileBlockMeta } from '@/blocks/blocks/greptile'
|
||||
import { GuardrailsBlock } from '@/blocks/blocks/guardrails'
|
||||
import { HarmonicBlock, HarmonicBlockMeta } from '@/blocks/blocks/harmonic'
|
||||
import { HexBlock, HexBlockMeta } from '@/blocks/blocks/hex'
|
||||
import { HubSpotBlock, HubSpotBlockMeta } from '@/blocks/blocks/hubspot'
|
||||
import { HuggingFaceBlock, HuggingFaceBlockMeta } from '@/blocks/blocks/huggingface'
|
||||
@@ -501,6 +502,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
|
||||
greenhouse: GreenhouseBlock,
|
||||
greptile: GreptileBlock,
|
||||
guardrails: GuardrailsBlock,
|
||||
harmonic: HarmonicBlock,
|
||||
hex: HexBlock,
|
||||
hubspot: HubSpotBlock,
|
||||
huggingface: HuggingFaceBlock,
|
||||
@@ -828,6 +830,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
|
||||
granola: GranolaBlockMeta,
|
||||
greenhouse: GreenhouseBlockMeta,
|
||||
greptile: GreptileBlockMeta,
|
||||
harmonic: HarmonicBlockMeta,
|
||||
hex: HexBlockMeta,
|
||||
hubspot: HubSpotBlockMeta,
|
||||
huggingface: HuggingFaceBlockMeta,
|
||||
|
||||
@@ -563,6 +563,28 @@ export function ChartBarIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function HarmonicIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 1 26 30.1' fill='none' role='img' xmlns='http://www.w3.org/2000/svg'>
|
||||
{/** svg-path-precision-exception: Preserves Harmonic's supplied brand-mark coordinates. */}
|
||||
<path
|
||||
d='M6.49743 1.08252L12.9949 4.83381V12.3364L6.49743 16.0877L0 12.3364V4.83381L6.49743 1.08252Z'
|
||||
fill='#FE5D45'
|
||||
/>
|
||||
{/** svg-path-precision-exception: Preserves Harmonic's supplied brand-mark coordinates. */}
|
||||
<path
|
||||
d='M6.49743 16.0874L12.9949 19.8387V27.3413L6.49743 31.0926L0 27.3413V19.8387L6.49743 16.0874Z'
|
||||
fill='#FE5D45'
|
||||
/>
|
||||
{/** svg-path-precision-exception: Preserves Harmonic's supplied brand-mark coordinates. */}
|
||||
<path
|
||||
d='M19.5026 8.58496L26 12.3363V19.8388L19.5026 23.5901L13.0051 19.8388V12.3363L19.5026 8.58496Z'
|
||||
fill='#FE5D45'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HubspotIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '@sim/testing/mocks/executor'
|
||||
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { HarmonicBlock } from '@/blocks/blocks/harmonic'
|
||||
import { KnowledgeBlock } from '@/blocks/blocks/knowledge'
|
||||
import { getBlock } from '@/blocks/index'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
@@ -490,6 +491,47 @@ describe('GenericBlockHandler', () => {
|
||||
expect(transform).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not expose an invalid resolved Harmonic batch value in handler errors', async () => {
|
||||
const resolvedSecret = 'sk-live-invalid-json-secret'
|
||||
mockBlock.metadata = { id: 'harmonic', name: 'Harmonic' }
|
||||
mockGetBlock.mockReturnValue(HarmonicBlock)
|
||||
mockExecuteTool.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Harmonic "personUrns" must be a JSON array',
|
||||
})
|
||||
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'BATCH_IDENTIFIERS',
|
||||
plaintext: resolvedSecret,
|
||||
encryptedValue: 'encrypted-batch-identifiers',
|
||||
},
|
||||
])
|
||||
registry.recordResolvedAtInputPath('BATCH_IDENTIFIERS', resolvedSecret, ['personUrns'])
|
||||
registry.recordResolvedInputProjection(['personUrns'], resolvedSecret, '{{BATCH_IDENTIFIERS}}')
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
|
||||
let thrown: unknown
|
||||
try {
|
||||
await handler.execute(mockContext, mockBlock, {
|
||||
operation: 'harmonic_batch_get_people',
|
||||
oauthCredential: 'credential-id',
|
||||
personUrns: resolvedSecret,
|
||||
})
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error)
|
||||
expect((thrown as Error).message).toBe('Harmonic "personUrns" must be a JSON array')
|
||||
expect((thrown as Error).message).not.toContain(resolvedSecret)
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'some_custom_tool',
|
||||
expect.objectContaining({ personUrns: resolvedSecret }),
|
||||
{ executionContext: mockContext }
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error if the associated tool is not found', async () => {
|
||||
const inputs = { param1: 'value' }
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson }))
|
||||
|
||||
import { selectorContractsByPath } from '@/lib/api/contracts/selectors'
|
||||
import { getSelectorDefinition } from '@/hooks/selectors/registry'
|
||||
import type { SelectorQueryArgs } from '@/hooks/selectors/types'
|
||||
|
||||
const selector = getSelectorDefinition('harmonic.savedSearches')
|
||||
|
||||
function selectorArgs(
|
||||
overrides: Partial<SelectorQueryArgs> = {},
|
||||
contextOverrides: Partial<SelectorQueryArgs['context']> = {}
|
||||
): SelectorQueryArgs {
|
||||
return {
|
||||
key: 'harmonic.savedSearches',
|
||||
context: {
|
||||
oauthCredential: 'credential-1',
|
||||
workflowId: 'workflow-1',
|
||||
...contextOverrides,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const savedSearches = [
|
||||
{ id: '17', urn: 'urn:harmonic:saved_search:17', name: 'FDE candidates' },
|
||||
{ id: '28', urn: 'urn:harmonic:saved_search:28', name: 'Enterprise operators' },
|
||||
]
|
||||
|
||||
describe('harmonic.savedSearches selector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRequestJson.mockResolvedValue({ savedSearches })
|
||||
})
|
||||
|
||||
it('is registered with the canonical contract and waits for both authorization dependencies', () => {
|
||||
expect(selector.key).toBe('harmonic.savedSearches')
|
||||
expect(selector.contracts).toEqual([
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
path: '/api/tools/harmonic/saved-searches',
|
||||
}),
|
||||
])
|
||||
expect(selector.contracts?.[0]).toBe(
|
||||
selectorContractsByPath['/api/tools/harmonic/saved-searches']
|
||||
)
|
||||
expect(selector.enabled?.(selectorArgs())).toBe(true)
|
||||
expect(selector.enabled?.(selectorArgs({}, { oauthCredential: undefined }))).toBe(false)
|
||||
expect(selector.enabled?.(selectorArgs({}, { workflowId: undefined }))).toBe(false)
|
||||
})
|
||||
|
||||
it('isolates the query cache by workflow and credential', () => {
|
||||
expect(selector.getQueryKey(selectorArgs())).toEqual([
|
||||
'selectors',
|
||||
'harmonic.savedSearches',
|
||||
'workflow-1',
|
||||
'credential-1',
|
||||
])
|
||||
expect(selector.getQueryKey(selectorArgs({}, { oauthCredential: 'credential-2' }))).toEqual([
|
||||
'selectors',
|
||||
'harmonic.savedSearches',
|
||||
'workflow-1',
|
||||
'credential-2',
|
||||
])
|
||||
expect(selector.getQueryKey(selectorArgs({}, { workflowId: 'workflow-2' }))).toEqual([
|
||||
'selectors',
|
||||
'harmonic.savedSearches',
|
||||
'workflow-2',
|
||||
'credential-1',
|
||||
])
|
||||
})
|
||||
|
||||
it('loads safe options through requestJson and uses the full URN as the selected value', async () => {
|
||||
const controller = new AbortController()
|
||||
const options = await selector.fetchList?.(selectorArgs({ signal: controller.signal }))
|
||||
|
||||
expect(mockRequestJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
path: '/api/tools/harmonic/saved-searches',
|
||||
}),
|
||||
{
|
||||
body: { credential: 'credential-1', workflowId: 'workflow-1' },
|
||||
signal: controller.signal,
|
||||
}
|
||||
)
|
||||
expect(options).toEqual([
|
||||
{
|
||||
id: 'urn:harmonic:saved_search:17',
|
||||
label: 'FDE candidates',
|
||||
meta: {
|
||||
id: '17',
|
||||
urn: 'urn:harmonic:saved_search:17',
|
||||
name: 'FDE candidates',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'urn:harmonic:saved_search:28',
|
||||
label: 'Enterprise operators',
|
||||
meta: {
|
||||
id: '28',
|
||||
urn: 'urn:harmonic:saved_search:28',
|
||||
name: 'Enterprise operators',
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['numeric ID', '17'],
|
||||
['full URN', 'urn:harmonic:saved_search:17'],
|
||||
])('resolves a persisted %s to the canonical full-URN option', async (_label, detailId) => {
|
||||
const option = await selector.fetchById?.(selectorArgs({ detailId }))
|
||||
|
||||
expect(option).toEqual({
|
||||
id: 'urn:harmonic:saved_search:17',
|
||||
label: 'FDE candidates',
|
||||
meta: {
|
||||
id: '17',
|
||||
urn: 'urn:harmonic:saved_search:17',
|
||||
name: 'FDE candidates',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for an unavailable ID and declares speculative resolution safe', async () => {
|
||||
expect(selector.resolvesUnknownIds).toBe(true)
|
||||
await expect(selector.fetchById?.(selectorArgs({ detailId: '999' }))).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('does not request options when detail resolution is missing its credential scope', async () => {
|
||||
await expect(
|
||||
selector.fetchById?.(selectorArgs({ detailId: '17' }, { oauthCredential: undefined }))
|
||||
).resolves.toBeNull()
|
||||
expect(mockRequestJson).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['credential', { oauthCredential: undefined }],
|
||||
['workflow ID', { workflowId: undefined }],
|
||||
])('rejects a missing %s before issuing a request', async (_label, context) => {
|
||||
await expect(selector.fetchList?.(selectorArgs({}, context))).rejects.toThrow(/Missing/)
|
||||
expect(mockRequestJson).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type HarmonicSavedSearchesSelectorResponse,
|
||||
harmonicSavedSearchesSelectorContract,
|
||||
} from '@/lib/api/contracts/selectors/harmonic'
|
||||
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
|
||||
import type {
|
||||
SelectorDefinition,
|
||||
SelectorKey,
|
||||
SelectorOption,
|
||||
SelectorQueryArgs,
|
||||
} from '@/hooks/selectors/types'
|
||||
|
||||
type HarmonicSavedSearch = HarmonicSavedSearchesSelectorResponse['savedSearches'][number]
|
||||
type HarmonicSelectorKey = Extract<SelectorKey, 'harmonic.savedSearches'>
|
||||
|
||||
function scopeSatisfied({ context }: SelectorQueryArgs): boolean {
|
||||
return Boolean(context.oauthCredential && context.workflowId)
|
||||
}
|
||||
|
||||
function toOption(savedSearch: HarmonicSavedSearch): SelectorOption {
|
||||
return {
|
||||
id: savedSearch.urn,
|
||||
label: savedSearch.name,
|
||||
meta: {
|
||||
id: savedSearch.id,
|
||||
urn: savedSearch.urn,
|
||||
name: savedSearch.name,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSavedSearches({ context, signal }: SelectorQueryArgs) {
|
||||
const credential = ensureCredential(context, 'harmonic.savedSearches')
|
||||
if (!context.workflowId) {
|
||||
throw new Error('Missing workflow ID for selector harmonic.savedSearches')
|
||||
}
|
||||
|
||||
return requestJson(harmonicSavedSearchesSelectorContract, {
|
||||
body: { credential, workflowId: context.workflowId },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export const harmonicSelectors = {
|
||||
'harmonic.savedSearches': {
|
||||
key: 'harmonic.savedSearches',
|
||||
contracts: [harmonicSavedSearchesSelectorContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'harmonic.savedSearches',
|
||||
context.workflowId ?? 'none',
|
||||
context.oauthCredential ?? 'none',
|
||||
],
|
||||
enabled: scopeSatisfied,
|
||||
fetchList: async (args: SelectorQueryArgs) =>
|
||||
(await fetchSavedSearches(args)).savedSearches.map(toOption),
|
||||
fetchById: async (args: SelectorQueryArgs) => {
|
||||
const detailId = args.detailId?.trim()
|
||||
if (!detailId || !scopeSatisfied(args)) return null
|
||||
const match = (await fetchSavedSearches(args)).savedSearches.find(
|
||||
(savedSearch) => savedSearch.urn === detailId || savedSearch.id === detailId
|
||||
)
|
||||
return match ? toOption(match) : null
|
||||
},
|
||||
resolvesUnknownIds: true,
|
||||
},
|
||||
} satisfies Record<HarmonicSelectorKey, SelectorDefinition>
|
||||
@@ -8,6 +8,7 @@ import { clickupSelectors } from '@/hooks/selectors/providers/clickup/selectors'
|
||||
import { cloudwatchSelectors } from '@/hooks/selectors/providers/cloudwatch/selectors'
|
||||
import { confluenceSelectors } from '@/hooks/selectors/providers/confluence/selectors'
|
||||
import { googleSelectors } from '@/hooks/selectors/providers/google/selectors'
|
||||
import { harmonicSelectors } from '@/hooks/selectors/providers/harmonic/selectors'
|
||||
import { hubspotSelectors } from '@/hooks/selectors/providers/hubspot/selectors'
|
||||
import { imapSelectors } from '@/hooks/selectors/providers/imap/selectors'
|
||||
import { jiraSelectors } from '@/hooks/selectors/providers/jira/selectors'
|
||||
@@ -50,6 +51,7 @@ export const selectorRegistry = {
|
||||
...confluenceSelectors,
|
||||
...jsmSelectors,
|
||||
...googleSelectors,
|
||||
...harmonicSelectors,
|
||||
...hubspotSelectors,
|
||||
...managedAgentSelectors,
|
||||
...imapSelectors,
|
||||
|
||||
@@ -20,6 +20,7 @@ export type SelectorKey =
|
||||
| 'clickup.lists'
|
||||
| 'confluence.spaces'
|
||||
| 'google.tasks.lists'
|
||||
| 'harmonic.savedSearches'
|
||||
| 'managedAgent.agents'
|
||||
| 'managedAgent.environments'
|
||||
| 'managedAgent.vaults'
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from 'zod'
|
||||
import { workflowIdSchema } from '@/lib/api/contracts/primitives'
|
||||
import { definePostSelector } from '@/lib/api/contracts/selectors/shared'
|
||||
import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types'
|
||||
|
||||
export const HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS = 500
|
||||
|
||||
const harmonicCredentialSchema = z
|
||||
.string({ error: 'Credential is required' })
|
||||
.trim()
|
||||
.min(1, 'Credential is required')
|
||||
.max(128, 'Credential ID is too long')
|
||||
|
||||
const harmonicWorkflowIdSchema = workflowIdSchema
|
||||
.trim()
|
||||
.min(1, 'Workflow ID is required')
|
||||
.max(128, 'Workflow ID is too long')
|
||||
|
||||
export const harmonicSavedSearchesBodySchema = z
|
||||
.object({
|
||||
credential: harmonicCredentialSchema,
|
||||
workflowId: harmonicWorkflowIdSchema,
|
||||
})
|
||||
.strict()
|
||||
|
||||
const harmonicSavedSearchIdSchema = z.string().regex(/^-?\d+$/, 'Invalid Harmonic saved-search ID')
|
||||
const harmonicSavedSearchUrnSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.regex(/^urn:harmonic:saved_search:[^\s]+$/, 'Invalid Harmonic saved-search URN')
|
||||
const harmonicSavedSearchNameSchema = z.string().trim().min(1).max(1_000)
|
||||
|
||||
/** Validates the documented fields consumed from a PERSONS saved-search row. */
|
||||
export const harmonicPeopleSavedSearchProviderSchema = z
|
||||
.object({
|
||||
id: z.number().int().safe(),
|
||||
entity_urn: harmonicSavedSearchUrnSchema,
|
||||
name: harmonicSavedSearchNameSchema,
|
||||
type: z.literal('PERSONS'),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const harmonicSavedSearchSelectorOptionSchema = z
|
||||
.object({
|
||||
id: harmonicSavedSearchIdSchema,
|
||||
urn: harmonicSavedSearchUrnSchema,
|
||||
name: harmonicSavedSearchNameSchema,
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const harmonicSavedSearchesSelectorContract = definePostSelector(
|
||||
'/api/tools/harmonic/saved-searches',
|
||||
harmonicSavedSearchesBodySchema,
|
||||
z
|
||||
.object({
|
||||
savedSearches: z
|
||||
.array(harmonicSavedSearchSelectorOptionSchema)
|
||||
.max(HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
|
||||
export type HarmonicSavedSearchesSelectorBody = ContractBodyInput<
|
||||
typeof harmonicSavedSearchesSelectorContract
|
||||
>
|
||||
export type HarmonicSavedSearchesSelectorResponse = ContractJsonResponse<
|
||||
typeof harmonicSavedSearchesSelectorContract
|
||||
>
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
googleSheetsSelectorContract,
|
||||
googleTasksTaskListsSelectorContract,
|
||||
} from '@/lib/api/contracts/selectors/google'
|
||||
import { harmonicSavedSearchesSelectorContract } from '@/lib/api/contracts/selectors/harmonic'
|
||||
import {
|
||||
hubspotListsSelectorContract,
|
||||
hubspotOwnersSelectorContract,
|
||||
@@ -130,6 +131,7 @@ export * from '@/lib/api/contracts/selectors/clickup'
|
||||
export * from '@/lib/api/contracts/selectors/cloudwatch'
|
||||
export * from '@/lib/api/contracts/selectors/confluence'
|
||||
export * from '@/lib/api/contracts/selectors/google'
|
||||
export * from '@/lib/api/contracts/selectors/harmonic'
|
||||
export * from '@/lib/api/contracts/selectors/hubspot'
|
||||
export * from '@/lib/api/contracts/selectors/jira'
|
||||
export * from '@/lib/api/contracts/selectors/jsm'
|
||||
@@ -170,6 +172,7 @@ export const selectorContractsByPath = {
|
||||
'/api/tools/jsm/selector-servicedesks': jsmServiceDesksSelectorContract,
|
||||
'/api/tools/jsm/selector-requesttypes': jsmRequestTypesSelectorContract,
|
||||
'/api/tools/google_tasks/task-lists': googleTasksTaskListsSelectorContract,
|
||||
'/api/tools/harmonic/saved-searches': harmonicSavedSearchesSelectorContract,
|
||||
'/api/tools/microsoft_planner/plans': microsoftPlannerPlansSelectorContract,
|
||||
'/api/tools/microsoft_planner/tasks': microsoftPlannerTasksSelectorContract,
|
||||
'/api/tools/notion/databases': notionDatabasesSelectorContract,
|
||||
|
||||
@@ -165,6 +165,7 @@ export const DOCS_MANIFEST: readonly string[] = [
|
||||
'integrations/granola.mdx',
|
||||
'integrations/greenhouse.mdx',
|
||||
'integrations/greptile.mdx',
|
||||
'integrations/harmonic.mdx',
|
||||
'integrations/hex.mdx',
|
||||
'integrations/hubspot-service-account.mdx',
|
||||
'integrations/hubspot-setup.mdx',
|
||||
|
||||
@@ -68,6 +68,7 @@ export const AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID = 'airtable-service-account' a
|
||||
export const NOTION_SERVICE_ACCOUNT_PROVIDER_ID = 'notion-service-account' as const
|
||||
export const ASANA_SERVICE_ACCOUNT_PROVIDER_ID = 'asana-service-account' as const
|
||||
export const ATTIO_SERVICE_ACCOUNT_PROVIDER_ID = 'attio-service-account' as const
|
||||
export const HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID = 'harmonic-service-account' as const
|
||||
export const CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID = 'clickup-service-account' as const
|
||||
export const LINEAR_SERVICE_ACCOUNT_PROVIDER_ID = 'linear-service-account' as const
|
||||
export const MONDAY_SERVICE_ACCOUNT_PROVIDER_ID = 'monday-service-account' as const
|
||||
@@ -96,6 +97,7 @@ export type TokenServiceAccountProviderId =
|
||||
| typeof NOTION_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof ASANA_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof ATTIO_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof LINEAR_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof MONDAY_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
@@ -203,6 +205,22 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
|
||||
helpText:
|
||||
'Check the scopes granted to the key in Attio — tools whose scopes are missing will fail at run time.',
|
||||
},
|
||||
[HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID]: {
|
||||
providerId: HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
serviceLabel: 'Harmonic',
|
||||
tokenNoun: 'team API key',
|
||||
connectNoun: 'API key',
|
||||
fields: [
|
||||
{
|
||||
id: 'apiToken',
|
||||
label: 'Team API key',
|
||||
placeholder: 'Paste Harmonic team API key',
|
||||
secret: true,
|
||||
},
|
||||
],
|
||||
docsUrl: 'https://docs.sim.ai/integrations/harmonic',
|
||||
helpText: "Harmonic API keys belong to a team and share access to that team's saved searches.",
|
||||
},
|
||||
[CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID]: {
|
||||
providerId: CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
serviceLabel: 'ClickUp',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CALCOM_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
isTokenServiceAccountProviderId,
|
||||
LINEAR_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
@@ -26,6 +27,7 @@ import { validateAttioServiceAccount } from '@/lib/credentials/token-service-acc
|
||||
import { validateCalcomServiceAccount } from '@/lib/credentials/token-service-accounts/validators/calcom'
|
||||
import { validateClaudePlatformServiceAccount } from '@/lib/credentials/token-service-accounts/validators/claude-platform'
|
||||
import { validateClickupServiceAccount } from '@/lib/credentials/token-service-accounts/validators/clickup'
|
||||
import { validateHarmonicServiceAccount } from '@/lib/credentials/token-service-accounts/validators/harmonic'
|
||||
import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-accounts/validators/hubspot'
|
||||
import { validateLinearServiceAccount } from '@/lib/credentials/token-service-accounts/validators/linear'
|
||||
import { validateMondayServiceAccount } from '@/lib/credentials/token-service-accounts/validators/monday'
|
||||
@@ -87,6 +89,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record<
|
||||
[ASANA_SERVICE_ACCOUNT_PROVIDER_ID]: validateAsanaServiceAccount,
|
||||
[ATTIO_SERVICE_ACCOUNT_PROVIDER_ID]: validateAttioServiceAccount,
|
||||
[CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID]: validateClickupServiceAccount,
|
||||
[HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID]: validateHarmonicServiceAccount,
|
||||
[LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: validateLinearServiceAccount,
|
||||
[MONDAY_SERVICE_ACCOUNT_PROVIDER_ID]: validateMondayServiceAccount,
|
||||
[SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID]: validateShopifyServiceAccount,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getTokenServiceAccountDescriptor,
|
||||
HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
} from '@/lib/credentials/token-service-accounts/descriptors'
|
||||
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
|
||||
import { getTokenServiceAccountValidator } from '@/lib/credentials/token-service-accounts/server'
|
||||
import { validateHarmonicServiceAccount } from '@/lib/credentials/token-service-accounts/validators/harmonic'
|
||||
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
const API_KEY = 'harmonic-team-key-abcdefghijklmnop'
|
||||
|
||||
async function expectValidationError(
|
||||
promise: Promise<unknown>,
|
||||
code: 'invalid_credentials' | 'provider_unavailable'
|
||||
): Promise<TokenServiceAccountValidationError> {
|
||||
const error = await promise.then(
|
||||
() => {
|
||||
throw new Error('expected validation to throw')
|
||||
},
|
||||
(cause: unknown) => cause
|
||||
)
|
||||
expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
|
||||
expect((error as TokenServiceAccountValidationError).code).toBe(code)
|
||||
return error as TokenServiceAccountValidationError
|
||||
}
|
||||
|
||||
describe('validateHarmonicServiceAccount', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('validates with the fixed saved-search endpoint and returns no inferred principal', async () => {
|
||||
const response = new Response('{not-json', { status: 200 })
|
||||
const cancel = vi.spyOn(response.body!, 'cancel')
|
||||
mockFetch.mockResolvedValue(response)
|
||||
|
||||
const result = await validateHarmonicServiceAccount({ apiToken: API_KEY })
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.harmonic.ai/savedSearches', {
|
||||
headers: {
|
||||
apikey: API_KEY,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
redirect: 'error',
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
expect(result).toEqual({
|
||||
displayName: 'Harmonic (…mnop)',
|
||||
principal: null,
|
||||
auditMetadata: {},
|
||||
})
|
||||
})
|
||||
|
||||
it.each([401, 403])('maps HTTP %i to invalid_credentials', async (status) => {
|
||||
const response = new Response(`denied ${API_KEY}`, { status })
|
||||
const cancel = vi.spyOn(response.body!, 'cancel')
|
||||
mockFetch.mockResolvedValue(response)
|
||||
|
||||
const error = await expectValidationError(
|
||||
validateHarmonicServiceAccount({ apiToken: API_KEY }),
|
||||
'invalid_credentials'
|
||||
)
|
||||
|
||||
expect(error.status).toBe(status)
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
expect(JSON.stringify(error)).not.toContain(API_KEY)
|
||||
})
|
||||
|
||||
it.each([201, 204])('rejects undocumented successful HTTP %i responses', async (status) => {
|
||||
mockFetch.mockResolvedValue(new Response(status === 204 ? null : '{}', { status }))
|
||||
|
||||
const error = await expectValidationError(
|
||||
validateHarmonicServiceAccount({ apiToken: API_KEY }),
|
||||
'provider_unavailable'
|
||||
)
|
||||
|
||||
expect(error.status).toBe(status)
|
||||
expect(JSON.stringify(error)).not.toContain(API_KEY)
|
||||
})
|
||||
|
||||
it('maps provider failures to provider_unavailable without retaining the response body', async () => {
|
||||
const response = new Response(`upstream echoed ${API_KEY}`, { status: 500 })
|
||||
const cancel = vi.spyOn(response.body!, 'cancel')
|
||||
mockFetch.mockResolvedValue(response)
|
||||
|
||||
const error = await expectValidationError(
|
||||
validateHarmonicServiceAccount({ apiToken: API_KEY }),
|
||||
'provider_unavailable'
|
||||
)
|
||||
|
||||
expect(error.status).toBe(500)
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
expect(JSON.stringify(error)).not.toContain(API_KEY)
|
||||
expect(error.logDetail).toEqual({
|
||||
step: 'saved_searches',
|
||||
reason: 'provider returned HTTP 500',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a network outage to provider_unavailable without leaking the key', async () => {
|
||||
mockFetch.mockRejectedValue(new TypeError(`request with ${API_KEY} failed`))
|
||||
|
||||
const error = await expectValidationError(
|
||||
validateHarmonicServiceAccount({ apiToken: API_KEY }),
|
||||
'provider_unavailable'
|
||||
)
|
||||
|
||||
expect(error.status).toBe(502)
|
||||
expect(JSON.stringify(error)).not.toContain(API_KEY)
|
||||
expect(error.logDetail).toEqual({
|
||||
step: 'saved_searches',
|
||||
reason: 'network error reaching provider',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Harmonic token-service-account registration', () => {
|
||||
it('keeps the descriptor, validator, and OAuth service metadata in parity', () => {
|
||||
expect(getTokenServiceAccountDescriptor(HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID)).toEqual({
|
||||
providerId: HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
serviceLabel: 'Harmonic',
|
||||
tokenNoun: 'team API key',
|
||||
connectNoun: 'API key',
|
||||
fields: [
|
||||
{
|
||||
id: 'apiToken',
|
||||
label: 'Team API key',
|
||||
placeholder: 'Paste Harmonic team API key',
|
||||
secret: true,
|
||||
},
|
||||
],
|
||||
docsUrl: 'https://docs.sim.ai/integrations/harmonic',
|
||||
helpText:
|
||||
"Harmonic API keys belong to a team and share access to that team's saved searches.",
|
||||
})
|
||||
expect(getTokenServiceAccountValidator(HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID)).toBe(
|
||||
validateHarmonicServiceAccount
|
||||
)
|
||||
expect(OAUTH_PROVIDERS.harmonic.services.harmonic).toMatchObject({
|
||||
providerId: 'harmonic',
|
||||
serviceAccountProviderId: HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
authType: 'service_account',
|
||||
scopes: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
fetchProvider,
|
||||
TokenServiceAccountValidationError,
|
||||
} from '@/lib/credentials/token-service-accounts/errors'
|
||||
import type {
|
||||
TokenServiceAccountFields,
|
||||
TokenServiceAccountValidationResult,
|
||||
} from '@/lib/credentials/token-service-accounts/server'
|
||||
|
||||
const HARMONIC_SAVED_SEARCHES_URL = 'https://api.harmonic.ai/savedSearches'
|
||||
|
||||
async function discardResponseBody(response: Response): Promise<void> {
|
||||
try {
|
||||
await response.body?.cancel()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Harmonic team API key by listing saved searches. Harmonic does
|
||||
* not expose an identity endpoint for API keys, so the documented HTTP 200 is
|
||||
* the entire validation contract and the response body is discarded. Provider
|
||||
* error bodies are discarded too: they are not needed for this decision and
|
||||
* must never be able to echo the submitted key into logs.
|
||||
*/
|
||||
export async function validateHarmonicServiceAccount(
|
||||
fields: TokenServiceAccountFields
|
||||
): Promise<TokenServiceAccountValidationResult> {
|
||||
const response = await fetchProvider(
|
||||
HARMONIC_SAVED_SEARCHES_URL,
|
||||
{
|
||||
headers: {
|
||||
apikey: fields.apiToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
redirect: 'error',
|
||||
},
|
||||
'saved_searches'
|
||||
)
|
||||
|
||||
if (response.status !== 200) {
|
||||
await discardResponseBody(response)
|
||||
throw new TokenServiceAccountValidationError(
|
||||
response.status === 401 || response.status === 403
|
||||
? 'invalid_credentials'
|
||||
: 'provider_unavailable',
|
||||
response.status,
|
||||
{
|
||||
step: 'saved_searches',
|
||||
reason: `provider returned HTTP ${response.status}`,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
await discardResponseBody(response)
|
||||
|
||||
return {
|
||||
displayName: `Harmonic (…${fields.apiToken.slice(-4)})`,
|
||||
principal: null,
|
||||
auditMetadata: {},
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ const EXPECTED_COVERAGE: Record<string, string[]> = {
|
||||
'google-tasks',
|
||||
'google-vault',
|
||||
],
|
||||
'harmonic-service-account': [],
|
||||
'hubspot-service-account': ['hubspot'],
|
||||
'linear-service-account': ['linear'],
|
||||
'monday-service-account': ['monday'],
|
||||
|
||||
@@ -112,6 +112,7 @@ import {
|
||||
GranolaIcon,
|
||||
GreenhouseIcon,
|
||||
GreptileIcon,
|
||||
HarmonicIcon,
|
||||
HexIcon,
|
||||
HubspotIcon,
|
||||
HuggingFaceIcon,
|
||||
@@ -384,6 +385,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
granola: GranolaIcon,
|
||||
greenhouse: GreenhouseIcon,
|
||||
greptile: GreptileIcon,
|
||||
harmonic: HarmonicIcon,
|
||||
hex: HexIcon,
|
||||
hubspot: HubspotIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
GoogleSheetsIcon,
|
||||
GoogleTasksIcon,
|
||||
GoogleVaultIcon,
|
||||
HarmonicIcon,
|
||||
HubspotIcon,
|
||||
InstagramIcon,
|
||||
JiraIcon,
|
||||
@@ -1155,6 +1156,23 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
},
|
||||
defaultService: 'hubspot',
|
||||
},
|
||||
harmonic: {
|
||||
name: 'Harmonic',
|
||||
icon: HarmonicIcon,
|
||||
services: {
|
||||
harmonic: {
|
||||
name: 'Harmonic',
|
||||
description: 'Search and enrich people with Harmonic data.',
|
||||
providerId: 'harmonic',
|
||||
serviceAccountProviderId: 'harmonic-service-account',
|
||||
icon: HarmonicIcon,
|
||||
baseProviderIcon: HarmonicIcon,
|
||||
scopes: [],
|
||||
authType: 'service_account',
|
||||
},
|
||||
},
|
||||
defaultService: 'harmonic',
|
||||
},
|
||||
linkedin: {
|
||||
name: 'LinkedIn',
|
||||
icon: LinkedInIcon,
|
||||
|
||||
@@ -78,6 +78,7 @@ export type OAuthProvider =
|
||||
| 'attio'
|
||||
| 'pipedrive'
|
||||
| 'hubspot'
|
||||
| 'harmonic'
|
||||
| 'salesforce'
|
||||
| 'linkedin'
|
||||
| 'instagram'
|
||||
@@ -135,6 +136,7 @@ export type OAuthService =
|
||||
| 'attio'
|
||||
| 'pipedrive'
|
||||
| 'hubspot'
|
||||
| 'harmonic'
|
||||
| 'salesforce'
|
||||
| 'linkedin'
|
||||
| 'instagram'
|
||||
|
||||
@@ -210,6 +210,67 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
|
||||
examples: ['Notion', 'Discord', 'GitHub', 'Twilio', 'Slack'],
|
||||
extract: (errorInfo) => errorInfo?.data?.message,
|
||||
},
|
||||
{
|
||||
id: 'harmonic-errors',
|
||||
description:
|
||||
'Harmonic API message errors, string and object FastAPI detail aborts including the enrichment URN, and validation detail arrays without echoed request input',
|
||||
examples: ['Harmonic'],
|
||||
extract: (errorInfo) => {
|
||||
const data = errorInfo?.data
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined
|
||||
|
||||
const message = typeof data.message === 'string' ? data.message.trim() : ''
|
||||
if (message) return message
|
||||
|
||||
/**
|
||||
* Harmonic's Kong edge answers with `message`, but the FastAPI application
|
||||
* behind it renders every non-422 abort as a bare string `detail`. Without
|
||||
* this branch those become `Request failed with status 403`, because a tool
|
||||
* that names an extractor gets no fallback chain.
|
||||
*/
|
||||
if (typeof data.detail === 'string') {
|
||||
const detail = data.detail.trim()
|
||||
return detail || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /persons` answers 404 with an object detail carrying the enrichment
|
||||
* Harmonic just scheduled. The URN is the only handle on that job, so it is
|
||||
* appended to the message rather than dropped with the rest of the envelope.
|
||||
*/
|
||||
if (data.detail && typeof data.detail === 'object' && !Array.isArray(data.detail)) {
|
||||
const detail = data.detail as { message?: unknown; enrichment_urn?: unknown }
|
||||
const detailMessage = typeof detail.message === 'string' ? detail.message.trim() : ''
|
||||
if (!detailMessage) return undefined
|
||||
const enrichmentUrn =
|
||||
typeof detail.enrichment_urn === 'string' ? detail.enrichment_urn.trim() : ''
|
||||
return enrichmentUrn ? `${detailMessage} (${enrichmentUrn})` : detailMessage
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.detail)) return undefined
|
||||
const details = data.detail
|
||||
.map((entry: unknown) => {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return ''
|
||||
const validation = entry as { loc?: unknown; msg?: unknown }
|
||||
const detail = typeof validation.msg === 'string' ? validation.msg.trim() : ''
|
||||
if (!detail) return ''
|
||||
|
||||
const location = Array.isArray(validation.loc)
|
||||
? validation.loc
|
||||
.filter(
|
||||
(segment): segment is string | number =>
|
||||
typeof segment === 'string' || typeof segment === 'number'
|
||||
)
|
||||
.map(String)
|
||||
: []
|
||||
const fieldPath = location[0] === 'body' ? location.slice(1) : location
|
||||
return fieldPath.length > 0 ? `${fieldPath.join('.')}: ${detail}` : detail
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
return details.length > 0 ? details.join('; ') : undefined
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'soap-fault',
|
||||
description: 'SOAP/XML fault string patterns',
|
||||
@@ -430,6 +491,7 @@ export const ErrorExtractorId = {
|
||||
ERRORS_ARRAY_STRING: 'errors-array-string',
|
||||
TELEGRAM_DESCRIPTION: 'telegram-description',
|
||||
STANDARD_MESSAGE: 'standard-message',
|
||||
HARMONIC_ERRORS: 'harmonic-errors',
|
||||
SOAP_FAULT: 'soap-fault',
|
||||
OAUTH_ERROR_DESCRIPTION: 'oauth-error-description',
|
||||
NESTED_ERROR_OBJECT: 'nested-error-object',
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,68 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import {
|
||||
HARMONIC_CONTACT_OUTPUT_PROPERTIES,
|
||||
type HarmonicBatchGetPeopleParams,
|
||||
type HarmonicBatchGetPeopleResponse,
|
||||
} from '@/tools/harmonic/types'
|
||||
import {
|
||||
buildBatchGetPeopleBody,
|
||||
HARMONIC_API_BASE,
|
||||
harmonicHeaders,
|
||||
normalizePersonArray,
|
||||
} from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicBatchGetPeopleTool: ToolConfig<
|
||||
HarmonicBatchGetPeopleParams,
|
||||
HarmonicBatchGetPeopleResponse
|
||||
> = {
|
||||
id: 'harmonic_batch_get_people',
|
||||
name: 'Harmonic Batch Get People',
|
||||
description:
|
||||
'Fetch full Harmonic person profiles for up to 500 combined numeric IDs and person URNs.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
personIds: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Array of numeric Harmonic person IDs; may be a JSON-array string',
|
||||
},
|
||||
personUrns: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Array of Harmonic person URNs; may be a JSON-array string',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: `${HARMONIC_API_BASE}/persons/batchGet`,
|
||||
method: 'POST',
|
||||
headers: (params) => harmonicHeaders(params.accessToken, { json: true }),
|
||||
body: (params) => buildBatchGetPeopleBody(params.personIds, params.personUrns),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const contacts = normalizePersonArray(await response.json())
|
||||
return { success: true, output: { contacts, count: contacts.length } }
|
||||
},
|
||||
|
||||
outputs: {
|
||||
contacts: {
|
||||
type: 'array',
|
||||
description: 'Fetched Harmonic person profiles normalized as contacts',
|
||||
items: { type: 'object', properties: HARMONIC_CONTACT_OUTPUT_PROPERTIES },
|
||||
},
|
||||
count: { type: 'number', description: 'Number of contacts returned' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
HarmonicClearPeopleSavedSearchNetNewResultsParams,
|
||||
HarmonicClearPeopleSavedSearchNetNewResultsResponse,
|
||||
} from '@/tools/harmonic/types'
|
||||
import {
|
||||
buildClearNetNewResultsUrl,
|
||||
harmonicHeaders,
|
||||
parsePersonUrns,
|
||||
} from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicClearPeopleSavedSearchNetNewResultsTool: ToolConfig<
|
||||
HarmonicClearPeopleSavedSearchNetNewResultsParams,
|
||||
HarmonicClearPeopleSavedSearchNetNewResultsResponse
|
||||
> = {
|
||||
id: 'harmonic_clear_people_saved_search_net_new_results',
|
||||
name: 'Harmonic Clear People Saved Search Net-New Results',
|
||||
description:
|
||||
'Acknowledge net-new people on a saved search so the next poll returns only fresh matches. Clearing everything requires setting the scope explicitly.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
savedSearchId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'People saved-search ID or full Harmonic saved-search URN',
|
||||
},
|
||||
personUrns: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Person URNs to acknowledge when clearScope is "selected". May be a JSON-array string',
|
||||
},
|
||||
clearScope: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Either "selected" (default, acknowledge only the listed URNs) or "all" (clear every net-new result)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
buildClearNetNewResultsUrl(params.savedSearchId, params.personUrns, params.clearScope),
|
||||
method: 'POST',
|
||||
headers: (params) => harmonicHeaders(params.accessToken),
|
||||
},
|
||||
|
||||
/**
|
||||
* Harmonic documents no response body for this endpoint — its OpenAPI entry
|
||||
* declares an empty 200 schema — so nothing is parsed out of it. The acknowledged
|
||||
* URNs are echoed back from the request so a workflow can chain on them.
|
||||
*/
|
||||
transformResponse: async (response, params) => {
|
||||
await response.body?.cancel().catch(() => {})
|
||||
const clearedEverything = params?.clearScope === 'all'
|
||||
const requested = clearedEverything ? [] : parsePersonUrns(params?.personUrns)
|
||||
return {
|
||||
success: true,
|
||||
output: { cleared: true, clearedPersonUrns: clearedEverything ? null : requested },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
cleared: { type: 'boolean', description: 'Whether Harmonic accepted the acknowledgement' },
|
||||
clearedPersonUrns: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'Person URNs acknowledged, or null when every net-new result was cleared',
|
||||
items: { type: 'string', description: 'Harmonic person URN' },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import {
|
||||
HARMONIC_CONTACT_OUTPUT_PROPERTIES,
|
||||
type HarmonicEnrichPersonParams,
|
||||
type HarmonicEnrichPersonResponse,
|
||||
} from '@/tools/harmonic/types'
|
||||
import {
|
||||
buildEnrichPersonUrl,
|
||||
harmonicHeaders,
|
||||
normalizeOptionalPerson,
|
||||
nullableResponseString,
|
||||
responseRecord,
|
||||
} from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicEnrichPersonTool: ToolConfig<
|
||||
HarmonicEnrichPersonParams,
|
||||
HarmonicEnrichPersonResponse
|
||||
> = {
|
||||
id: 'harmonic_enrich_person',
|
||||
name: 'Harmonic Enrich Person',
|
||||
description:
|
||||
'Resolve a LinkedIn profile URL or email address into a normalized Harmonic contact, queueing enrichment when the person is not yet in Harmonic.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
linkedinUrl: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'LinkedIn profile URL, e.g. https://www.linkedin.com/in/example',
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Email address used as a fallback when the LinkedIn URL is absent or unmatched',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => buildEnrichPersonUrl(params.linkedinUrl, params.email),
|
||||
method: 'POST',
|
||||
headers: (params) => harmonicHeaders(params.accessToken),
|
||||
},
|
||||
|
||||
/**
|
||||
* Harmonic answers 200 for a fresh record and 201 when it queued a background
|
||||
* refresh; both bodies are a person, so both are projected the same way.
|
||||
*
|
||||
* A 404 means the person is not in Harmonic yet and enrichment was scheduled.
|
||||
* The shared executor rejects every non-2xx before `transformResponse` runs, so
|
||||
* that case never reaches this projection; the Harmonic error extractor lifts
|
||||
* both the message and the scheduled `enrichment_urn` out of the 404 envelope so
|
||||
* the job stays pollable with Get Enrichment Status.
|
||||
*/
|
||||
transformResponse: async (response) => {
|
||||
const payload = await response.json()
|
||||
const contact = normalizeOptionalPerson(payload)
|
||||
if (!contact) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
contact: null,
|
||||
enrichmentUrn: null,
|
||||
mergedPersonUrn: null,
|
||||
requestedEntityUrn: null,
|
||||
found: false,
|
||||
enrichmentQueued: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = responseRecord(payload, 'person enrichment')
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
contact,
|
||||
enrichmentUrn: nullableResponseString(data.enrichment_urn),
|
||||
mergedPersonUrn: nullableResponseString(data.merged_person_urn),
|
||||
requestedEntityUrn: nullableResponseString(data.requested_entity_urn),
|
||||
found: true,
|
||||
enrichmentQueued: response.status === 201,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
contact: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'Normalized Harmonic contact, or null when the person is not yet in Harmonic',
|
||||
properties: HARMONIC_CONTACT_OUTPUT_PROPERTIES,
|
||||
},
|
||||
enrichmentUrn: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description:
|
||||
'Enrichment URN to poll with Get Enrichment Status when Harmonic queued a refresh',
|
||||
},
|
||||
mergedPersonUrn: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'URN this person was merged into, when Harmonic deduplicated the record',
|
||||
},
|
||||
requestedEntityUrn: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Person URN Harmonic matched the request to',
|
||||
},
|
||||
found: { type: 'boolean', description: 'Whether Harmonic returned a person profile' },
|
||||
enrichmentQueued: {
|
||||
type: 'boolean',
|
||||
description: 'Whether Harmonic queued a background refresh (HTTP 201) for this person',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import {
|
||||
HARMONIC_PAGE_INFO_OUTPUT_PROPERTIES,
|
||||
type HarmonicGetCompanyEmployeesParams,
|
||||
type HarmonicGetCompanyEmployeesResponse,
|
||||
} from '@/tools/harmonic/types'
|
||||
import {
|
||||
buildCompanyEmployeesUrl,
|
||||
harmonicHeaders,
|
||||
normalizePageInfo,
|
||||
normalizePersonUrnList,
|
||||
nullableResponseNumber,
|
||||
responseRecord,
|
||||
} from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicGetCompanyEmployeesTool: ToolConfig<
|
||||
HarmonicGetCompanyEmployeesParams,
|
||||
HarmonicGetCompanyEmployeesResponse
|
||||
> = {
|
||||
id: 'harmonic_get_company_employees',
|
||||
name: 'Harmonic Get Company Employees',
|
||||
description:
|
||||
'List person URNs for a company, filtered by role group and employment status. Pair with Batch Get People to hydrate contacts.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
companyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Harmonic company ID or full company URN',
|
||||
},
|
||||
employeeGroupType: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Role group: CEO, FOUNDERS_AND_CEO, EXECUTIVES, FOUNDERS, LEADERSHIP, NON_LEADERSHIP, ALL, ADVISORS, NON_PARTNERS (default ALL)',
|
||||
},
|
||||
employeeStatus: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Employment status: ACTIVE, NOT_ACTIVE, or ACTIVE_AND_NOT_ACTIVE (default ACTIVE)',
|
||||
},
|
||||
userConnectionStatus: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Connection filter: TEAM_CONNECTION or NO_CONNECTION. Harmonic documents per-user connection filtering as unsupported via the API',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Results to return; Sim caps this at 100 per page (default 50)',
|
||||
},
|
||||
cursor: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Opaque next-page cursor from a previous response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
buildCompanyEmployeesUrl(params.companyId, {
|
||||
employeeGroupType: params.employeeGroupType,
|
||||
employeeStatus: params.employeeStatus,
|
||||
userConnectionStatus: params.userConnectionStatus,
|
||||
size: params.size,
|
||||
cursor: params.cursor,
|
||||
}),
|
||||
method: 'GET',
|
||||
headers: (params) => harmonicHeaders(params.accessToken),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = responseRecord(await response.json(), 'company employees')
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
personUrns: normalizePersonUrnList(data.results, 'company employees'),
|
||||
totalCount: nullableResponseNumber(data.count),
|
||||
pageInfo: normalizePageInfo(data.page_info),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
personUrns: {
|
||||
type: 'array',
|
||||
description: 'Person URNs for the matching employees; Harmonic returns URNs only',
|
||||
items: { type: 'string', description: 'Harmonic person URN' },
|
||||
},
|
||||
totalCount: { type: 'number', nullable: true, description: 'Total matching employees' },
|
||||
pageInfo: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'Cursor pagination metadata',
|
||||
properties: HARMONIC_PAGE_INFO_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import {
|
||||
HARMONIC_EMAIL_JOB_COUNTS_OUTPUT_PROPERTIES,
|
||||
HARMONIC_EMAIL_JOB_ITEM_OUTPUT_PROPERTIES,
|
||||
type HarmonicGetEmailEnrichmentJobParams,
|
||||
type HarmonicGetEmailEnrichmentJobResponse,
|
||||
} from '@/tools/harmonic/types'
|
||||
import {
|
||||
HARMONIC_API_BASE,
|
||||
HARMONIC_EMAIL_JOB_TERMINAL_STATUSES,
|
||||
harmonicHeaders,
|
||||
normalizeEmailJobCounts,
|
||||
normalizeEmailJobResults,
|
||||
nullableResponseString,
|
||||
requireIdentifier,
|
||||
responseRecord,
|
||||
} from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicGetEmailEnrichmentJobTool: ToolConfig<
|
||||
HarmonicGetEmailEnrichmentJobParams,
|
||||
HarmonicGetEmailEnrichmentJobResponse
|
||||
> = {
|
||||
id: 'harmonic_get_email_enrichment_job',
|
||||
name: 'Harmonic Get Email Enrichment Job',
|
||||
description:
|
||||
'Check a Harmonic bulk email enrichment job. Per-person results appear once the job is terminal; fetch the emails with Get Person or Batch Get People.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
jobId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Job ID returned by Submit Email Enrichment Job',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
`${HARMONIC_API_BASE}/email_enrichment/jobs/${encodeURIComponent(
|
||||
requireIdentifier(params.jobId, 'jobId')
|
||||
)}`,
|
||||
method: 'GET',
|
||||
headers: (params) => harmonicHeaders(params.accessToken),
|
||||
},
|
||||
|
||||
/**
|
||||
* Harmonic leaves `results` null until the job reaches a terminal state, and the
|
||||
* per-person rows carry a status only — never an email. `succeededPersonUrns` is
|
||||
* the hand-off into Batch Get People, where the resolved address arrives in `contact`.
|
||||
*/
|
||||
transformResponse: async (response) => {
|
||||
const data = responseRecord(await response.json(), 'email enrichment job')
|
||||
const jobId = nullableResponseString(data.job_id)
|
||||
const status = nullableResponseString(data.status)
|
||||
if (!jobId || !status) {
|
||||
throw new Error('Harmonic returned an email enrichment job without an ID or status')
|
||||
}
|
||||
|
||||
const results = normalizeEmailJobResults(data.results)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
jobId,
|
||||
status,
|
||||
isTerminal: HARMONIC_EMAIL_JOB_TERMINAL_STATUSES.has(status),
|
||||
counts: normalizeEmailJobCounts(data.counts),
|
||||
results,
|
||||
succeededPersonUrns: (results ?? [])
|
||||
.filter((item) => item.status === 'SUCCESS')
|
||||
.map((item) => item.personUrn),
|
||||
createdAt: nullableResponseString(data.created_at) ?? '',
|
||||
completedAt: nullableResponseString(data.completed_at),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
jobId: { type: 'string', description: 'Job identifier' },
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Job status (PENDING, IN_PROGRESS, COMPLETED, FAILED)',
|
||||
},
|
||||
isTerminal: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the job finished, meaning results will no longer change',
|
||||
},
|
||||
counts: {
|
||||
type: 'object',
|
||||
description: 'Per-outcome tallies for the job',
|
||||
properties: HARMONIC_EMAIL_JOB_COUNTS_OUTPUT_PROPERTIES,
|
||||
},
|
||||
results: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'Per-person outcomes; null until the job reaches a terminal status',
|
||||
items: { type: 'object', properties: HARMONIC_EMAIL_JOB_ITEM_OUTPUT_PROPERTIES },
|
||||
},
|
||||
succeededPersonUrns: {
|
||||
type: 'array',
|
||||
description: 'Person URNs whose email was found; pass these to Batch Get People',
|
||||
items: { type: 'string', description: 'Harmonic person URN' },
|
||||
},
|
||||
createdAt: { type: 'string', description: 'Job creation timestamp' },
|
||||
completedAt: { type: 'string', nullable: true, description: 'Job completion timestamp' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
HarmonicGetEmailEnrichmentUsageParams,
|
||||
HarmonicGetEmailEnrichmentUsageResponse,
|
||||
} from '@/tools/harmonic/types'
|
||||
import { HARMONIC_API_BASE, harmonicHeaders, responseRecord } from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicGetEmailEnrichmentUsageTool: ToolConfig<
|
||||
HarmonicGetEmailEnrichmentUsageParams,
|
||||
HarmonicGetEmailEnrichmentUsageResponse
|
||||
> = {
|
||||
id: 'harmonic_get_email_enrichment_usage',
|
||||
name: 'Harmonic Get Email Enrichment Usage',
|
||||
description:
|
||||
'Read the team monthly email-enrichment quota. Check this before a large batch to avoid a quota rejection.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: `${HARMONIC_API_BASE}/email_enrichment/usage`,
|
||||
method: 'GET',
|
||||
headers: (params) => harmonicHeaders(params.accessToken),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = responseRecord(await response.json(), 'email enrichment usage')
|
||||
const counters = ['monthly_usage', 'monthly_limit', 'monthly_remaining'] as const
|
||||
for (const counter of counters) {
|
||||
if (typeof data[counter] !== 'number' || !Number.isFinite(data[counter] as number)) {
|
||||
throw new Error('Harmonic returned email enrichment usage without usable counters')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
monthlyUsage: data.monthly_usage as number,
|
||||
monthlyLimit: data.monthly_limit as number,
|
||||
monthlyRemaining: data.monthly_remaining as number,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
monthlyUsage: { type: 'number', description: 'Emails enriched so far this month' },
|
||||
monthlyLimit: { type: 'number', description: 'Monthly email enrichment allowance' },
|
||||
monthlyRemaining: { type: 'number', description: 'Enrichments left this month' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import {
|
||||
HARMONIC_ENRICHMENT_STATUS_OUTPUT_PROPERTIES,
|
||||
type HarmonicGetEnrichmentStatusParams,
|
||||
type HarmonicGetEnrichmentStatusResponse,
|
||||
} from '@/tools/harmonic/types'
|
||||
import {
|
||||
buildEnrichmentStatusUrl,
|
||||
harmonicHeaders,
|
||||
normalizeEnrichmentStatuses,
|
||||
} from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicGetEnrichmentStatusTool: ToolConfig<
|
||||
HarmonicGetEnrichmentStatusParams,
|
||||
HarmonicGetEnrichmentStatusResponse
|
||||
> = {
|
||||
id: 'harmonic_get_enrichment_status',
|
||||
name: 'Harmonic Get Enrichment Status',
|
||||
description:
|
||||
'Check enrichment jobs Harmonic queued for people it did not already have, and read the person URN each one produced.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
enrichmentUrns: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Array of Harmonic enrichment URNs or bare enrichment UUIDs from Enrich Person; may be a JSON-array string',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => buildEnrichmentStatusUrl(params.enrichmentUrns),
|
||||
method: 'GET',
|
||||
headers: (params) => harmonicHeaders(params.accessToken),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const enrichments = normalizeEnrichmentStatuses(await response.json())
|
||||
return { success: true, output: { enrichments, count: enrichments.length } }
|
||||
},
|
||||
|
||||
outputs: {
|
||||
enrichments: {
|
||||
type: 'array',
|
||||
description: 'Status of each requested enrichment job',
|
||||
items: { type: 'object', properties: HARMONIC_ENRICHMENT_STATUS_OUTPUT_PROPERTIES },
|
||||
},
|
||||
count: { type: 'number', description: 'Number of enrichment statuses returned' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import {
|
||||
HARMONIC_CONTACT_OUTPUT_PROPERTIES,
|
||||
HARMONIC_PAGE_INFO_OUTPUT_PROPERTIES,
|
||||
type HarmonicGetPeopleSavedSearchNetNewResultsParams,
|
||||
type HarmonicGetPeopleSavedSearchNetNewResultsResponse,
|
||||
} from '@/tools/harmonic/types'
|
||||
import {
|
||||
buildNetNewResultsUrl,
|
||||
harmonicHeaders,
|
||||
normalizePageInfo,
|
||||
normalizePeopleResults,
|
||||
nullableResponseString,
|
||||
responseRecord,
|
||||
} from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicGetPeopleSavedSearchNetNewResultsTool: ToolConfig<
|
||||
HarmonicGetPeopleSavedSearchNetNewResultsParams,
|
||||
HarmonicGetPeopleSavedSearchNetNewResultsResponse
|
||||
> = {
|
||||
id: 'harmonic_get_people_saved_search_net_new_results',
|
||||
name: 'Harmonic Get People Saved Search Net-New Results',
|
||||
description:
|
||||
'Get only the people newly matching a subscribed Harmonic people saved search, so a monitor does not reprocess the whole result set.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
savedSearchId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'People saved-search ID or full Harmonic saved-search URN',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Results to return; Sim caps this at 100 per page (default 50)',
|
||||
},
|
||||
cursor: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Opaque next-page cursor from a previous response',
|
||||
},
|
||||
newResultsSince: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Only return matches after this UTC point, as YYYY-MM-DD or YYYY-MM-DDTHH:00:00Z',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
buildNetNewResultsUrl(
|
||||
params.savedSearchId,
|
||||
params.size,
|
||||
params.cursor,
|
||||
params.newResultsSince
|
||||
),
|
||||
method: 'GET',
|
||||
headers: (params) => harmonicHeaders(params.accessToken),
|
||||
},
|
||||
|
||||
/**
|
||||
* Harmonic keys this collection `urns` rather than `results`, but the element
|
||||
* union is identical to the saved-search results endpoint, so the shared
|
||||
* person projection applies unchanged.
|
||||
*/
|
||||
transformResponse: async (response) => {
|
||||
const data = responseRecord(await response.json(), 'saved-search net-new results')
|
||||
const people = normalizePeopleResults(data.urns)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
...people,
|
||||
cursor: nullableResponseString(data.cursor),
|
||||
pageInfo: normalizePageInfo(data.page_info),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
contacts: {
|
||||
type: 'array',
|
||||
description: 'Newly matching people returned as full profiles, normalized as contacts',
|
||||
items: { type: 'object', properties: HARMONIC_CONTACT_OUTPUT_PROPERTIES },
|
||||
},
|
||||
personUrns: {
|
||||
type: 'array',
|
||||
description: 'All newly matching person URNs, including rows returned without full profiles',
|
||||
items: { type: 'string', description: 'Harmonic person URN' },
|
||||
},
|
||||
cursor: { type: 'string', nullable: true, description: 'Cursor echoed by Harmonic' },
|
||||
pageInfo: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'Cursor pagination metadata',
|
||||
properties: HARMONIC_PAGE_INFO_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import {
|
||||
HARMONIC_CONTACT_OUTPUT_PROPERTIES,
|
||||
HARMONIC_PAGE_INFO_OUTPUT_PROPERTIES,
|
||||
type HarmonicGetPeopleSavedSearchResultsParams,
|
||||
type HarmonicGetPeopleSavedSearchResultsResponse,
|
||||
} from '@/tools/harmonic/types'
|
||||
import {
|
||||
buildPagedUrl,
|
||||
harmonicHeaders,
|
||||
normalizePageInfo,
|
||||
normalizePeopleResults,
|
||||
nullableResponseNumber,
|
||||
requireIdentifier,
|
||||
responseRecord,
|
||||
} from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicGetPeopleSavedSearchResultsTool: ToolConfig<
|
||||
HarmonicGetPeopleSavedSearchResultsParams,
|
||||
HarmonicGetPeopleSavedSearchResultsResponse
|
||||
> = {
|
||||
id: 'harmonic_get_people_saved_search_results',
|
||||
name: 'Harmonic Get People Saved Search Results',
|
||||
description:
|
||||
'Get one page of a Harmonic people saved search. Full records become contacts; URN-only rows are exposed for Batch Get People.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
savedSearchId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'People saved-search ID or full Harmonic saved-search URN',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Results to return; Sim caps this at 100 per page (default 50)',
|
||||
},
|
||||
cursor: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Opaque next-page cursor from a previous response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
buildPagedUrl(
|
||||
`/savedSearches:results/${encodeURIComponent(
|
||||
requireIdentifier(params.savedSearchId, 'savedSearchId')
|
||||
)}`,
|
||||
params.size,
|
||||
params.cursor
|
||||
),
|
||||
method: 'GET',
|
||||
headers: (params) => harmonicHeaders(params.accessToken),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = responseRecord(await response.json(), 'saved-search results')
|
||||
const people = normalizePeopleResults(data.results)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
...people,
|
||||
totalCount: nullableResponseNumber(data.count),
|
||||
pageInfo: normalizePageInfo(data.page_info),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
contacts: {
|
||||
type: 'array',
|
||||
description: 'Full person records returned by the saved search, normalized as contacts',
|
||||
items: { type: 'object', properties: HARMONIC_CONTACT_OUTPUT_PROPERTIES },
|
||||
},
|
||||
personUrns: {
|
||||
type: 'array',
|
||||
description: 'All person URNs in the page, including rows returned without full profiles',
|
||||
items: { type: 'string', description: 'Harmonic person URN' },
|
||||
},
|
||||
totalCount: { type: 'number', nullable: true, description: 'Total matching people' },
|
||||
pageInfo: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'Cursor pagination metadata',
|
||||
properties: HARMONIC_PAGE_INFO_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import {
|
||||
HARMONIC_CONTACT_OUTPUT_PROPERTIES,
|
||||
type HarmonicGetPersonParams,
|
||||
type HarmonicGetPersonResponse,
|
||||
} from '@/tools/harmonic/types'
|
||||
import { buildGetPersonUrl, harmonicHeaders, normalizeOptionalPerson } from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicGetPersonTool: ToolConfig<HarmonicGetPersonParams, HarmonicGetPersonResponse> =
|
||||
{
|
||||
id: 'harmonic_get_person',
|
||||
name: 'Harmonic Get Person',
|
||||
description:
|
||||
'Fetch one Harmonic person by numeric ID or URN, including any email resolved by a completed enrichment job.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
personId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Harmonic person ID or full person URN',
|
||||
},
|
||||
companyContextUrns: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Company URNs used to scope the returned experience context; may be a JSON-array string',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => buildGetPersonUrl(params.personId, params.companyContextUrns),
|
||||
method: 'GET',
|
||||
headers: (params) => harmonicHeaders(params.accessToken),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const contact = normalizeOptionalPerson(await response.json())
|
||||
return { success: true, output: { contact, found: contact !== null } }
|
||||
},
|
||||
|
||||
outputs: {
|
||||
contact: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'Normalized Harmonic contact, or null when Harmonic has no such person',
|
||||
properties: HARMONIC_CONTACT_OUTPUT_PROPERTIES,
|
||||
},
|
||||
found: { type: 'boolean', description: 'Whether Harmonic returned a person profile' },
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
export { harmonicBatchGetPeopleTool } from '@/tools/harmonic/batch_get_people'
|
||||
export { harmonicClearPeopleSavedSearchNetNewResultsTool } from '@/tools/harmonic/clear_people_saved_search_net_new_results'
|
||||
export { harmonicEnrichPersonTool } from '@/tools/harmonic/enrich_person'
|
||||
export { harmonicGetCompanyEmployeesTool } from '@/tools/harmonic/get_company_employees'
|
||||
export { harmonicGetEmailEnrichmentJobTool } from '@/tools/harmonic/get_email_enrichment_job'
|
||||
export { harmonicGetEmailEnrichmentUsageTool } from '@/tools/harmonic/get_email_enrichment_usage'
|
||||
export { harmonicGetEnrichmentStatusTool } from '@/tools/harmonic/get_enrichment_status'
|
||||
export { harmonicGetPeopleSavedSearchNetNewResultsTool } from '@/tools/harmonic/get_people_saved_search_net_new_results'
|
||||
export { harmonicGetPeopleSavedSearchResultsTool } from '@/tools/harmonic/get_people_saved_search_results'
|
||||
export { harmonicGetPersonTool } from '@/tools/harmonic/get_person'
|
||||
export { harmonicListPeopleSavedSearchesTool } from '@/tools/harmonic/list_people_saved_searches'
|
||||
export { harmonicSearchPeopleScoutTool } from '@/tools/harmonic/search_people_scout'
|
||||
export { harmonicSubmitEmailEnrichmentJobTool } from '@/tools/harmonic/submit_email_enrichment_job'
|
||||
export type * from '@/tools/harmonic/types'
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import {
|
||||
HARMONIC_SAVED_SEARCH_OUTPUT_PROPERTIES,
|
||||
type HarmonicListPeopleSavedSearchesParams,
|
||||
type HarmonicListPeopleSavedSearchesResponse,
|
||||
type HarmonicSavedSearchOutput,
|
||||
} from '@/tools/harmonic/types'
|
||||
import {
|
||||
HARMONIC_API_BASE,
|
||||
harmonicHeaders,
|
||||
normalizeSavedSearch,
|
||||
responseArray,
|
||||
responseRecord,
|
||||
} from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicListPeopleSavedSearchesTool: ToolConfig<
|
||||
HarmonicListPeopleSavedSearchesParams,
|
||||
HarmonicListPeopleSavedSearchesResponse
|
||||
> = {
|
||||
id: 'harmonic_list_people_saved_searches',
|
||||
name: 'Harmonic List People Saved Searches',
|
||||
description:
|
||||
'List the team-shared Harmonic saved searches that target people. Use a returned ID or URN to fetch results.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: `${HARMONIC_API_BASE}/savedSearches`,
|
||||
method: 'GET',
|
||||
headers: (params) => harmonicHeaders(params.accessToken),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const savedSearches = responseArray(await response.json(), 'saved searches')
|
||||
.map((item) => responseRecord(item, 'saved search') as HarmonicSavedSearchOutput)
|
||||
.filter((item) => item.type === 'PERSONS')
|
||||
.map(normalizeSavedSearch)
|
||||
|
||||
return { success: true, output: { savedSearches, count: savedSearches.length } }
|
||||
},
|
||||
|
||||
outputs: {
|
||||
savedSearches: {
|
||||
type: 'array',
|
||||
description: 'Team-accessible Harmonic saved searches that target people',
|
||||
items: { type: 'object', properties: HARMONIC_SAVED_SEARCH_OUTPUT_PROPERTIES },
|
||||
},
|
||||
count: { type: 'number', description: 'Number of people saved searches returned' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import {
|
||||
HARMONIC_CONTACT_OUTPUT_PROPERTIES,
|
||||
type HarmonicScoutPerson,
|
||||
type HarmonicSearchPeopleScoutParams,
|
||||
type HarmonicSearchPeopleScoutResponse,
|
||||
} from '@/tools/harmonic/types'
|
||||
import {
|
||||
buildScoutBody,
|
||||
HARMONIC_API_BASE,
|
||||
harmonicHeaders,
|
||||
normalizeScoutPerson,
|
||||
nullableResponseString,
|
||||
responseArray,
|
||||
responseRecord,
|
||||
} from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicSearchPeopleScoutTool: ToolConfig<
|
||||
HarmonicSearchPeopleScoutParams,
|
||||
HarmonicSearchPeopleScoutResponse
|
||||
> = {
|
||||
id: 'harmonic_search_people_scout',
|
||||
name: 'Harmonic Search People with Scout',
|
||||
description:
|
||||
'Ask Harmonic Scout to find people using natural language and return a stable, workflow-ready contacts table.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Natural-language people research request, e.g. "Find forward-deployed engineers in enterprise software"',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: `${HARMONIC_API_BASE}/scout/tasks/wait`,
|
||||
method: 'POST',
|
||||
headers: (params) => harmonicHeaders(params.accessToken, { json: true }),
|
||||
body: (params) => buildScoutBody(params.query),
|
||||
modelInput: {
|
||||
mode: 'project',
|
||||
select: (params) => ({ query: params.query }),
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = responseRecord(await response.json(), 'Scout task')
|
||||
const taskId = nullableResponseString(data.task_id)
|
||||
const status = nullableResponseString(data.status)
|
||||
if (!taskId || !status) throw new Error('Harmonic Scout returned an invalid task response')
|
||||
|
||||
if (status !== 'success') {
|
||||
const detail = typeof data.content === 'string' ? data.content.trim() : ''
|
||||
throw new Error(
|
||||
`Harmonic Scout task ${taskId} ended with status "${status}"${detail ? `: ${detail}` : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
const content = responseRecord(data.content, 'Scout content')
|
||||
const contacts = responseArray(content.people, 'Scout people').map((person) =>
|
||||
normalizeScoutPerson(responseRecord(person, 'Scout person') as HarmonicScoutPerson)
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: { contacts, taskId, status, count: contacts.length },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
contacts: {
|
||||
type: 'array',
|
||||
description: 'People matching the Scout request, normalized for downstream workflow use',
|
||||
items: { type: 'object', properties: HARMONIC_CONTACT_OUTPUT_PROPERTIES },
|
||||
},
|
||||
taskId: { type: 'string', description: 'Harmonic Scout task identifier' },
|
||||
status: { type: 'string', description: 'Final Scout task status (success)' },
|
||||
count: { type: 'number', description: 'Number of contacts returned' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import {
|
||||
HARMONIC_DROPPED_IDENTIFIER_OUTPUT_PROPERTIES,
|
||||
type HarmonicSubmitEmailEnrichmentJobParams,
|
||||
type HarmonicSubmitEmailEnrichmentJobResponse,
|
||||
} from '@/tools/harmonic/types'
|
||||
import {
|
||||
buildEmailEnrichmentJobBody,
|
||||
HARMONIC_API_BASE,
|
||||
harmonicHeaders,
|
||||
normalizeDroppedIdentifiers,
|
||||
responseRecord,
|
||||
} from '@/tools/harmonic/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const harmonicSubmitEmailEnrichmentJobTool: ToolConfig<
|
||||
HarmonicSubmitEmailEnrichmentJobParams,
|
||||
HarmonicSubmitEmailEnrichmentJobResponse
|
||||
> = {
|
||||
id: 'harmonic_submit_email_enrichment_job',
|
||||
name: 'Harmonic Submit Email Enrichment Job',
|
||||
description:
|
||||
'Queue bulk email enrichment for up to 5,000 people, given either person URNs or LinkedIn profile URLs.',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: 'harmonic' },
|
||||
errorExtractor: ErrorExtractorId.HARMONIC_ERRORS,
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Harmonic credential resolved by the connected account',
|
||||
},
|
||||
personUrns: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Array of Harmonic person URNs, 1-5000; may be a JSON-array string. Mutually exclusive with personLinkedinUrls',
|
||||
},
|
||||
personLinkedinUrls: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Array of LinkedIn profile URLs, 1-5000; may be a JSON-array string. Mutually exclusive with personUrns',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: `${HARMONIC_API_BASE}/email_enrichment/jobs`,
|
||||
method: 'POST',
|
||||
headers: (params) => harmonicHeaders(params.accessToken, { json: true }),
|
||||
body: (params) => buildEmailEnrichmentJobBody(params.personUrns, params.personLinkedinUrls),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = responseRecord(await response.json(), 'email enrichment job')
|
||||
const jobId = typeof data.job_id === 'string' ? data.job_id.trim() : ''
|
||||
const status = typeof data.status === 'string' ? data.status.trim() : ''
|
||||
if (!jobId || !status) {
|
||||
throw new Error('Harmonic returned an email enrichment job without an ID or status')
|
||||
}
|
||||
if (typeof data.accepted_count !== 'number' || typeof data.monthly_remaining !== 'number') {
|
||||
throw new Error('Harmonic returned an email enrichment job without usable counters')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
jobId,
|
||||
status,
|
||||
acceptedCount: data.accepted_count,
|
||||
monthlyRemaining: data.monthly_remaining,
|
||||
createdAt: typeof data.created_at === 'string' ? data.created_at : '',
|
||||
dropped: normalizeDroppedIdentifiers(data.dropped),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
jobId: { type: 'string', description: 'Job identifier to poll with Get Email Enrichment Job' },
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Job status (PENDING, IN_PROGRESS, COMPLETED, FAILED)',
|
||||
},
|
||||
acceptedCount: { type: 'number', description: 'People accepted into the job' },
|
||||
monthlyRemaining: {
|
||||
type: 'number',
|
||||
description: 'Email enrichments left in the team monthly quota',
|
||||
},
|
||||
createdAt: { type: 'string', description: 'Job creation timestamp' },
|
||||
dropped: {
|
||||
type: 'array',
|
||||
description: 'Identifiers Harmonic dropped before queueing, with the reason for each',
|
||||
items: { type: 'object', properties: HARMONIC_DROPPED_IDENTIFIER_OUTPUT_PROPERTIES },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import type { OutputProperty, ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface HarmonicContact {
|
||||
personUrn: string | null
|
||||
personId: number | null
|
||||
fullName: string | null
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
headline: string | null
|
||||
currentTitles: string[] | null
|
||||
currentCompanyNames: string[] | null
|
||||
currentCompanyUrns: string[] | null
|
||||
primaryEmail: string | null
|
||||
emails: string[] | null
|
||||
phoneNumbers: string[] | null
|
||||
linkedinUrl: string | null
|
||||
formattedLocation: string | null
|
||||
city: string | null
|
||||
state: string | null
|
||||
country: string | null
|
||||
profilePictureUrl: string | null
|
||||
summary: string | null
|
||||
isRedacted: boolean | null
|
||||
}
|
||||
|
||||
export interface HarmonicPageInfo {
|
||||
nextCursor: string | null
|
||||
currentCursor: string | null
|
||||
hasNext: boolean
|
||||
}
|
||||
|
||||
export interface HarmonicSavedSearch {
|
||||
savedSearchId: number
|
||||
savedSearchUrn: string
|
||||
name: string
|
||||
isPrivate: boolean | null
|
||||
savedSearchType: 'PERSONS'
|
||||
userSavedSearchType: string
|
||||
creatorUrn: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface HarmonicContactMetadata {
|
||||
emails?: unknown
|
||||
phone_numbers?: unknown
|
||||
exec_emails?: unknown
|
||||
primary_email?: unknown
|
||||
}
|
||||
|
||||
export interface HarmonicLocationMetadata {
|
||||
address_formatted?: unknown
|
||||
location?: unknown
|
||||
city?: unknown
|
||||
state?: unknown
|
||||
country?: unknown
|
||||
}
|
||||
|
||||
export interface HarmonicSocialMetadata {
|
||||
url?: unknown
|
||||
}
|
||||
|
||||
export interface HarmonicExperienceMetadata {
|
||||
title?: unknown
|
||||
is_current_position?: unknown
|
||||
company?: unknown
|
||||
company_name?: unknown
|
||||
}
|
||||
|
||||
/** The documented subset of PersonOutput used by the contact projection. */
|
||||
export interface HarmonicPersonOutput {
|
||||
entity_urn?: unknown
|
||||
id?: unknown
|
||||
full_name?: unknown
|
||||
first_name?: unknown
|
||||
last_name?: unknown
|
||||
profile_picture_url?: unknown
|
||||
contact?: HarmonicContactMetadata | null
|
||||
location?: HarmonicLocationMetadata | null
|
||||
socials?: Record<string, HarmonicSocialMetadata> | null
|
||||
experience?: HarmonicExperienceMetadata[] | null
|
||||
linkedin_headline?: unknown
|
||||
current_company_urns?: unknown
|
||||
is_redacted?: unknown
|
||||
}
|
||||
|
||||
export interface HarmonicScoutPerson {
|
||||
name?: unknown
|
||||
linkedin_url?: unknown
|
||||
person_urn?: unknown
|
||||
title?: unknown
|
||||
company?: unknown
|
||||
location?: unknown
|
||||
email?: unknown
|
||||
one_liner?: unknown
|
||||
}
|
||||
|
||||
export interface HarmonicPaginationMetadata {
|
||||
next?: unknown
|
||||
current?: unknown
|
||||
has_next?: unknown
|
||||
}
|
||||
|
||||
export interface HarmonicSavedSearchOutput {
|
||||
id?: unknown
|
||||
entity_urn?: unknown
|
||||
name?: unknown
|
||||
is_private?: unknown
|
||||
type?: unknown
|
||||
user_saved_search_type?: unknown
|
||||
creator?: unknown
|
||||
created_at?: unknown
|
||||
updated_at?: unknown
|
||||
}
|
||||
|
||||
export interface HarmonicEnrichmentOutput {
|
||||
entity_urn?: unknown
|
||||
status?: unknown
|
||||
message?: unknown
|
||||
enriched_entity_urn?: unknown
|
||||
}
|
||||
|
||||
export interface HarmonicDroppedPerson {
|
||||
submitted_identifier?: unknown
|
||||
reason?: unknown
|
||||
}
|
||||
|
||||
export interface HarmonicPersonJobResultOutput {
|
||||
person_urn?: unknown
|
||||
status?: unknown
|
||||
}
|
||||
|
||||
export interface HarmonicPersonJobCountsOutput {
|
||||
total_processed?: unknown
|
||||
total_succeeded?: unknown
|
||||
total_failed?: unknown
|
||||
total_skipped?: unknown
|
||||
total_not_found?: unknown
|
||||
}
|
||||
|
||||
export interface HarmonicEnrichmentStatus {
|
||||
enrichmentUrn: string | null
|
||||
status: string | null
|
||||
message: string | null
|
||||
enrichedEntityUrn: string | null
|
||||
}
|
||||
|
||||
export interface HarmonicDroppedIdentifier {
|
||||
submittedIdentifier: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface HarmonicEmailJobItem {
|
||||
personUrn: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface HarmonicEmailJobCounts {
|
||||
totalProcessed: number
|
||||
totalSucceeded: number
|
||||
totalFailed: number
|
||||
totalSkipped: number
|
||||
totalNotFound: number
|
||||
}
|
||||
|
||||
interface HarmonicAuthParams {
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
export interface HarmonicSearchPeopleScoutParams extends HarmonicAuthParams {
|
||||
query: string
|
||||
}
|
||||
|
||||
export type HarmonicListPeopleSavedSearchesParams = HarmonicAuthParams
|
||||
|
||||
export interface HarmonicGetPeopleSavedSearchResultsParams extends HarmonicAuthParams {
|
||||
savedSearchId: string
|
||||
size?: number | string
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export interface HarmonicBatchGetPeopleParams extends HarmonicAuthParams {
|
||||
personIds?: Array<number | string> | string
|
||||
personUrns?: string[] | string
|
||||
}
|
||||
|
||||
export interface HarmonicSearchPeopleScoutResponse extends ToolResponse {
|
||||
output: {
|
||||
contacts: HarmonicContact[]
|
||||
taskId: string
|
||||
status: string
|
||||
count: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface HarmonicListPeopleSavedSearchesResponse extends ToolResponse {
|
||||
output: {
|
||||
savedSearches: HarmonicSavedSearch[]
|
||||
count: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface HarmonicGetPeopleSavedSearchResultsResponse extends ToolResponse {
|
||||
output: {
|
||||
contacts: HarmonicContact[]
|
||||
personUrns: string[]
|
||||
totalCount: number | null
|
||||
pageInfo: HarmonicPageInfo | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface HarmonicBatchGetPeopleResponse extends ToolResponse {
|
||||
output: {
|
||||
contacts: HarmonicContact[]
|
||||
count: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface HarmonicEnrichPersonParams extends HarmonicAuthParams {
|
||||
linkedinUrl?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export interface HarmonicGetPersonParams extends HarmonicAuthParams {
|
||||
personId: string
|
||||
companyContextUrns?: string[] | string
|
||||
}
|
||||
|
||||
export interface HarmonicGetCompanyEmployeesParams extends HarmonicAuthParams {
|
||||
companyId: string
|
||||
employeeGroupType?: string
|
||||
employeeStatus?: string
|
||||
userConnectionStatus?: string
|
||||
size?: number | string
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export interface HarmonicGetPeopleSavedSearchNetNewResultsParams extends HarmonicAuthParams {
|
||||
savedSearchId: string
|
||||
size?: number | string
|
||||
cursor?: string
|
||||
newResultsSince?: string
|
||||
}
|
||||
|
||||
export interface HarmonicClearPeopleSavedSearchNetNewResultsParams extends HarmonicAuthParams {
|
||||
savedSearchId: string
|
||||
personUrns?: string[] | string
|
||||
clearScope?: 'selected' | 'all'
|
||||
}
|
||||
|
||||
export interface HarmonicSubmitEmailEnrichmentJobParams extends HarmonicAuthParams {
|
||||
personUrns?: string[] | string
|
||||
personLinkedinUrls?: string[] | string
|
||||
}
|
||||
|
||||
export interface HarmonicGetEmailEnrichmentJobParams extends HarmonicAuthParams {
|
||||
jobId: string
|
||||
}
|
||||
|
||||
export type HarmonicGetEmailEnrichmentUsageParams = HarmonicAuthParams
|
||||
|
||||
export interface HarmonicGetEnrichmentStatusParams extends HarmonicAuthParams {
|
||||
enrichmentUrns?: string[] | string
|
||||
}
|
||||
|
||||
export interface HarmonicEnrichPersonResponse extends ToolResponse {
|
||||
output: {
|
||||
contact: HarmonicContact | null
|
||||
enrichmentUrn: string | null
|
||||
mergedPersonUrn: string | null
|
||||
requestedEntityUrn: string | null
|
||||
found: boolean
|
||||
enrichmentQueued: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface HarmonicGetPersonResponse extends ToolResponse {
|
||||
output: {
|
||||
contact: HarmonicContact | null
|
||||
found: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface HarmonicGetCompanyEmployeesResponse extends ToolResponse {
|
||||
output: {
|
||||
personUrns: string[]
|
||||
totalCount: number | null
|
||||
pageInfo: HarmonicPageInfo | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface HarmonicGetPeopleSavedSearchNetNewResultsResponse extends ToolResponse {
|
||||
output: {
|
||||
contacts: HarmonicContact[]
|
||||
personUrns: string[]
|
||||
cursor: string | null
|
||||
pageInfo: HarmonicPageInfo | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface HarmonicClearPeopleSavedSearchNetNewResultsResponse extends ToolResponse {
|
||||
output: {
|
||||
cleared: boolean
|
||||
clearedPersonUrns: string[] | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface HarmonicSubmitEmailEnrichmentJobResponse extends ToolResponse {
|
||||
output: {
|
||||
jobId: string
|
||||
status: string
|
||||
acceptedCount: number
|
||||
monthlyRemaining: number
|
||||
createdAt: string
|
||||
dropped: HarmonicDroppedIdentifier[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface HarmonicGetEmailEnrichmentJobResponse extends ToolResponse {
|
||||
output: {
|
||||
jobId: string
|
||||
status: string
|
||||
isTerminal: boolean
|
||||
counts: HarmonicEmailJobCounts
|
||||
results: HarmonicEmailJobItem[] | null
|
||||
succeededPersonUrns: string[]
|
||||
createdAt: string
|
||||
completedAt: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface HarmonicGetEmailEnrichmentUsageResponse extends ToolResponse {
|
||||
output: {
|
||||
monthlyUsage: number
|
||||
monthlyLimit: number
|
||||
monthlyRemaining: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface HarmonicGetEnrichmentStatusResponse extends ToolResponse {
|
||||
output: {
|
||||
enrichments: HarmonicEnrichmentStatus[]
|
||||
count: number
|
||||
}
|
||||
}
|
||||
|
||||
export const HARMONIC_CONTACT_OUTPUT_PROPERTIES = {
|
||||
personUrn: { type: 'string', nullable: true, description: 'Harmonic person URN' },
|
||||
personId: { type: 'number', nullable: true, description: 'Numeric Harmonic person ID' },
|
||||
fullName: { type: 'string', nullable: true, description: 'Full name' },
|
||||
firstName: { type: 'string', nullable: true, description: 'First name' },
|
||||
lastName: { type: 'string', nullable: true, description: 'Last name' },
|
||||
headline: { type: 'string', nullable: true, description: 'LinkedIn headline or current title' },
|
||||
currentTitles: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'Current job titles',
|
||||
items: { type: 'string', description: 'Job title' },
|
||||
},
|
||||
currentCompanyNames: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'Current company names',
|
||||
items: { type: 'string', description: 'Company name' },
|
||||
},
|
||||
currentCompanyUrns: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'Current Harmonic company URNs',
|
||||
items: { type: 'string', description: 'Company URN' },
|
||||
},
|
||||
primaryEmail: { type: 'string', nullable: true, description: 'Primary known email address' },
|
||||
emails: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'Known email addresses',
|
||||
items: { type: 'string', description: 'Email address' },
|
||||
},
|
||||
phoneNumbers: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'Known phone numbers',
|
||||
items: { type: 'string', description: 'Phone number' },
|
||||
},
|
||||
linkedinUrl: { type: 'string', nullable: true, description: 'LinkedIn profile URL' },
|
||||
formattedLocation: { type: 'string', nullable: true, description: 'Formatted location' },
|
||||
city: { type: 'string', nullable: true, description: 'City' },
|
||||
state: { type: 'string', nullable: true, description: 'State or region' },
|
||||
country: { type: 'string', nullable: true, description: 'Country' },
|
||||
profilePictureUrl: { type: 'string', nullable: true, description: 'Profile picture URL' },
|
||||
summary: { type: 'string', nullable: true, description: 'Scout-generated contact summary' },
|
||||
isRedacted: {
|
||||
type: 'boolean',
|
||||
nullable: true,
|
||||
description: 'Whether Harmonic marks the person record as redacted',
|
||||
},
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
export const HARMONIC_PAGE_INFO_OUTPUT_PROPERTIES = {
|
||||
nextCursor: { type: 'string', nullable: true, description: 'Cursor for the next page' },
|
||||
currentCursor: { type: 'string', nullable: true, description: 'Cursor for the current page' },
|
||||
hasNext: { type: 'boolean', description: 'Whether another page is available' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
export const HARMONIC_SAVED_SEARCH_OUTPUT_PROPERTIES = {
|
||||
savedSearchId: { type: 'number', description: 'Saved search ID' },
|
||||
savedSearchUrn: { type: 'string', description: 'Saved search URN' },
|
||||
name: { type: 'string', description: 'Saved search name' },
|
||||
isPrivate: { type: 'boolean', nullable: true, description: 'Whether the search is private' },
|
||||
savedSearchType: { type: 'string', description: 'Saved search entity type (PERSONS)' },
|
||||
userSavedSearchType: {
|
||||
type: 'string',
|
||||
description: 'User-facing saved search type',
|
||||
},
|
||||
creatorUrn: { type: 'string', description: 'Creator user URN' },
|
||||
createdAt: { type: 'string', description: 'Creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'Last update timestamp' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
export const HARMONIC_DROPPED_IDENTIFIER_OUTPUT_PROPERTIES = {
|
||||
submittedIdentifier: { type: 'string', description: 'Identifier submitted to Harmonic' },
|
||||
reason: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Why Harmonic dropped it (NOT_FOUND, INVALID_URL, ALREADY_HAS_EMAIL, RECENTLY_ATTEMPTED)',
|
||||
},
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
export const HARMONIC_EMAIL_JOB_ITEM_OUTPUT_PROPERTIES = {
|
||||
personUrn: { type: 'string', description: 'Harmonic person URN' },
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Per-person job status (PENDING, SUCCESS, NOT_FOUND, FAILED, SKIPPED)',
|
||||
},
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
export const HARMONIC_EMAIL_JOB_COUNTS_OUTPUT_PROPERTIES = {
|
||||
totalProcessed: { type: 'number', description: 'People processed' },
|
||||
totalSucceeded: { type: 'number', description: 'People with an email found' },
|
||||
totalFailed: { type: 'number', description: 'People whose enrichment failed' },
|
||||
totalSkipped: { type: 'number', description: 'People skipped' },
|
||||
totalNotFound: { type: 'number', description: 'People Harmonic could not resolve' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
export const HARMONIC_ENRICHMENT_STATUS_OUTPUT_PROPERTIES = {
|
||||
enrichmentUrn: { type: 'string', nullable: true, description: 'Harmonic enrichment URN' },
|
||||
status: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description:
|
||||
'Enrichment job status (QUEUED, IN_PROGRESS, COMPLETE, FAILED, NOT_FOUND, EXPERIENCES_HIDDEN)',
|
||||
},
|
||||
message: { type: 'string', nullable: true, description: 'Provider status message' },
|
||||
enrichedEntityUrn: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Resulting company or person URN once enrichment completes',
|
||||
},
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
@@ -0,0 +1,950 @@
|
||||
import type {
|
||||
HarmonicContact,
|
||||
HarmonicDroppedIdentifier,
|
||||
HarmonicEmailJobCounts,
|
||||
HarmonicEmailJobItem,
|
||||
HarmonicEnrichmentOutput,
|
||||
HarmonicEnrichmentStatus,
|
||||
HarmonicExperienceMetadata,
|
||||
HarmonicLocationMetadata,
|
||||
HarmonicPageInfo,
|
||||
HarmonicPaginationMetadata,
|
||||
HarmonicPersonOutput,
|
||||
HarmonicSavedSearch,
|
||||
HarmonicSavedSearchOutput,
|
||||
HarmonicScoutPerson,
|
||||
} from '@/tools/harmonic/types'
|
||||
|
||||
export const HARMONIC_API_BASE = 'https://api.harmonic.ai'
|
||||
export const HARMONIC_PAGE_SIZE_DEFAULT = 50
|
||||
export const HARMONIC_PAGE_SIZE_MAX = 100
|
||||
export const HARMONIC_BATCH_PEOPLE_MAX = 500
|
||||
export const HARMONIC_EMAIL_ENRICHMENT_MAX = 5000
|
||||
export const HARMONIC_ENRICHMENT_STATUS_MAX = 500
|
||||
export const HARMONIC_CLEAR_NET_NEW_MAX = 500
|
||||
export const HARMONIC_EMPLOYEE_GROUP_TYPES = [
|
||||
'CEO',
|
||||
'FOUNDERS_AND_CEO',
|
||||
'EXECUTIVES',
|
||||
'FOUNDERS',
|
||||
'LEADERSHIP',
|
||||
'NON_LEADERSHIP',
|
||||
'ALL',
|
||||
'ADVISORS',
|
||||
'NON_PARTNERS',
|
||||
] as const
|
||||
export const HARMONIC_EMPLOYEE_STATUSES = ['ACTIVE', 'NOT_ACTIVE', 'ACTIVE_AND_NOT_ACTIVE'] as const
|
||||
export const HARMONIC_USER_CONNECTION_STATUSES = [
|
||||
'USER_CONNECTION',
|
||||
'TEAM_CONNECTION',
|
||||
'NO_CONNECTION',
|
||||
] as const
|
||||
/** Terminal states for a bulk email-enrichment job; `results` stays null until one is reached. */
|
||||
export const HARMONIC_EMAIL_JOB_TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED'])
|
||||
export const HARMONIC_PERSON_INCLUDE_FIELDS = [
|
||||
'entity_urn',
|
||||
'id',
|
||||
'full_name',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'profile_picture_url',
|
||||
'contact',
|
||||
'location',
|
||||
'socials',
|
||||
'experience',
|
||||
'linkedin_headline',
|
||||
'current_company_urns',
|
||||
'is_redacted',
|
||||
] as const
|
||||
|
||||
const PERSON_URN_PATTERN = /^urn:harmonic:person:[^\s]+$/
|
||||
const SAVED_SEARCH_URN_PATTERN = /^urn:harmonic:saved_search:[^\s]+$/
|
||||
const USER_URN_PATTERN = /^urn:harmonic:user:[^\s]+$/
|
||||
const ENRICHMENT_URN_PATTERN = /^urn:harmonic:enrichment:[^\s]+$/
|
||||
const COMPANY_OR_PERSON_URN_PATTERN = /^urn:harmonic:(company|person):[^\s]+$/
|
||||
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const SAFE_DECIMAL_INTEGER_PATTERN = /^-?\d+$/
|
||||
const RFC3339_DATE_TIME_PATTERN =
|
||||
/^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:([Zz])|([+-])(\d{2}):(\d{2}))$/
|
||||
/** UTC dates that ended in a leap second, through IERS Bulletin C 72 (July 2026). */
|
||||
const KNOWN_UTC_LEAP_SECOND_DATES = new Set([
|
||||
'1972-06-30',
|
||||
'1972-12-31',
|
||||
'1973-12-31',
|
||||
'1974-12-31',
|
||||
'1975-12-31',
|
||||
'1976-12-31',
|
||||
'1977-12-31',
|
||||
'1978-12-31',
|
||||
'1979-12-31',
|
||||
'1981-06-30',
|
||||
'1982-06-30',
|
||||
'1983-06-30',
|
||||
'1985-06-30',
|
||||
'1987-12-31',
|
||||
'1989-12-31',
|
||||
'1990-12-31',
|
||||
'1992-06-30',
|
||||
'1993-06-30',
|
||||
'1994-06-30',
|
||||
'1995-12-31',
|
||||
'1997-06-30',
|
||||
'1998-12-31',
|
||||
'2005-12-31',
|
||||
'2008-12-31',
|
||||
'2012-06-30',
|
||||
'2015-06-30',
|
||||
'2016-12-31',
|
||||
])
|
||||
const HARMONIC_USER_SAVED_SEARCH_TYPES = new Set([
|
||||
'USER_CREATED',
|
||||
'GENERATED_FROM_PREFERENCES',
|
||||
'TEMPLATE_FROM_PREFERENCES',
|
||||
])
|
||||
|
||||
/**
|
||||
* Scout returns `content` as an object matching this schema when the request succeeds.
|
||||
* Keeping the schema integration-owned gives every workflow the same downstream table shape.
|
||||
*/
|
||||
export const HARMONIC_SCOUT_PEOPLE_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
people: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
name: { type: 'string', description: "The person's full name" },
|
||||
linkedin_url: { type: 'string', description: "The person's LinkedIn profile URL" },
|
||||
person_urn: { type: 'string', description: "The person's Harmonic URN" },
|
||||
title: { type: 'string', description: "The person's current job title" },
|
||||
company: { type: 'string', description: "The person's current company" },
|
||||
location: { type: 'string', description: "The person's location" },
|
||||
email: { type: 'string', description: "The person's email address" },
|
||||
one_liner: {
|
||||
type: 'string',
|
||||
description: 'A brief explanation of why the person matches the request',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['people'],
|
||||
} as const
|
||||
|
||||
export function harmonicHeaders(
|
||||
accessToken: string,
|
||||
options: { json?: boolean } = {}
|
||||
): Record<string, string> {
|
||||
return {
|
||||
apikey: accessToken,
|
||||
Accept: 'application/json',
|
||||
...(options.json ? { 'Content-Type': 'application/json' } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function asOpaqueString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown): boolean | null {
|
||||
return typeof value === 'boolean' ? value : null
|
||||
}
|
||||
|
||||
function uniqueStrings(values: unknown[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const value of values) {
|
||||
const normalized = asString(value)
|
||||
if (normalized && !seen.has(normalized)) {
|
||||
seen.add(normalized)
|
||||
result.push(normalized)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function nullableStringArray(value: unknown): string[] | null {
|
||||
return Array.isArray(value) ? uniqueStrings(value) : null
|
||||
}
|
||||
|
||||
function personUrn(value: unknown): string | null {
|
||||
const normalized = asString(value)
|
||||
return normalized && PERSON_URN_PATTERN.test(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
function requirePersonUrn(value: unknown, paramName: string): string {
|
||||
const normalized = personUrn(value)
|
||||
if (!normalized) {
|
||||
throw new Error(`Harmonic "${paramName}" must contain only person URNs`)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function requirePersonId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value)) return value
|
||||
if (typeof value === 'string' && UUID_PATTERN.test(value)) return null
|
||||
throw new Error('Harmonic returned a person record with an invalid ID')
|
||||
}
|
||||
|
||||
function requireSavedSearchUrn(value: unknown): string {
|
||||
const normalized = asString(value)
|
||||
if (!normalized || !SAVED_SEARCH_URN_PATTERN.test(normalized)) {
|
||||
throw new Error('Harmonic returned a people saved search with an invalid entity URN')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function requireSavedSearchString(value: unknown, field: string): string {
|
||||
const normalized = asString(value)
|
||||
if (!normalized) {
|
||||
throw new Error(`Harmonic returned a people saved search without a valid ${field}`)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function requireUserUrn(value: unknown): string {
|
||||
const normalized = requireSavedSearchString(value, 'creator')
|
||||
if (!USER_URN_PATTERN.test(normalized)) {
|
||||
throw new Error('Harmonic returned a people saved search with an invalid creator URN')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function requireUserSavedSearchType(value: unknown): string {
|
||||
const normalized = requireSavedSearchString(value, 'user_saved_search_type')
|
||||
if (!HARMONIC_USER_SAVED_SEARCH_TYPES.has(normalized)) {
|
||||
throw new Error(
|
||||
'Harmonic returned a people saved search with an invalid user_saved_search_type'
|
||||
)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function requireSavedSearchTimestamp(value: unknown, field: string): string {
|
||||
const normalized = requireSavedSearchString(value, field)
|
||||
const match = RFC3339_DATE_TIME_PATTERN.exec(normalized)
|
||||
if (!match) {
|
||||
throw new Error(`Harmonic returned a people saved search with an invalid ${field}`)
|
||||
}
|
||||
|
||||
const [
|
||||
,
|
||||
yearText,
|
||||
monthText,
|
||||
dayText,
|
||||
hourText,
|
||||
minuteText,
|
||||
secondText,
|
||||
utcDesignator,
|
||||
offsetSign,
|
||||
offsetHourText,
|
||||
offsetMinuteText,
|
||||
] = match
|
||||
const year = Number(yearText)
|
||||
const month = Number(monthText)
|
||||
const day = Number(dayText)
|
||||
const hour = Number(hourText)
|
||||
const minute = Number(minuteText)
|
||||
const second = Number(secondText)
|
||||
const offsetHour = offsetHourText === undefined ? 0 : Number(offsetHourText)
|
||||
const offsetMinute = offsetMinuteText === undefined ? 0 : Number(offsetMinuteText)
|
||||
const isLeapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
|
||||
const daysInMonth = [31, isLeapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
|
||||
if (
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > daysInMonth[month - 1] ||
|
||||
hour > 23 ||
|
||||
minute > 59 ||
|
||||
second > 60 ||
|
||||
offsetHour > 23 ||
|
||||
offsetMinute > 59
|
||||
) {
|
||||
throw new Error(`Harmonic returned a people saved search with an invalid ${field}`)
|
||||
}
|
||||
|
||||
if (second === 60) {
|
||||
if (year < 1972) {
|
||||
throw new Error(`Harmonic returned a people saved search with an invalid ${field}`)
|
||||
}
|
||||
const offsetMinutes = utcDesignator
|
||||
? 0
|
||||
: (offsetSign === '-' ? -1 : 1) * (offsetHour * 60 + offsetMinute)
|
||||
const precedingUtcSecond = new Date(
|
||||
Date.UTC(year, month - 1, day, hour, minute, 59) - offsetMinutes * 60_000
|
||||
)
|
||||
const leapSecondDate = precedingUtcSecond.toISOString().slice(0, 10)
|
||||
if (
|
||||
precedingUtcSecond.getUTCHours() !== 23 ||
|
||||
precedingUtcSecond.getUTCMinutes() !== 59 ||
|
||||
precedingUtcSecond.getUTCSeconds() !== 59 ||
|
||||
!KNOWN_UTC_LEAP_SECOND_DATES.has(leapSecondDate)
|
||||
) {
|
||||
throw new Error(`Harmonic returned a people saved search with an invalid ${field}`)
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function parseArrayParam(value: unknown, paramName: string): unknown[] {
|
||||
if (value === undefined || value === null || value === '') return []
|
||||
if (Array.isArray(value)) return value
|
||||
|
||||
if (typeof value === 'string') {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
throw new Error(`Harmonic "${paramName}" must be a JSON array`)
|
||||
}
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
}
|
||||
|
||||
throw new Error(`Harmonic "${paramName}" must be a JSON array`)
|
||||
}
|
||||
|
||||
function parseSafeDecimalInteger(value: unknown, paramName: string): number {
|
||||
let parsed: number
|
||||
if (typeof value === 'number') {
|
||||
parsed = value
|
||||
} else if (typeof value === 'string') {
|
||||
const normalized = value.trim()
|
||||
if (!SAFE_DECIMAL_INTEGER_PATTERN.test(normalized)) {
|
||||
throw new Error(`Harmonic "${paramName}" must be a safe decimal integer`)
|
||||
}
|
||||
parsed = Number(normalized)
|
||||
} else {
|
||||
throw new Error(`Harmonic "${paramName}" must be a safe decimal integer`)
|
||||
}
|
||||
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`Harmonic "${paramName}" must be a safe decimal integer`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizePersonUrns(values: unknown[], paramName: string): string[] {
|
||||
return [...new Set(values.map((urn) => requirePersonUrn(urn, paramName)))]
|
||||
}
|
||||
|
||||
function normalizePersonIds(values: unknown[]): number[] {
|
||||
return [...new Set(values.map((id) => parseSafeDecimalInteger(id, 'personIds')))]
|
||||
}
|
||||
|
||||
export function parsePersonUrns(value: unknown, paramName = 'personUrns'): string[] {
|
||||
return normalizePersonUrns(parseArrayParam(value, paramName), paramName)
|
||||
}
|
||||
|
||||
export function parsePersonIds(value: unknown): number[] {
|
||||
return normalizePersonIds(parseArrayParam(value, 'personIds'))
|
||||
}
|
||||
|
||||
export function clampPageSize(value: unknown): number {
|
||||
if (value === undefined || value === null || value === '') return HARMONIC_PAGE_SIZE_DEFAULT
|
||||
const parsed = parseSafeDecimalInteger(value, 'size')
|
||||
return Math.min(Math.max(parsed, 1), HARMONIC_PAGE_SIZE_MAX)
|
||||
}
|
||||
|
||||
export function requireIdentifier(value: unknown, paramName: string): string {
|
||||
const normalized = asString(value)
|
||||
if (!normalized) throw new Error(`Harmonic "${paramName}" is required`)
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function buildPagedUrl(path: string, size: unknown, cursor?: unknown): string {
|
||||
const url = new URL(`${HARMONIC_API_BASE}${path}`)
|
||||
url.searchParams.set('size', String(clampPageSize(size)))
|
||||
const normalizedCursor = asOpaqueString(cursor)
|
||||
if (normalizedCursor) url.searchParams.set('cursor', normalizedCursor)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function buildScoutBody(query: unknown): Record<string, unknown> {
|
||||
const input = requireIdentifier(query, 'query')
|
||||
return { input, json_schema: HARMONIC_SCOUT_PEOPLE_SCHEMA }
|
||||
}
|
||||
|
||||
export function buildBatchGetPeopleBody(
|
||||
personIds: unknown,
|
||||
personUrns: unknown
|
||||
): Record<string, unknown> {
|
||||
const rawIds = parseArrayParam(personIds, 'personIds')
|
||||
const rawUrns = parseArrayParam(personUrns, 'personUrns')
|
||||
const rawTotal = rawIds.length + rawUrns.length
|
||||
if (rawTotal === 0) {
|
||||
throw new Error('Harmonic Batch Get People requires at least one person ID or person URN')
|
||||
}
|
||||
if (rawTotal > HARMONIC_BATCH_PEOPLE_MAX) {
|
||||
throw new Error(`Harmonic Batch Get People accepts at most ${HARMONIC_BATCH_PEOPLE_MAX} people`)
|
||||
}
|
||||
|
||||
return {
|
||||
ids: normalizePersonIds(rawIds),
|
||||
urns: normalizePersonUrns(rawUrns, 'personUrns'),
|
||||
include_fields: [...HARMONIC_PERSON_INCLUDE_FIELDS],
|
||||
}
|
||||
}
|
||||
|
||||
function currentExperience(raw: HarmonicPersonOutput): HarmonicExperienceMetadata[] | null {
|
||||
if (!Array.isArray(raw.experience)) return null
|
||||
return raw.experience.filter((experience) => experience?.is_current_position === true)
|
||||
}
|
||||
|
||||
function normalizeLinkedinProfileUrl(value: unknown): string | null {
|
||||
const rawUrl = asString(value)
|
||||
if (!rawUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
const isLinkedinHost = hostname === 'linkedin.com' || hostname.endsWith('.linkedin.com')
|
||||
const [, profileKind, profileSlug] = url.pathname.split('/')
|
||||
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.port ||
|
||||
!isLinkedinHost ||
|
||||
(profileKind !== 'in' && profileKind !== 'pub') ||
|
||||
!profileSlug
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function linkedinUrl(socials: HarmonicPersonOutput['socials']): string | null {
|
||||
const socialRecord = asRecord(socials)
|
||||
if (!socialRecord) return null
|
||||
|
||||
for (const metadata of Object.values(socialRecord)) {
|
||||
const normalized = normalizeLinkedinProfileUrl(asRecord(metadata)?.url)
|
||||
if (normalized) return normalized
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizePerson(raw: HarmonicPersonOutput): HarmonicContact {
|
||||
const normalizedPersonId = requirePersonId(raw.id)
|
||||
const contact = asRecord(raw.contact)
|
||||
const location = (asRecord(raw.location) ?? {}) as HarmonicLocationMetadata
|
||||
const experiences = currentExperience(raw)
|
||||
const primaryEmail = asString(contact?.primary_email)
|
||||
const contactEmails = nullableStringArray(contact?.emails)
|
||||
const executiveEmails = nullableStringArray(contact?.exec_emails)
|
||||
const emails =
|
||||
primaryEmail || contactEmails !== null || executiveEmails !== null
|
||||
? uniqueStrings([primaryEmail, ...(contactEmails ?? []), ...(executiveEmails ?? [])])
|
||||
: null
|
||||
const currentTitles = experiences
|
||||
? uniqueStrings(experiences.map((experience) => experience.title))
|
||||
: null
|
||||
const currentCompanyNames = experiences
|
||||
? uniqueStrings(experiences.map((experience) => experience.company_name))
|
||||
: null
|
||||
const personCompanyUrns = nullableStringArray(raw.current_company_urns)
|
||||
const currentCompanyUrns =
|
||||
personCompanyUrns !== null || experiences !== null
|
||||
? uniqueStrings([
|
||||
...(personCompanyUrns ?? []),
|
||||
...(experiences ?? []).map((experience) => experience.company),
|
||||
]).filter((urn) => urn.startsWith('urn:harmonic:company:'))
|
||||
: null
|
||||
|
||||
return {
|
||||
personUrn: personUrn(raw.entity_urn),
|
||||
personId: normalizedPersonId,
|
||||
fullName: asString(raw.full_name),
|
||||
firstName: asString(raw.first_name),
|
||||
lastName: asString(raw.last_name),
|
||||
headline: asString(raw.linkedin_headline) ?? currentTitles?.[0] ?? null,
|
||||
currentTitles,
|
||||
currentCompanyNames,
|
||||
currentCompanyUrns,
|
||||
primaryEmail,
|
||||
emails,
|
||||
phoneNumbers: nullableStringArray(contact?.phone_numbers),
|
||||
linkedinUrl: linkedinUrl(raw.socials),
|
||||
formattedLocation: asString(location.address_formatted) ?? asString(location.location),
|
||||
city: asString(location.city),
|
||||
state: asString(location.state),
|
||||
country: asString(location.country),
|
||||
profilePictureUrl: asString(raw.profile_picture_url),
|
||||
summary: null,
|
||||
isRedacted: asBoolean(raw.is_redacted),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeScoutPerson(raw: HarmonicScoutPerson): HarmonicContact {
|
||||
const name = asString(raw.name)
|
||||
if (!name) throw new Error('Harmonic Scout returned a person without the required name')
|
||||
const title = asString(raw.title)
|
||||
const company = asString(raw.company)
|
||||
const email = asString(raw.email)
|
||||
|
||||
return {
|
||||
personUrn: personUrn(raw.person_urn),
|
||||
personId: null,
|
||||
fullName: name,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
headline: title,
|
||||
currentTitles: title ? [title] : null,
|
||||
currentCompanyNames: company ? [company] : null,
|
||||
currentCompanyUrns: null,
|
||||
primaryEmail: email,
|
||||
emails: email ? [email] : null,
|
||||
phoneNumbers: null,
|
||||
linkedinUrl: normalizeLinkedinProfileUrl(raw.linkedin_url),
|
||||
formattedLocation: asString(raw.location),
|
||||
city: null,
|
||||
state: null,
|
||||
country: null,
|
||||
profilePictureUrl: null,
|
||||
summary: asString(raw.one_liner),
|
||||
isRedacted: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePageInfo(value: unknown): HarmonicPageInfo | null {
|
||||
if (value === undefined || value === null) return null
|
||||
const pageInfo = asRecord(value) as HarmonicPaginationMetadata | null
|
||||
if (!pageInfo) throw new Error('Harmonic returned invalid page_info metadata')
|
||||
if (typeof pageInfo.has_next !== 'boolean') {
|
||||
throw new Error('Harmonic returned page_info without a boolean has_next value')
|
||||
}
|
||||
|
||||
const cursor = (cursorValue: unknown, field: string): string | null => {
|
||||
if (cursorValue === undefined || cursorValue === null) return null
|
||||
if (typeof cursorValue !== 'string') {
|
||||
throw new Error(`Harmonic returned page_info.${field} with a non-string cursor`)
|
||||
}
|
||||
return cursorValue
|
||||
}
|
||||
|
||||
return {
|
||||
nextCursor: cursor(pageInfo.next, 'next'),
|
||||
currentCursor: cursor(pageInfo.current, 'current'),
|
||||
hasNext: pageInfo.has_next,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSavedSearch(raw: HarmonicSavedSearchOutput): HarmonicSavedSearch {
|
||||
if (raw.type !== 'PERSONS') {
|
||||
throw new Error('Harmonic returned a saved search that does not target people')
|
||||
}
|
||||
|
||||
if (typeof raw.id !== 'number' || !Number.isSafeInteger(raw.id)) {
|
||||
throw new Error('Harmonic returned a people saved search with an invalid numeric ID')
|
||||
}
|
||||
const savedSearchId = raw.id
|
||||
const savedSearchUrn = requireSavedSearchUrn(raw.entity_urn)
|
||||
const name = asString(raw.name)
|
||||
if (!name) throw new Error('Harmonic returned a people saved search without a name')
|
||||
|
||||
return {
|
||||
savedSearchId,
|
||||
savedSearchUrn,
|
||||
name,
|
||||
isPrivate: asBoolean(raw.is_private),
|
||||
savedSearchType: 'PERSONS',
|
||||
userSavedSearchType: requireUserSavedSearchType(raw.user_saved_search_type),
|
||||
creatorUrn: requireUserUrn(raw.creator),
|
||||
createdAt: requireSavedSearchTimestamp(raw.created_at, 'created_at'),
|
||||
updatedAt: requireSavedSearchTimestamp(raw.updated_at, 'updated_at'),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePeopleResults(value: unknown): {
|
||||
contacts: HarmonicContact[]
|
||||
personUrns: string[]
|
||||
} {
|
||||
if (!Array.isArray(value)) throw new Error('Harmonic returned an invalid people results array')
|
||||
|
||||
const contacts: HarmonicContact[] = []
|
||||
const urns: string[] = []
|
||||
for (const result of value) {
|
||||
if (typeof result === 'string') {
|
||||
urns.push(requirePersonUrn(result, 'results'))
|
||||
continue
|
||||
}
|
||||
|
||||
const person = asRecord(result) as HarmonicPersonOutput | null
|
||||
const urn = personUrn(person?.entity_urn)
|
||||
if (!person || !urn) {
|
||||
throw new Error('Harmonic saved search returned a non-person result')
|
||||
}
|
||||
contacts.push(normalizePerson(person))
|
||||
urns.push(urn)
|
||||
}
|
||||
|
||||
return { contacts, personUrns: uniqueStrings(urns) }
|
||||
}
|
||||
|
||||
export function normalizePersonArray(value: unknown): HarmonicContact[] {
|
||||
if (!Array.isArray(value)) throw new Error('Harmonic returned an invalid people array')
|
||||
return value.map((item) => {
|
||||
const person = asRecord(item) as HarmonicPersonOutput | null
|
||||
if (!person || !personUrn(person.entity_urn)) {
|
||||
throw new Error('Harmonic returned an invalid person record')
|
||||
}
|
||||
return normalizePerson(person)
|
||||
})
|
||||
}
|
||||
|
||||
export function responseRecord(value: unknown, context: string): Record<string, unknown> {
|
||||
const record = asRecord(value)
|
||||
if (!record) throw new Error(`Harmonic returned an invalid ${context} response`)
|
||||
return record
|
||||
}
|
||||
|
||||
export function responseArray(value: unknown, context: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error(`Harmonic returned an invalid ${context} response`)
|
||||
return value
|
||||
}
|
||||
|
||||
export function nullableResponseNumber(value: unknown): number | null {
|
||||
return asNumber(value)
|
||||
}
|
||||
|
||||
export function nullableResponseString(value: unknown): string | null {
|
||||
return asString(value)
|
||||
}
|
||||
|
||||
function requireResponseString(value: unknown, context: string): string {
|
||||
const normalized = asString(value)
|
||||
if (!normalized) throw new Error(`Harmonic returned ${context} without a valid value`)
|
||||
return normalized
|
||||
}
|
||||
|
||||
function requireResponseNumber(value: unknown, context: string): number {
|
||||
const normalized = asNumber(value)
|
||||
if (normalized === null || !Number.isSafeInteger(normalized)) {
|
||||
throw new Error(`Harmonic returned ${context} without a valid count`)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function enumOption(
|
||||
value: unknown,
|
||||
allowed: readonly string[],
|
||||
paramName: string
|
||||
): string | undefined {
|
||||
const normalized = asString(value)
|
||||
if (!normalized) return undefined
|
||||
const match = allowed.find((option) => option === normalized.toUpperCase())
|
||||
if (!match) {
|
||||
throw new Error(`Harmonic "${paramName}" must be one of: ${allowed.join(', ')}`)
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
/**
|
||||
* Harmonic accepts `YYYY-MM-DD` or `YYYY-MM-DDTHH:00:00Z` here. Anything else is
|
||||
* rejected locally so a malformed filter cannot silently widen the delta window.
|
||||
*/
|
||||
export function normalizeNewResultsSince(value: unknown): string | undefined {
|
||||
const normalized = asString(value)
|
||||
if (!normalized) return undefined
|
||||
if (DATE_ONLY_PATTERN.test(normalized)) return normalized
|
||||
if (RFC3339_DATE_TIME_PATTERN.test(normalized)) return normalized
|
||||
throw new Error(
|
||||
'Harmonic "newResultsSince" must be YYYY-MM-DD or an RFC 3339 timestamp such as 2026-01-31T00:00:00Z'
|
||||
)
|
||||
}
|
||||
|
||||
export function buildEnrichPersonUrl(linkedinUrl: unknown, email: unknown): string {
|
||||
const normalizedLinkedin = asString(linkedinUrl)
|
||||
const normalizedEmail = asString(email)
|
||||
if (!normalizedLinkedin && !normalizedEmail) {
|
||||
throw new Error('Harmonic Enrich Person requires a LinkedIn profile URL or an email address')
|
||||
}
|
||||
|
||||
const url = new URL(`${HARMONIC_API_BASE}/persons`)
|
||||
if (normalizedLinkedin) url.searchParams.set('linkedin_url', normalizedLinkedin)
|
||||
if (normalizedEmail) url.searchParams.set('email', normalizedEmail)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function buildGetPersonUrl(personId: unknown, companyContextUrns: unknown): string {
|
||||
const url = new URL(
|
||||
`${HARMONIC_API_BASE}/persons/${encodeURIComponent(requireIdentifier(personId, 'personId'))}`
|
||||
)
|
||||
for (const urn of uniqueStrings(parseArrayParam(companyContextUrns, 'companyContextUrns'))) {
|
||||
url.searchParams.append('company_context_urns', urn)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function buildCompanyEmployeesUrl(
|
||||
companyId: unknown,
|
||||
options: {
|
||||
employeeGroupType?: unknown
|
||||
employeeStatus?: unknown
|
||||
userConnectionStatus?: unknown
|
||||
size?: unknown
|
||||
cursor?: unknown
|
||||
}
|
||||
): string {
|
||||
const url = new URL(
|
||||
`${HARMONIC_API_BASE}/companies/${encodeURIComponent(
|
||||
requireIdentifier(companyId, 'companyId')
|
||||
)}/employees`
|
||||
)
|
||||
url.searchParams.set('size', String(clampPageSize(options.size)))
|
||||
const groupType = enumOption(
|
||||
options.employeeGroupType,
|
||||
HARMONIC_EMPLOYEE_GROUP_TYPES,
|
||||
'employeeGroupType'
|
||||
)
|
||||
if (groupType) url.searchParams.set('employee_group_type', groupType)
|
||||
const status = enumOption(options.employeeStatus, HARMONIC_EMPLOYEE_STATUSES, 'employeeStatus')
|
||||
if (status) url.searchParams.set('employee_status', status)
|
||||
const connection = enumOption(
|
||||
options.userConnectionStatus,
|
||||
HARMONIC_USER_CONNECTION_STATUSES,
|
||||
'userConnectionStatus'
|
||||
)
|
||||
if (connection) url.searchParams.set('user_connection_status', connection)
|
||||
const cursor = asOpaqueString(options.cursor)
|
||||
if (cursor) url.searchParams.set('cursor', cursor)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function buildNetNewResultsUrl(
|
||||
savedSearchId: unknown,
|
||||
size: unknown,
|
||||
cursor: unknown,
|
||||
newResultsSince: unknown
|
||||
): string {
|
||||
const url = new URL(
|
||||
`${HARMONIC_API_BASE}/savedSearches/${encodeURIComponent(
|
||||
requireIdentifier(savedSearchId, 'savedSearchId')
|
||||
)}/net_new_results`
|
||||
)
|
||||
url.searchParams.set('size', String(clampPageSize(size)))
|
||||
const normalizedCursor = asOpaqueString(cursor)
|
||||
if (normalizedCursor) url.searchParams.set('cursor', normalizedCursor)
|
||||
const since = normalizeNewResultsSince(newResultsSince)
|
||||
if (since) url.searchParams.set('new_results_since', since)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Omitting `entity_urns` tells Harmonic to clear the entire net-new queue, so an
|
||||
* empty URN list must never reach the wire by accident. Clearing everything has to
|
||||
* be asked for explicitly through `clearScope`.
|
||||
*/
|
||||
export function buildClearNetNewResultsUrl(
|
||||
savedSearchId: unknown,
|
||||
personUrns: unknown,
|
||||
clearScope: unknown
|
||||
): string {
|
||||
const url = new URL(
|
||||
`${HARMONIC_API_BASE}/savedSearches/${encodeURIComponent(
|
||||
requireIdentifier(savedSearchId, 'savedSearchId')
|
||||
)}/clear_net_new_results`
|
||||
)
|
||||
|
||||
const scope = asString(clearScope) ?? 'selected'
|
||||
if (scope !== 'selected' && scope !== 'all') {
|
||||
throw new Error('Harmonic "clearScope" must be either "selected" or "all"')
|
||||
}
|
||||
|
||||
const urns = parsePersonUrns(personUrns)
|
||||
if (scope === 'all') {
|
||||
if (urns.length > 0) {
|
||||
throw new Error(
|
||||
'Harmonic Clear Net-New Results cannot combine specific person URNs with clearing everything'
|
||||
)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
if (urns.length === 0) {
|
||||
throw new Error(
|
||||
'Harmonic Clear Net-New Results requires at least one person URN, or clearScope set to "all" to clear every net-new result'
|
||||
)
|
||||
}
|
||||
if (urns.length > HARMONIC_CLEAR_NET_NEW_MAX) {
|
||||
throw new Error(
|
||||
`Sim sends at most ${HARMONIC_CLEAR_NET_NEW_MAX} person URNs per Harmonic clear request to bound the query string; split the batch`
|
||||
)
|
||||
}
|
||||
for (const urn of urns) url.searchParams.append('entity_urns', urn)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function buildEnrichmentStatusUrl(enrichmentUrns: unknown): string {
|
||||
const urns = uniqueStrings(parseArrayParam(enrichmentUrns, 'enrichmentUrns'))
|
||||
if (urns.length === 0) {
|
||||
throw new Error('Harmonic Get Enrichment Status requires at least one enrichment URN')
|
||||
}
|
||||
if (urns.length > HARMONIC_ENRICHMENT_STATUS_MAX) {
|
||||
throw new Error(
|
||||
`Sim sends at most ${HARMONIC_ENRICHMENT_STATUS_MAX} enrichment identifiers per Harmonic request to bound the query string; split the batch`
|
||||
)
|
||||
}
|
||||
|
||||
/** Harmonic documents both the bare enrichment UUID (`ids`) and the full URN (`urns`). */
|
||||
const url = new URL(`${HARMONIC_API_BASE}/enrichment_status`)
|
||||
for (const urn of urns) {
|
||||
if (ENRICHMENT_URN_PATTERN.test(urn)) {
|
||||
url.searchParams.append('urns', urn)
|
||||
} else if (UUID_PATTERN.test(urn)) {
|
||||
url.searchParams.append('ids', urn)
|
||||
} else {
|
||||
throw new Error(
|
||||
'Harmonic "enrichmentUrns" must contain enrichment URNs or bare enrichment UUIDs'
|
||||
)
|
||||
}
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function buildEmailEnrichmentJobBody(
|
||||
personUrns: unknown,
|
||||
personLinkedinUrls: unknown
|
||||
): Record<string, unknown> {
|
||||
const urns = parsePersonUrns(personUrns)
|
||||
/**
|
||||
* Harmonic reports unusable entries per item as `dropped[].reason = INVALID_URL`
|
||||
* without consuming quota, so one odd URL must not fail the whole batch.
|
||||
* Recognisable profile URLs are canonicalised; anything else is forwarded intact
|
||||
* for Harmonic to adjudicate. Only values that are not absolute http(s) URLs at
|
||||
* all are rejected here, because those are a local mistake, not a provider call.
|
||||
*/
|
||||
const linkedinUrls = uniqueStrings(parseArrayParam(personLinkedinUrls, 'personLinkedinUrls')).map(
|
||||
(value) => {
|
||||
const normalized = normalizeLinkedinProfileUrl(value)
|
||||
if (normalized) return normalized
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error('unsupported scheme')
|
||||
}
|
||||
return value
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Harmonic "personLinkedinUrls" must contain absolute http(s) URLs; Harmonic reports unmatched profiles in dropped'
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Harmonic documents these as mutually exclusive — "Provide exactly one of the
|
||||
* two arrays" — so sending both is rejected locally rather than letting the
|
||||
* provider silently pick one and bill for it.
|
||||
*/
|
||||
if (urns.length > 0 && linkedinUrls.length > 0) {
|
||||
throw new Error(
|
||||
'Harmonic Submit Email Enrichment Job accepts person URNs or LinkedIn URLs, not both'
|
||||
)
|
||||
}
|
||||
const identifiers = urns.length > 0 ? urns : linkedinUrls
|
||||
if (identifiers.length === 0) {
|
||||
throw new Error(
|
||||
'Harmonic Submit Email Enrichment Job requires at least one person URN or LinkedIn profile URL'
|
||||
)
|
||||
}
|
||||
if (identifiers.length > HARMONIC_EMAIL_ENRICHMENT_MAX) {
|
||||
throw new Error(
|
||||
`Harmonic Submit Email Enrichment Job accepts at most ${HARMONIC_EMAIL_ENRICHMENT_MAX} people`
|
||||
)
|
||||
}
|
||||
|
||||
return urns.length > 0 ? { person_urns: urns } : { person_linkedin_urls: linkedinUrls }
|
||||
}
|
||||
|
||||
export function normalizePersonUrnList(value: unknown, context: string): string[] {
|
||||
return uniqueStrings(responseArray(value, context).map((urn) => requirePersonUrn(urn, 'results')))
|
||||
}
|
||||
|
||||
export function normalizeOptionalPerson(value: unknown): HarmonicContact | null {
|
||||
if (value === undefined || value === null) return null
|
||||
const person = asRecord(value)
|
||||
if (!person) throw new Error('Harmonic returned an invalid person record')
|
||||
return normalizePerson(person)
|
||||
}
|
||||
|
||||
export function normalizeDroppedIdentifiers(value: unknown): HarmonicDroppedIdentifier[] {
|
||||
if (value === undefined || value === null) return []
|
||||
return responseArray(value, 'dropped identifiers').map((entry) => {
|
||||
const dropped = responseRecord(entry, 'dropped identifier')
|
||||
return {
|
||||
submittedIdentifier: requireResponseString(
|
||||
dropped.submitted_identifier,
|
||||
'a dropped identifier'
|
||||
),
|
||||
reason: requireResponseString(dropped.reason, 'a dropped identifier reason'),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeEmailJobCounts(value: unknown): HarmonicEmailJobCounts {
|
||||
const counts = responseRecord(value, 'email enrichment counts')
|
||||
return {
|
||||
totalProcessed: requireResponseNumber(counts.total_processed, 'total_processed'),
|
||||
totalSucceeded: requireResponseNumber(counts.total_succeeded, 'total_succeeded'),
|
||||
totalFailed: requireResponseNumber(counts.total_failed, 'total_failed'),
|
||||
totalSkipped: requireResponseNumber(counts.total_skipped, 'total_skipped'),
|
||||
totalNotFound: requireResponseNumber(counts.total_not_found, 'total_not_found'),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeEmailJobResults(value: unknown): HarmonicEmailJobItem[] | null {
|
||||
if (value === undefined || value === null) return null
|
||||
return responseArray(value, 'email enrichment results').map((entry) => {
|
||||
const item = responseRecord(entry, 'email enrichment result')
|
||||
return {
|
||||
personUrn: requirePersonUrn(item.person_urn, 'results'),
|
||||
status: requireResponseString(item.status, 'an email enrichment result status'),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeEnrichmentStatuses(value: unknown): HarmonicEnrichmentStatus[] {
|
||||
return responseArray(value, 'enrichment statuses').map((entry) => {
|
||||
const record = responseRecord(entry, 'enrichment status') as HarmonicEnrichmentOutput
|
||||
const enrichedEntityUrn = asString(record.enriched_entity_urn)
|
||||
if (enrichedEntityUrn && !COMPANY_OR_PERSON_URN_PATTERN.test(enrichedEntityUrn)) {
|
||||
throw new Error('Harmonic returned an enrichment status with an invalid entity URN')
|
||||
}
|
||||
return {
|
||||
enrichmentUrn: asString(record.entity_urn),
|
||||
status: asString(record.status),
|
||||
message: asString(record.message),
|
||||
enrichedEntityUrn,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2071,6 +2071,21 @@ import {
|
||||
greptileStatusTool,
|
||||
} from '@/tools/greptile'
|
||||
import { guardrailsValidateTool } from '@/tools/guardrails'
|
||||
import {
|
||||
harmonicBatchGetPeopleTool,
|
||||
harmonicClearPeopleSavedSearchNetNewResultsTool,
|
||||
harmonicEnrichPersonTool,
|
||||
harmonicGetCompanyEmployeesTool,
|
||||
harmonicGetEmailEnrichmentJobTool,
|
||||
harmonicGetEmailEnrichmentUsageTool,
|
||||
harmonicGetEnrichmentStatusTool,
|
||||
harmonicGetPeopleSavedSearchNetNewResultsTool,
|
||||
harmonicGetPeopleSavedSearchResultsTool,
|
||||
harmonicGetPersonTool,
|
||||
harmonicListPeopleSavedSearchesTool,
|
||||
harmonicSearchPeopleScoutTool,
|
||||
harmonicSubmitEmailEnrichmentJobTool,
|
||||
} from '@/tools/harmonic'
|
||||
import {
|
||||
hexCancelRunTool,
|
||||
hexCreateCollectionTool,
|
||||
@@ -5998,6 +6013,20 @@ export const tools: Record<string, ToolConfig> = {
|
||||
granola_update_webhook_endpoint: granolaUpdateWebhookEndpointTool,
|
||||
granola_delete_webhook_endpoint: granolaDeleteWebhookEndpointTool,
|
||||
guardrails_validate: guardrailsValidateTool,
|
||||
harmonic_batch_get_people: harmonicBatchGetPeopleTool,
|
||||
harmonic_clear_people_saved_search_net_new_results:
|
||||
harmonicClearPeopleSavedSearchNetNewResultsTool,
|
||||
harmonic_enrich_person: harmonicEnrichPersonTool,
|
||||
harmonic_get_company_employees: harmonicGetCompanyEmployeesTool,
|
||||
harmonic_get_email_enrichment_job: harmonicGetEmailEnrichmentJobTool,
|
||||
harmonic_get_email_enrichment_usage: harmonicGetEmailEnrichmentUsageTool,
|
||||
harmonic_get_enrichment_status: harmonicGetEnrichmentStatusTool,
|
||||
harmonic_get_people_saved_search_net_new_results: harmonicGetPeopleSavedSearchNetNewResultsTool,
|
||||
harmonic_get_people_saved_search_results: harmonicGetPeopleSavedSearchResultsTool,
|
||||
harmonic_get_person: harmonicGetPersonTool,
|
||||
harmonic_list_people_saved_searches: harmonicListPeopleSavedSearchesTool,
|
||||
harmonic_search_people_scout: harmonicSearchPeopleScoutTool,
|
||||
harmonic_submit_email_enrichment_job: harmonicSubmitEmailEnrichmentJobTool,
|
||||
hex_cancel_run: hexCancelRunTool,
|
||||
hex_create_collection: hexCreateCollectionTool,
|
||||
hex_create_group: hexCreateGroupTool,
|
||||
|
||||
Reference in New Issue
Block a user