orangeslice 2.4.1-beta.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -105,6 +105,14 @@ async function fetchWithRedirect(url, init) {
105
105
  }
106
106
  return res;
107
107
  }
108
+ function formatRequestError(endpoint, status, message) {
109
+ // 4xx → caller-fixable problem: surface just the server's message so it reads
110
+ // like a normal validation error (e.g. `Cannot resolve sheet "leads"`).
111
+ // 5xx → infra-shaped problem: keep the noisy prefix so callers can grep logs.
112
+ if (status >= 400 && status < 500)
113
+ return message;
114
+ return `[orangeslice] ${endpoint}: ${status} ${message}`;
115
+ }
108
116
  async function pollUntilComplete(baseUrl, endpoint, pending) {
109
117
  const pollUrl = resolvePollUrl(baseUrl, pending);
110
118
  const timeoutAt = Date.now() + POLL_TIMEOUT_MS;
@@ -122,11 +130,14 @@ async function pollUntilComplete(baseUrl, endpoint, pending) {
122
130
  }
123
131
  if (!res.ok) {
124
132
  const message = asErrorMessage(data) || JSON.stringify(data);
125
- throw new Error(`[orangeslice] ${endpoint}: ${res.status} ${message}`);
133
+ throw new Error(formatRequestError(endpoint, res.status, message));
126
134
  }
127
135
  const message = asErrorMessage(data);
128
136
  if (message) {
129
- throw new Error(`[orangeslice] ${endpoint}: ${message}`);
137
+ // 2xx body carrying an `error` field is semantically a caller-fixable
138
+ // error delivered asynchronously — treat it like a 4xx so the message
139
+ // surfaces cleanly without the `[orangeslice] /endpoint: …` prefix.
140
+ throw new Error(formatRequestError(endpoint, 400, message));
130
141
  }
131
142
  return data;
132
143
  }
@@ -153,7 +164,7 @@ async function post(endpoint, payload) {
153
164
  if (!res.ok) {
154
165
  const data = await readResponseBody(res);
155
166
  const message = asErrorMessage(data) || (typeof data === "string" ? data : JSON.stringify(data));
156
- throw new Error(`[orangeslice] ${endpoint}: ${res.status} ${message}`);
167
+ throw new Error(formatRequestError(endpoint, res.status, message));
157
168
  }
158
169
  const data = await readResponseBody(res);
159
170
  if (isPendingResponse(data)) {
package/dist/ctx.d.ts CHANGED
@@ -25,6 +25,20 @@ export interface SpreadsheetListItem {
25
25
  orgId: string | null;
26
26
  userId: string | null;
27
27
  }
28
+ export interface SpreadsheetDescribeColumn {
29
+ id: string;
30
+ name: string;
31
+ }
32
+ export interface SpreadsheetDescribeSheet {
33
+ id: string;
34
+ name: string;
35
+ columns: SpreadsheetDescribeColumn[];
36
+ }
37
+ export interface SpreadsheetDescribeResult {
38
+ id: string;
39
+ name: string;
40
+ sheets: SpreadsheetDescribeSheet[];
41
+ }
28
42
  export interface SqlQueryResult {
29
43
  rows: Record<string, unknown>[];
30
44
  rowIds: string[];
@@ -61,6 +75,7 @@ export declare const ctx: {
61
75
  sql: (spreadsheetId: string, sql: string) => Promise<SqlResult>;
62
76
  spreadsheet: (spreadsheetId: string) => {
63
77
  sql: (sql: string) => Promise<SqlResult>;
78
+ describe: () => Promise<SpreadsheetDescribeResult>;
64
79
  sheet: (sheetName: string) => {
65
80
  addRows: (rows: Record<string, unknown> | Record<string, unknown>[]) => Promise<RowsAddResult>;
66
81
  };
package/dist/ctx.js CHANGED
@@ -44,6 +44,7 @@ function createSpreadsheetHandle(spreadsheetId) {
44
44
  assertNotRun(sql);
45
45
  return (0, api_1.post)("/ctx/sql", { spreadsheetId, sql });
46
46
  },
47
+ describe: () => (0, api_1.post)("/ctx/spreadsheet/describe", { spreadsheetId }),
47
48
  sheet: (sheetName) => createSheetHandle(spreadsheetId, sheetName)
48
49
  };
49
50
  }
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export type { Skill, SkillListParams, SkillCreateParams, SkillUpdateParams } fro
7
7
  export { findCareersPage, scrapeCareersPage } from "./careers";
8
8
  export type { FindCareersPageParams, FindCareersPageResult, ScrapeCareersPageParams, ScrapeCareersPageResult, ScrapeCareersPageJob } from "./careers";
9
9
  export { ctx } from "./ctx";
10
- export type { Spreadsheet, SpreadsheetListItem, SqlResult, SqlQueryResult, SqlActionResult, RowsAddResult } from "./ctx";
10
+ export type { Spreadsheet, SpreadsheetListItem, SpreadsheetDescribeResult, SpreadsheetDescribeSheet, SpreadsheetDescribeColumn, SqlResult, SqlQueryResult, SqlActionResult, RowsAddResult } from "./ctx";
11
11
  export { linkedinSearch } from "./b2b";
12
12
  export type { LinkedInSearchParams, LinkedInSearchResponse } from "./b2b";
13
13
  export { crunchbaseSearch } from "./crunchbase";
@@ -240,6 +240,34 @@ await integrations.attio.createNote({
240
240
 
241
241
  See [attio/](./attio/) for all available functions.
242
242
 
243
+ ### Website tracking
244
+
245
+ LinkedIn-person identification for US-based website visitors. Customers
246
+ paste a single `<script>` tag on their site; each identified visit lands
247
+ as a row on the spreadsheet that owns the trigger.
248
+
249
+ ```typescript
250
+ // Enroll a single domain — creates the trigger + sheet ingester, registers
251
+ // the domain in our master webhook routing table, adds it to the upstream
252
+ // allow-list, and returns the <script> the user pastes on their site.
253
+ const { script } = await integrations.websiteTracking.setupTracking({
254
+ domain: "acme.com",
255
+ sheetName: "Website Visitors"
256
+ });
257
+
258
+ // Inspect / remove later
259
+ const tracked = await integrations.websiteTracking.listTrackedDomains();
260
+ await integrations.websiteTracking.removeTracking("acme.com");
261
+ ```
262
+
263
+ Pricing: **80 credits per identified visitor** (charged at the master
264
+ webhook). Repeat visits and company-only pings are forwarded to the sheet
265
+ but don't cost credits. When the account runs out of credits the domain
266
+ is automatically paused; the next top-up resumes it.
267
+
268
+ See [websiteTracking/](./websiteTracking/) for the full recipe,
269
+ canonical visitor shape, and trigger code template.
270
+
243
271
  ### Gmail
244
272
 
245
273
  Read and write emails from connected Google Gmail accounts.
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: integrations/websiteTracking
3
+ description: Track website visitors (LinkedIn person identification). Use when a user asks to identify who is visiting their site, build website-intent lists, or trigger outreach from anonymous traffic.
4
+ ---
5
+
6
+ # Website Visitor Identification
7
+
8
+ Identify the LinkedIn profile of US-based visitors to a customer's
9
+ website. The customer pastes a single `<script>` tag on their site;
10
+ every identified visit lands as a row on the spreadsheet that owns the
11
+ trigger.
12
+
13
+ - **One shared tracking script** is pasted on the customer's site. No
14
+ per-customer external account, no logins, no separate billing.
15
+ - Every visitor ping fans out to a **master webhook** we own. The
16
+ master webhook looks up the captured domain, charges the customer's
17
+ Orange Slice credit balance, then forwards the visitor record to a
18
+ trigger on the customer's spreadsheet which writes it as a row.
19
+
20
+ ## Pricing
21
+
22
+ **80 credits per identified visitor.** A visitor counts as "identified"
23
+ when we resolve both a LinkedIn URL and a first name on a non-repeat
24
+ visit. Company-only pings and repeat visitor pings are recorded for
25
+ context but NEVER charge credits.
26
+
27
+ When the customer's balance hits zero, the master webhook automatically
28
+ pauses their domain so they stop incurring charges. The next top-up
29
+ resumes the domain automatically — the customer doesn't have to re-paste
30
+ the script.
31
+
32
+ ## The Recipe (call once per domain)
33
+
34
+ ```typescript
35
+ const { script, triggerId } = await integrations.websiteTracking.setupTracking({
36
+ domain: "acme.com",
37
+ sheetName: "Website Visitors" // optional, defaults to this
38
+ });
39
+
40
+ // Hand `script` to the user — they paste it on their site.
41
+ ```
42
+
43
+ `setupTracking` does all of the following atomically:
44
+
45
+ 1. Creates a sheet-aware trigger that ingests visitor payloads into the
46
+ chosen sheet (uses `addRows` with `createMissingColumns: true` so the
47
+ user doesn't have to pre-create columns).
48
+ 2. Inserts the domain → trigger mapping into our master routing table.
49
+ 3. Calls the upstream allow-list API to register the domain.
50
+ 4. Returns the canonical `<script>` tag the user pastes on their site.
51
+
52
+ If the same domain is re-enrolled it is idempotent — the existing
53
+ trigger and mapping are reused.
54
+
55
+ ## After setupTracking — what to tell the user
56
+
57
+ 1. Paste the returned `script` into the `<head>` of their website.
58
+ 2. Let them know identified visitors will appear in the chosen sheet
59
+ within ~5 minutes of installing. Pricing: 80 credits per identified
60
+ person.
61
+ 3. If they want to enrich rows further, point out that the trigger only
62
+ ingests — they should add **column code** (or just let the AI suggest
63
+ columns) for tasks like "compute company size" or "draft an outreach
64
+ email" so per-row work shows in the UI and is retryable.
65
+
66
+ ## Multiple sites
67
+
68
+ A single account can track many domains, each with its own trigger and
69
+ sheet. Just call `setupTracking` once per domain. A domain can only be
70
+ tracked by ONE Orange Slice account at a time — re-enrolling from another
71
+ account throws unless the original account previously called
72
+ `removeTracking` (then the row is a tombstone and the new account can
73
+ claim it).
74
+
75
+ ## Inspecting / removing
76
+
77
+ ```typescript
78
+ // What's tracked on this spreadsheet?
79
+ const mappings = await integrations.websiteTracking.listTrackedDomains();
80
+
81
+ // Stop tracking a domain.
82
+ await integrations.websiteTracking.removeTracking("acme.com");
83
+ ```
84
+
85
+ See [setupTracking.md](./setupTracking.md) for the full input/output
86
+ contract.
@@ -0,0 +1,110 @@
1
+ ---
2
+ name: integrations/websiteTracking/setupTracking
3
+ description: Atomic enrollment helper — creates the receiving trigger, registers the domain in the master webhook table, adds the domain to the upstream allow-list, returns the user-facing <script> tag.
4
+ ---
5
+
6
+ # integrations.websiteTracking.setupTracking
7
+
8
+ Enroll one customer domain into website-visitor tracking.
9
+
10
+ ## Input
11
+
12
+ ```typescript
13
+ {
14
+ domain: string; // e.g. "acme.com" (with or without scheme/www)
15
+ sheetName?: string; // defaults to "Website Visitors"
16
+ triggerName?: string; // defaults to `Website visitors – {domain}`
17
+ }
18
+ ```
19
+
20
+ The `domain` is normalized: protocol, `www.`, paths, query, and ports are
21
+ stripped before storage. `"https://www.acme.com/pricing"` and `"acme.com"`
22
+ both store as `acme.com`.
23
+
24
+ ## Output
25
+
26
+ ```typescript
27
+ {
28
+ script: string; // <script> tag for the user to paste
29
+ domain: string; // normalized domain
30
+ triggerId: string;
31
+ mappingId: string;
32
+ sheetName: string;
33
+ triggerName: string;
34
+ alreadyOwned: boolean; // true if mapping existed before this call
35
+ alreadyOnAllowList: boolean; // true if the upstream allow-list already had the domain
36
+ }
37
+ ```
38
+
39
+ ## Errors
40
+
41
+ - `Invalid domain` — `domain` couldn't be normalized to a host with a TLD.
42
+ - `Domain "<x>" is already claimed by another account` — every domain
43
+ belongs to exactly one Orange Slice account (a `removed` tombstone from
44
+ a prior teardown does NOT lock the domain).
45
+
46
+ ## What the trigger does
47
+
48
+ The trigger code generated by this helper stays thin per the
49
+ [triggers-runtime.md](../../triggers-runtime.md) rule: it just normalizes
50
+ the upstream payload into the canonical visitor shape and pushes a row
51
+ into the user's chosen sheet with `createMissingColumns: true` and
52
+ `run: true`. Any enrichment / scoring belongs in column code on that
53
+ sheet.
54
+
55
+ ## Canonical visitor shape
56
+
57
+ Every identified visit is delivered to the trigger as a record with
58
+ these fields:
59
+
60
+ ```typescript
61
+ {
62
+ linkedinUrl: string | null;
63
+ firstName: string | null;
64
+ lastName: string | null;
65
+ title: string | null;
66
+ companyName: string | null;
67
+ businessEmail: string | null;
68
+ website: string | null;
69
+ industry: string | null;
70
+ employeeCount: number | string | null;
71
+ estimatedRevenue: string | null;
72
+ city: string | null;
73
+ state: string | null;
74
+ zip: string | null;
75
+ seenAt: string | null;
76
+ referrer: string | null;
77
+ capturedUrl: string | null;
78
+ tags: string | null;
79
+ isRepeatVisit: boolean;
80
+ extra?: Record<string, unknown>; // optional extras (UTMs, seniority, etc.)
81
+ }
82
+ ```
83
+
84
+ ## Example chat flow
85
+
86
+ When a user says "I want to track who visits my site", do this:
87
+
88
+ ```typescript
89
+ const result = await integrations.websiteTracking.setupTracking({
90
+ domain: "acme.com"
91
+ });
92
+
93
+ // Then tell the user:
94
+ // 1. Paste this snippet (`result.script`) into the <head> of acme.com.
95
+ // 2. Identified visitors will land in the "Website Visitors" sheet.
96
+ // 3. Each identified visitor costs 80 credits. Repeat / company-only
97
+ // visits are free.
98
+ // 4. If credits run out, tracking pauses automatically and resumes on
99
+ // the next top-up.
100
+ ```
101
+
102
+ ## Pricing recap
103
+
104
+ Charged once per ping where:
105
+ - `linkedinUrl` is present, AND
106
+ - `firstName` is present, AND
107
+ - `isRepeatVisit` is not `true`.
108
+
109
+ Cost: **80 credits**. Company-only and repeat visits are still forwarded
110
+ to the sheet (so the user can analyze recurring traffic) but cost zero.
@@ -53,8 +53,30 @@ await ss.sheet("contacts").addRows([
53
53
  ```
54
54
 
55
55
  - **`ss.sql(sql)`** — Execute EAV-SQL (same as `ctx.sql` but without needing to pass spreadsheetId).
56
+ - **`ss.describe()`** — Return the structural snapshot of the spreadsheet: its `id`, `name`, and an array of `sheets`, each with its `columns`. Use this to discover sheet/column names before issuing SQL — especially when attaching to a spreadsheet you didn't create.
56
57
  - **`ss.sheet(name).addRows(rows)`** — Insert one or more rows into the named sheet. Accepts a single row object or an array. Missing columns are auto-created.
57
58
 
59
+ ### Attaching to an existing spreadsheet
60
+
61
+ When the spreadsheet was created elsewhere (e.g. in the UI), use `describe()` to discover what's inside before running SQL:
62
+
63
+ ```typescript
64
+ const { spreadsheets } = await ctx.listSpreadsheets();
65
+ const target = spreadsheets.find((s) => s.name === "Find more paying customers V1")!;
66
+
67
+ const ss = ctx.spreadsheet(target.id);
68
+ const info = await ss.describe();
69
+ // info.sheets: [{ id, name, columns: [{ id, name }] }, ...]
70
+
71
+ for (const sheet of info.sheets) {
72
+ const cols = sheet.columns.map((c) => c.name).join(", ");
73
+ console.log(`${sheet.name}: ${cols}`);
74
+ }
75
+
76
+ // Now you know the real sheet names; query safely
77
+ const result = await ss.sql(`SELECT * FROM "${info.sheets[0].name}" LIMIT 10`);
78
+ ```
79
+
58
80
  ## EAV-SQL Reference
59
81
 
60
82
  The `sql` method supports a subset of SQL mapped to Orange Slice's EAV storage:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orangeslice",
3
- "version": "2.4.1-beta.0",
3
+ "version": "2.5.0",
4
4
  "description": "B2B LinkedIn database prospector - 1.15B profiles, 85M companies",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",