bereach-openclaw 1.3.1 → 1.3.2
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/openclaw.plugin.json +1 -1
- package/package.json +3 -3
- package/skills/bereach/SKILL.md +17 -6
- package/skills/bereach/sdk-reference.md +14 -15
- package/skills/bereach/sub/lead-gen.md +91 -151
- package/skills/bereach/sub/lead-magnet.md +7 -7
- package/skills/bereach/sub/outreach.md +1 -1
- package/skills/bereach/workspace/agents-template.md +20 -0
- package/skills/bereach/workspace/soul-template.md +152 -0
- package/src/commands/setup.ts +37 -0
- package/src/index.ts +21 -0
- package/src/test-sdk-methods.mts +284 -0
- package/src/tools/definitions.ts +14 -13
- package/src/tools/index.ts +45 -15
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bereach-openclaw",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "BeReach LinkedIn automation plugin for OpenClaw",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"exports": {
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
"bereach": "^1.3.2"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@types/node": "^
|
|
21
|
+
"@types/node": "^25.5.0",
|
|
22
22
|
"tsx": "^4.21.0",
|
|
23
23
|
"typescript": "^5.9.3",
|
|
24
|
-
"vitest": "^4.0
|
|
24
|
+
"vitest": "^4.1.0"
|
|
25
25
|
}
|
|
26
26
|
}
|
package/skills/bereach/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bereach
|
|
3
3
|
description: "Automate LinkedIn outreach via BeReach (berea.ch). Use when: prospecting, engaging posts, scraping engagement, searching LinkedIn, managing inbox, running campaigns, managing invitations, analytics, company pages, Sales Navigator, content engagement, feed monitoring. Requires BEREACH_API_KEY."
|
|
4
|
-
lastUpdatedAt:
|
|
4
|
+
lastUpdatedAt: 1773675510
|
|
5
5
|
metadata: { "openclaw": { "requires": { "env": ["BEREACH_API_KEY"] }, "primaryEnv": "BEREACH_API_KEY" } }
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -24,11 +24,22 @@ Automate LinkedIn prospection and engagement via BeReach.
|
|
|
24
24
|
|
|
25
25
|
| Sub-skill | Keywords | URL | lastUpdatedAt |
|
|
26
26
|
| --------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------- |
|
|
27
|
-
| Lead Magnet | lead magnet, comment to DM, resource delivery, post giveaway, auto-accept invitations, scheduled run, recap, campaign stats, pause | sub/lead-magnet.md |
|
|
28
|
-
| Lead Gen | lead gen, find leads, search, qualify, ICP, pipeline, build list, scrape engagement, competitor, grow database, prospecting, hashtag, Sales Navigator | sub/lead-gen.md |
|
|
29
|
-
| Outreach | outreach, connect, DM, message, follow up, sequence, connection request, reply, personalized outreach, campaign outreach, warming, follow, like post | sub/outreach.md |
|
|
27
|
+
| Lead Magnet | lead magnet, comment to DM, resource delivery, post giveaway, auto-accept invitations, scheduled run, recap, campaign stats, pause | sub/lead-magnet.md | 1773675510 |
|
|
28
|
+
| Lead Gen | lead gen, find leads, search, qualify, ICP, pipeline, build list, scrape engagement, competitor, grow database, prospecting, hashtag, Sales Navigator | sub/lead-gen.md | 1773663610 |
|
|
29
|
+
| Outreach | outreach, connect, DM, message, follow up, sequence, connection request, reply, personalized outreach, campaign outreach, warming, follow, like post | sub/outreach.md | 1773616752 |
|
|
30
30
|
| OpenClaw Optimization | openclaw, model, opus, sonnet, haiku, config, SOUL.md, heartbeat, prompt caching, AI cost reduction, /model | openclaw-optimization.md | 1773274027 |
|
|
31
|
-
| SDK Reference | sdk, method, parameter, signature, reference, api, script | sdk-reference.md |
|
|
31
|
+
| SDK Reference | sdk, method, parameter, signature, reference, api, script | sdk-reference.md | 1773665416 |
|
|
32
|
+
|
|
33
|
+
### Workspace Templates
|
|
34
|
+
|
|
35
|
+
At install or upgrade, set up the workspace with these templates:
|
|
36
|
+
|
|
37
|
+
| Template | Purpose | URL |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| SOUL.md | Always-loaded tool listing + session startup + sub-skills routing table | workspace/soul-template.md |
|
|
40
|
+
| AGENTS.md | Lean sub-agent safety net | workspace/agents-template.md |
|
|
41
|
+
|
|
42
|
+
**Step 0 (mandatory at install/upgrade):** Check if SOUL.md contains `<!-- bereach-workspace-v`. If missing: fetch soul-template.md from the URL above (or via `GET /api/onboarding/soul`), write it into the user's SOUL.md between the markers. Set up the pipeline cron (every 15min) if not already configured. This step cannot be skipped.
|
|
32
43
|
|
|
33
44
|
### Supporting files
|
|
34
45
|
|
|
@@ -153,7 +164,7 @@ These are technical constraints BeReach requires. Everything else, adapt as need
|
|
|
153
164
|
|
|
154
165
|
1. **Dedup** — pass `campaignSlug` on every action. BeReach deduplicates by target automatically. Duplicates return `duplicate: true` and cost nothing. Pre-check: use `client.campaigns.getStatus()` or the `getStatus` tool.
|
|
155
166
|
2. **Pacing** — after every SDK/tool call, sleep a random delay. Write actions (DM, reply, like, comment, connect, accept, follow, repost, edit, sync): `random delay 8-12s`. Read actions (visit, scrape, search, find, feed, analytics, count-0 checks): `random delay 2-6s`. Inbox management (archive, star, mark-read, react, typing): `random delay 1-3s`. On 429: wait the number of seconds from the error response, retry (max 3). If daily/weekly cap hit, switch action type.
|
|
156
|
-
3. **Save incrementally** — persist tracking after each action, not at the end.
|
|
167
|
+
3. **Save incrementally** — persist pipeline progress and tracking state after each action, not at the end. Contact data is auto-saved by API calls; only save operational state (e.g. which posts were scraped, discovery sources processed).
|
|
157
168
|
4. **Limits check** — call `getLimits` once per day at session start.
|
|
158
169
|
5. **Visit before connecting** — looks natural to LinkedIn.
|
|
159
170
|
6. **Credits** — check with `getCredits` → `{credits: {current, limit, remaining, percentage, isUnlimited}}`. When `isUnlimited` is true, `remaining` and `limit` are null — credits are unlimited, skip credit budgeting.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bereach-sdk-reference
|
|
3
3
|
description: "Complete SDK v1.3.2 method reference — parameters, types, and descriptions for all BeReach operations."
|
|
4
|
-
lastUpdatedAt:
|
|
4
|
+
lastUpdatedAt: 1773665416
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
<!--
|
|
@@ -47,7 +47,7 @@ Methods are split across three getters: `scrapers`, `linkedinScrapers`, and `lin
|
|
|
47
47
|
|
|
48
48
|
### collectLikes
|
|
49
49
|
|
|
50
|
-
Scrape LinkedIn post likes. Returns paginated list of profiles who liked a post.
|
|
50
|
+
Scrape LinkedIn post likes. Returns paginated list of profiles who liked a post. **Auto-saves:** auto-creates a contact record for each liker.
|
|
51
51
|
|
|
52
52
|
`client.linkedinScrapers.collectLikes(params)`
|
|
53
53
|
|
|
@@ -68,7 +68,7 @@ Scrape LinkedIn post likes. Returns paginated list of profiles who liked a post.
|
|
|
68
68
|
|
|
69
69
|
### collectComments
|
|
70
70
|
|
|
71
|
-
Scrape LinkedIn post comments. Returns paginated list of commenters with comment text and URNs.
|
|
71
|
+
Scrape LinkedIn post comments. Returns paginated list of commenters with comment text and URNs. **Auto-saves:** auto-creates a contact record for each commenter.
|
|
72
72
|
|
|
73
73
|
`client.linkedinScrapers.collectComments(params)`
|
|
74
74
|
|
|
@@ -92,7 +92,7 @@ Scrape LinkedIn post comments. Returns paginated list of commenters with comment
|
|
|
92
92
|
|
|
93
93
|
### collectCommentReplies
|
|
94
94
|
|
|
95
|
-
Scrape replies to a LinkedIn comment. Returns paginated replies for a specific comment URN.
|
|
95
|
+
Scrape replies to a LinkedIn comment. Returns paginated replies for a specific comment URN. **Auto-saves:** auto-creates a contact record for each replier.
|
|
96
96
|
|
|
97
97
|
`client.linkedInScrapers.collectCommentReplies(params)`
|
|
98
98
|
|
|
@@ -115,7 +115,7 @@ Scrape replies to a LinkedIn comment. Returns paginated replies for a specific c
|
|
|
115
115
|
|
|
116
116
|
### collectPosts
|
|
117
117
|
|
|
118
|
-
Scrape LinkedIn profile posts. Returns paginated list of posts from a profile.
|
|
118
|
+
Scrape LinkedIn profile posts. Returns paginated list of posts from a profile. **Auto-saves:** auto-creates/updates a contact record for the profile.
|
|
119
119
|
|
|
120
120
|
`client.scrapers.collectPosts(params)`
|
|
121
121
|
|
|
@@ -139,7 +139,7 @@ Scrape LinkedIn profile posts. Returns paginated list of posts from a profile.
|
|
|
139
139
|
|
|
140
140
|
### visitProfile
|
|
141
141
|
|
|
142
|
-
Visit LinkedIn profile and extract contact data. Distance-1 profiles cached 24h. No dedup — always executes.
|
|
142
|
+
Visit LinkedIn profile and extract contact data. Distance-1 profiles cached 24h. No dedup — always executes. **Auto-saves:** creates/updates the contact record with full profileData — do not manually save profileData after calling this.
|
|
143
143
|
|
|
144
144
|
`client.scrapers.visitProfile(params)`
|
|
145
145
|
|
|
@@ -529,9 +529,8 @@ Reply to a LinkedIn comment. Use the commentUrn from the comments endpoint direc
|
|
|
529
529
|
|
|
530
530
|
`client.linkedinActions.replyToComment(params)`
|
|
531
531
|
|
|
532
|
-
- **postUrl** (string, required) — LinkedIn post URL containing the comment.
|
|
533
532
|
- **commentUrn** (string, required) — LinkedIn comment URN (e.g., 'urn:li:comment:(activity:123,456)').
|
|
534
|
-
- **
|
|
533
|
+
- **message** (string, required) — Reply message text.
|
|
535
534
|
- **campaignSlug** (string) — Campaign identifier for deduplication.
|
|
536
535
|
|
|
537
536
|
**Returns:**
|
|
@@ -587,7 +586,7 @@ Add a top-level comment on a LinkedIn post. Deduplicates by post within a campai
|
|
|
587
586
|
`client.linkedInActions.createComment(params)`
|
|
588
587
|
|
|
589
588
|
- **postUrl** (string, required) — LinkedIn post URL.
|
|
590
|
-
- **
|
|
589
|
+
- **message** (string, required) — Comment text.
|
|
591
590
|
- **companyId** (string) — Act as a company page (numeric company ID) instead of personal profile.
|
|
592
591
|
- **campaignSlug** (string) — Campaign identifier for deduplication.
|
|
593
592
|
|
|
@@ -853,7 +852,7 @@ Search LinkedIn inbox conversations by keyword. 0 credits.
|
|
|
853
852
|
|
|
854
853
|
### findConversation
|
|
855
854
|
|
|
856
|
-
Find a conversation with a specific person. Direct O(1) lookup via LinkedIn's compose API. 0 credits.
|
|
855
|
+
Find a conversation with a specific person. Direct O(1) lookup via LinkedIn's compose API. 0 credits. **Auto-saves:** creates/updates the contact record with conversationData — do not manually save conversationData after calling this.
|
|
857
856
|
|
|
858
857
|
`client.chat.findConversation(params)`
|
|
859
858
|
|
|
@@ -1287,7 +1286,7 @@ The SDK `contacts` getter only exposes `upsert`. All other contact methods (getB
|
|
|
1287
1286
|
|
|
1288
1287
|
### upsert
|
|
1289
1288
|
|
|
1290
|
-
Create or upsert contacts without a campaign.
|
|
1289
|
+
Create or upsert contacts without a campaign. **For manual imports only** — most scrape/search/visit calls auto-create contacts. Use this only when the user provides a CSV/spreadsheet of profiles to import. Upserts by LinkedIn URL.
|
|
1291
1290
|
|
|
1292
1291
|
`client.contacts.upsert(params)`
|
|
1293
1292
|
|
|
@@ -1597,10 +1596,10 @@ Update a contact: lifecycle stage, score, notes, profile/conversation context, o
|
|
|
1597
1596
|
- **qualificationNotes** (string) — Agent reasoning.
|
|
1598
1597
|
- **notes** (string)
|
|
1599
1598
|
- **name** (string) — Update contact name.
|
|
1600
|
-
- **profileData** (object) — Full LinkedIn profile snapshot (JSON).
|
|
1601
|
-
- **profileUpdatedAt** (string, ISO datetime) — When profileData was last refreshed.
|
|
1602
|
-
- **conversationData** (object) — DM history: `{ conversationUrn, historySummary, recentMessages, lastSyncedAt }`.
|
|
1603
|
-
- **conversationUpdatedAt** (string, ISO datetime) — When conversationData was last synced.
|
|
1599
|
+
- **profileData** (object) — Full LinkedIn profile snapshot (JSON). **Note:** auto-populated by `visitProfile` — only set manually for data not obtained through a visit.
|
|
1600
|
+
- **profileUpdatedAt** (string, ISO datetime) — When profileData was last refreshed. Auto-set by `visitProfile`.
|
|
1601
|
+
- **conversationData** (object) — DM history: `{ conversationUrn, historySummary, recentMessages, lastSyncedAt }`. **Note:** auto-populated by `findConversation` — only set manually for data not obtained through a find.
|
|
1602
|
+
- **conversationUpdatedAt** (string, ISO datetime) — When conversationData was last synced. Auto-set by `findConversation`.
|
|
1604
1603
|
- **outreachStatus** (string) — Manual override. Usually auto-synced — only set manually for `in_conversation`, `converted`, `not_interested`.
|
|
1605
1604
|
- **nextFollowUpAt** (string, ISO datetime | null) — When the agent should follow up. Queryable via `searchContacts({ followUpBefore })`. Set `null` to clear.
|
|
1606
1605
|
- **draftNextDm** (string | null) — Draft DM to send on next follow-up. Set `null` to clear. Auto-cleared when the contact replies or a message is sent. `draftNextDmGeneratedAt` is auto-set on write.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bereach-lead-gen
|
|
3
3
|
description: "Autonomous lead generation — find and qualify leads daily through multi-angle search, engagement scraping, and a learning loop."
|
|
4
|
-
lastUpdatedAt:
|
|
4
|
+
lastUpdatedAt: 1773663610
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
<!--
|
|
@@ -30,9 +30,71 @@ Lead gen uses a **hybrid execution model**:
|
|
|
30
30
|
- **Tool-based (default)** — the agent uses BeReach tools directly. It thinks between calls, adapts in real-time, tries different angles. No TypeScript script generation needed. This is the right mode for exploration and daily runs (20-30 profiles).
|
|
31
31
|
- **Script-based (scale)** — when an angle is proven (client validated, good results), the agent can generate a TypeScript script to process it at high volume (hundreds of profiles). Scripts go in `src/lead-gen/` — do NOT modify `src/lead-magnet/helpers.ts`.
|
|
32
32
|
|
|
33
|
+
## Auto-Save Architecture
|
|
34
|
+
|
|
35
|
+
Every API route that returns profile data auto-creates/updates contacts via Next.js `after()` (no latency impact). `smartUpsertContact` handles dedup by profileUrn/publicIdentifier/linkedinUrl.
|
|
36
|
+
|
|
37
|
+
| API call | What auto-saves | Credits |
|
|
38
|
+
| ----------------------- | ------------------------------------- | --------------- |
|
|
39
|
+
| `collectComments` | Contact per commenter (name + URL) | 1 |
|
|
40
|
+
| `collectLikes` | Contact per liker (name + URL) | 1 |
|
|
41
|
+
| `collectPosts` | Contact for profile + saves posts | 1 |
|
|
42
|
+
| `collectCommentReplies` | Contact per replier | 1 |
|
|
43
|
+
| `collectHashtagPosts` | Contact per post author | 1 |
|
|
44
|
+
| `searchPeople` | Contact per result | 1 |
|
|
45
|
+
| `searchPosts` | Contact per post author | 1 |
|
|
46
|
+
| `searchByUrl` | Contact per result | 1 |
|
|
47
|
+
| `salesNav.*` | Contact per result | dynamic |
|
|
48
|
+
| `visitProfile` | Creates/updates with full profileData | 1 (0 if cached) |
|
|
49
|
+
| `findConversation` | Creates/updates with conversationData | 0 |
|
|
50
|
+
| `listConversations` | Contact per conversation participant | 0 |
|
|
51
|
+
|
|
52
|
+
`searchCompanies` and `searchJobs` do NOT create contacts (not people).
|
|
53
|
+
|
|
54
|
+
**Consequence:** The agent never holds profile data in memory. Never calls `contacts.upsertContacts` for data that was just scraped/searched/visited. `contacts.upsertContacts` is only for manual imports (user pastes a CSV/spreadsheet).
|
|
55
|
+
|
|
56
|
+
## Pipeline: 4 Phases
|
|
57
|
+
|
|
58
|
+
The DB is the queue. Each phase maps to a DB query.
|
|
59
|
+
|
|
60
|
+
### Phase 1: Discover
|
|
61
|
+
|
|
62
|
+
Use ANY channel (search, scrape, hashtag, followers, feed, Sales Nav...). Contacts are auto-created by every API call. If campaign-based: `addContacts` links to campaign + sets source/sourceAngle. Strategy is up to the agent based on user prompt.
|
|
63
|
+
|
|
64
|
+
### Phase 2: Enrich
|
|
65
|
+
|
|
66
|
+
`searchContacts({ hasProfileData: "false" })` = the work queue. For each contact: `visitProfile` + `findConversation`. Both auto-save to the contact record. Skip errors, try next. Resumes across sessions automatically since the DB tracks what's enriched.
|
|
67
|
+
|
|
68
|
+
### Phase 3: Qualify
|
|
69
|
+
|
|
70
|
+
`searchContacts({ hasProfileData: "true", lifecycleStage: "contact" })` = contacts needing qualification. Evaluate against ICP (from campaign context or user prompt). Update `lifecycleStage` + `hotScore` via `contacts.updateContact`. Stages: `contact` → `lead` → `qualified`. Rules are campaign-specific, not hardcoded.
|
|
71
|
+
|
|
72
|
+
### Phase 4: Report
|
|
73
|
+
|
|
74
|
+
`contacts.getStats()` for funnel metrics. `searchContacts` by `hotScore` for top qualified leads. Present results in a clean table.
|
|
75
|
+
|
|
76
|
+
### Multi-Day Resilience
|
|
77
|
+
|
|
78
|
+
The DB IS the queue:
|
|
79
|
+
|
|
80
|
+
- `searchContacts({ hasProfileData: "false" })` = Phase 2 remaining
|
|
81
|
+
- `searchContacts({ hasProfileData: "true", lifecycleStage: "contact" })` = Phase 3 remaining
|
|
82
|
+
|
|
83
|
+
`agentState` only tracks which discovery sources were already processed. Everything else lives in the DB.
|
|
84
|
+
|
|
85
|
+
### Data Model Reference
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
LifecycleStage: contact -> lead -> qualified -> approved | rejected
|
|
89
|
+
ContactSource: likes, comments, reposts, posts, company_followers,
|
|
90
|
+
search_results, engagement_scraping, content_search, followers_mining,
|
|
91
|
+
people_search, job_search, company_search, network_expansion,
|
|
92
|
+
manual_import, event_attendees, group_members
|
|
93
|
+
```
|
|
94
|
+
|
|
33
95
|
## Channels
|
|
34
96
|
|
|
35
|
-
|
|
97
|
+
Eleven lead-finding channels, ordered by typical warmth. The agent picks channels based on the prompt, context, and available budget.
|
|
36
98
|
|
|
37
99
|
### 1. Engagement scraping (hottest)
|
|
38
100
|
|
|
@@ -143,92 +205,20 @@ LinkedIn supports: `"exact phrase"`, `AND`, `OR`, `NOT`, parentheses. Teach effe
|
|
|
143
205
|
|
|
144
206
|
When the client pastes a LinkedIn search URL, use `searchByUrl` to extract all filters automatically. Fast way to define new angles from the client's own LinkedIn browsing.
|
|
145
207
|
|
|
146
|
-
## Qualification
|
|
147
|
-
|
|
148
|
-
Two-stage qualification to save credits.
|
|
149
|
-
|
|
150
|
-
### Stage 1 — Pre-filter (0 credits)
|
|
151
|
-
|
|
152
|
-
Search results and scrape results already contain some data (name, headline snippet, profile URL). Filter here first — skip obviously irrelevant profiles before spending 1 credit on `visitProfile`. For engagement scraping, the comment text itself is a clue (substantive vs "great post").
|
|
153
|
-
|
|
154
|
-
### Stage 2 — Deep qualification (1 credit)
|
|
155
|
-
|
|
156
|
-
`visitProfile` with `includePosts: true`. Available data and what it typically tells you:
|
|
157
|
-
|
|
158
|
-
| Data point | What it tells you |
|
|
159
|
-
| --- | --- |
|
|
160
|
-
| `publicIdentifier` | Vanity name for URL building and dedup |
|
|
161
|
-
| `profileUrn` | URN for API calls and conversation matching |
|
|
162
|
-
| `headline` | Role, seniority, positioning |
|
|
163
|
-
| `position` + `company` | Current role and company fit |
|
|
164
|
-
| `positions` (array) | Career trajectory, recent job change = transition indicator |
|
|
165
|
-
| `location` | Geographic fit, timezone |
|
|
166
|
-
| `memberDistance` | 1st = already connected, 2nd = mutual connections (warmer) |
|
|
167
|
-
| `pendingConnection` | "pending" = already tried, "none" = fresh |
|
|
168
|
-
| `lastPosts` | Topics, concerns, engagement level |
|
|
169
|
-
| `email` | Available for distance-1 only |
|
|
170
|
-
| `educations` | Background, alumni networks |
|
|
171
|
-
| `cached` | If `true`, visit was free (distance-1 cached 24h) |
|
|
172
|
-
|
|
173
|
-
The agent decides what matters based on the client's context. No fixed scoring formula.
|
|
174
|
-
|
|
175
|
-
### Company-level qualification
|
|
176
|
-
|
|
177
|
-
When company fit matters, use `visitCompany` (1 credit): `employeeCount`, `industry`, `specialities`, `foundedOn`, `similarCompanies` (free expansion), `websiteUrl`. Cache company data — multiple leads may work at the same company.
|
|
178
|
-
|
|
179
|
-
## Lifecycle stages
|
|
180
|
-
|
|
181
|
-
Every person progresses through stages. A scraped commenter is a **contact**, not a lead. The data model reflects this:
|
|
182
|
-
|
|
183
|
-
| Stage | What happens | Credits | Tracked via |
|
|
184
|
-
| --- | --- | --- | --- |
|
|
185
|
-
| `contact` | Raw profile from search/scrape | 0 | `contacts.upsertContacts` or `contacts.addContacts` (linkedinUrl + name) |
|
|
186
|
-
| `lead` | Pre-filtered (Stage 1), looks promising | 0 | `contacts.updateContact` + qualificationNotes |
|
|
187
|
-
| `qualified` | Deep-qualified via visitProfile, confirmed ICP | 1/profile | `contacts.updateContact` + hotScore + profileData. Log `visitProfile` result as activity. |
|
|
188
|
-
| `approved` | Client reviewed and confirmed | 0 | `contacts.updateContact` |
|
|
189
|
-
| `rejected` | Doesn't match ICP (from any stage) | 0 | `contacts.updateContact` + rejection reason |
|
|
190
|
-
|
|
191
|
-
Stage moves forward only (except `rejected`).
|
|
192
|
-
|
|
193
|
-
**Profile and conversation data can be stored on the contact.** When you `visitProfile`, save the result to `profileData` (JSON) via `contacts.updateContact({ contactId, profileData: { ... }, profileUpdatedAt: "..." })`. When you check DM history via `findConversation`, save to `conversationData`. This gives the outreach agent instant context without re-visiting. Use `searchContacts({ hasProfileData: "false" })` to find contacts needing enrichment.
|
|
194
|
-
|
|
195
208
|
## Contacts DB
|
|
196
209
|
|
|
197
210
|
The contacts DB stores identity, pipeline status, outreach status, activities, and optionally cached profile/conversation data. All contact operations are 0 credits.
|
|
198
211
|
|
|
199
|
-
###
|
|
200
|
-
|
|
201
|
-
**Note: auto-upsert.** BeReach auto-creates contacts when the agent scrapes posts, reads inbox, or finds conversations. The agent should still use `upsertContacts` or `addContacts` to set lifecycle stage, hotScore, tags, and campaign membership — but doesn't need to worry about creating the raw contact record.
|
|
202
|
-
|
|
203
|
-
**1. Organic (no campaign):** Use `contacts.upsertContacts` to save contacts as you discover them. No campaign required. Attach to campaigns later. Best for exploratory scanning — "save everything interesting, organize later."
|
|
204
|
-
|
|
205
|
-
```
|
|
206
|
-
contacts.upsertContacts({ contacts: [
|
|
207
|
-
{ linkedinUrl: "...", name: "Alice", lifecycleStage: "contact" },
|
|
208
|
-
{ linkedinUrl: "...", name: "Bob", lifecycleStage: "lead", hotScore: 70 },
|
|
209
|
-
] })
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
**2. Campaign-based:** Use `contacts.addContacts` when you have a clear angle. Campaign naming: `lg-{channel}-{descriptor}` (e.g., `lg-engagement-competitor-openai`).
|
|
212
|
+
### Creating and organizing contacts
|
|
213
213
|
|
|
214
|
-
|
|
214
|
+
Contacts are auto-created by scrape/search/visit calls. To organize them:
|
|
215
215
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
2b. When adding contacts, include `source` (how found: `"engagement_scraping"`, `"content_search"`, `"people_search"`, `"company_followers"`, `"network_expansion"`) and `sourceAngle` (the specific search/post: `"VP Sales SaaS Paris"`, `"post about cold email tools"`). This enables attribution reporting via `contacts.getStats()`.
|
|
219
|
-
3. Log observations: `contacts.logActivity({ contactId, activities: [{ type: "engagement_observed", content: "..." }] })`
|
|
220
|
-
4. Pre-filter promising ones → promote: `contacts.updateContact({ contactId, lifecycleStage: "lead", qualificationNotes: "VP Sales at SaaS company" })`
|
|
221
|
-
5. Deep-qualify via `visitProfile` → store profile data + promote:
|
|
222
|
-
```
|
|
223
|
-
contacts.updateContact({ contactId, lifecycleStage: "qualified", hotScore: 85, profileData: visitResult, profileUpdatedAt: new Date().toISOString() })
|
|
224
|
-
```
|
|
225
|
-
6. **Bulk pre-qualification:** promote hundreds at once: `contacts.bulkUpdate({ contactIds: [...], update: { lifecycleStage: "lead" } })`
|
|
226
|
-
7. **Tag for segmentation:** `contacts.updateContact({ contactId, tags: { add: ["vp-sales", "saas-100-500"] } })`
|
|
227
|
-
8. Client reviews → `contacts.updateContact({ contactId, lifecycleStage: "approved" })` or `"rejected"`
|
|
216
|
+
- **Campaign-based (recommended):** Use `contacts.addContacts` to link contacts to a campaign + set source/sourceAngle/initial stage. `addContacts` is a full upsert — creates the contact if it doesn't exist, updates if it does, and links to the campaign. Campaign naming: `lg-{channel}-{descriptor}` (e.g., `lg-engagement-competitor-openai`).
|
|
217
|
+
- **Manual import only:** Use `contacts.upsertContacts` only when the user provides a CSV/spreadsheet of profiles to import. Never use it for data that was just scraped or visited.
|
|
228
218
|
|
|
229
219
|
### Activities
|
|
230
220
|
|
|
231
|
-
Every meaningful observation or action is logged as an activity on the contact
|
|
221
|
+
Every meaningful observation or action is logged as an activity on the contact:
|
|
232
222
|
|
|
233
223
|
- `engagement_observed` — "Commented on [post URL]: 'great insights about outbound'"
|
|
234
224
|
- `post_interaction` — "Liked competitor post about sales automation"
|
|
@@ -245,105 +235,55 @@ Before visiting a profile (1 credit), check: `contacts.getByUrl({ linkedinUrl })
|
|
|
245
235
|
|
|
246
236
|
### Enrichment planning
|
|
247
237
|
|
|
248
|
-
Find contacts that need profile data: `contacts.searchContacts({ hasProfileData: "false"
|
|
238
|
+
Find contacts that need profile data: `contacts.searchContacts({ hasProfileData: "false" })`. Prioritize by score: `sortBy: "hotScore_desc"`.
|
|
249
239
|
|
|
250
240
|
### Funnel metrics
|
|
251
241
|
|
|
252
242
|
`contacts.getStats()` returns a real funnel: `{ contact: 450, lead: 120, qualified: 45, approved: 20, rejected: 30 }` per campaign and globally, plus conversion rates per `sourceAngle`. Powers the learning loop.
|
|
253
243
|
|
|
254
|
-
##
|
|
255
|
-
|
|
256
|
-
The agent doesn't run all channels equally. Default priority by warmth and credit efficiency:
|
|
257
|
-
|
|
258
|
-
1. **New engagement** (hottest, best ratio) — scrape watchlisted profiles for new posts/comments since last run
|
|
259
|
-
2. **Profile views** (warm, passive) — `profile.getProfileViews()` to find people already checking you out
|
|
260
|
-
3. **Content activity** (hot) — `searchPosts` for recent posts about relevant topics
|
|
261
|
-
4. **Hashtag scraping** (topical) — `collectHashtagPosts` on ICP-relevant hashtags for active authors
|
|
262
|
-
5. **Feed scan** (passive) — `linkedInScrapers.getFeed({ sortOrder: "REV_CHRON" })` for serendipitous ICP matches
|
|
263
|
-
6. **Saved posts** (curated) — `listSavedPosts` for authors of bookmarked content
|
|
264
|
-
7. **People search** (warm) — fill remaining credit budget with profile-based matches
|
|
265
|
-
8. **Sales Navigator** (precision) — if available, use for hard-to-reach ICP segments standard search can't filter
|
|
266
|
-
|
|
267
|
-
Over time, the learning loop adjusts this order based on actual quality feedback.
|
|
268
|
-
|
|
269
|
-
## Growth engine
|
|
270
|
-
|
|
271
|
-
Lead gen is not a one-shot task. It's an autonomous process that produces new qualified leads every day.
|
|
272
|
-
|
|
273
|
-
### Daily autonomous run
|
|
274
|
-
|
|
275
|
-
Each daily run:
|
|
276
|
-
|
|
277
|
-
1. **Re-run existing angles** — same search queries yield new results over time (people change jobs, join LinkedIn, post new content). `contacts.addContacts` dedup via `@@unique([userId, linkedinUrl])` on Contact + `@@unique([campaignId, contactId])` on CampaignContact ensures no contact is processed twice.
|
|
278
|
-
2. **Monitor engagement watchlist** — scrape new posts from watchlisted profiles (competitors, industry leaders). New posts = new engagers = new leads. Most passive growth channel.
|
|
279
|
-
3. **Passive discovery** — `profile.getProfileViews()` for people viewing you, `linkedInScrapers.getFeed()` for relevant content in the feed, `listSavedPosts()` for bookmarked post authors. Low effort, high serendipity.
|
|
280
|
-
4. **Expand search space** — try variations of what worked:
|
|
281
|
-
- Title variations: "Head of Sales" → "Sales Director", "VP Sales", "VP Revenue", "CRO"
|
|
282
|
-
- Geographic expansion: France → Belgium, Switzerland, Canada
|
|
283
|
-
- Company cascade: qualified lead works at company X → `visitCompany(X)` → `similarCompanies` → more targets (free)
|
|
284
|
-
- Content cascade: `searchPosts` found a relevant post → scrape its commenters/likers
|
|
285
|
-
- Hashtag cascade: `collectHashtagPosts` on a trending hashtag → scrape commenters on top posts
|
|
286
|
-
- Lookalike: `connectionOf` on best qualified leads → similar profiles
|
|
287
|
-
- Job posting expansion: job posting for "Sales Manager" → find that company's current VP Sales
|
|
288
|
-
- Topic broadening: "cold email" → "outbound", "sales automation", "pipeline"
|
|
289
|
-
- Sales Nav precision: `salesNav.searchPeople` with seniority/function/tenure filters for hard-to-reach segments
|
|
290
|
-
5. **Report daily recap** — what was found, how many new leads, which angles performed, suggestions for new directions. The client sees a summary and can steer.
|
|
291
|
-
|
|
292
|
-
### Agent behavior
|
|
244
|
+
## Agent behavior
|
|
293
245
|
|
|
294
246
|
- **Act first, check after** — run 3-4 searches, report findings, suggest new angles. Don't wait for permission.
|
|
295
247
|
- **Light validation** — one-line suggestions, not a questionnaire. The client steers if they want.
|
|
296
248
|
- **Prompt-driven** — the client gives a prompt (detailed or vague). The agent uses its context layer (profile, posts, website, campaigns, competitors) to fill gaps. If context is truly insufficient, one nudge max.
|
|
297
249
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
The agent gets smarter through multiple feedback sources, all powered by `contacts.getStats()`:
|
|
301
|
-
|
|
302
|
-
- **Immediate** — `contacts.getStats()` grouped by `sourceAngle` shows real conversion rates: contact → lead → qualified. Angle "engagement-scraping-competitor-X" converts 40%? Double down. "people-search-generic" converts 12%? Deprioritize.
|
|
303
|
-
- **Periodic** — client reviews contacts via `contacts.updateContact({ lifecycleStage: "approved" })` or `"rejected"`. Agent queries approved vs rejected per angle to refine.
|
|
304
|
-
- **Future** — outreach conversion data (who replied, who booked) feeds back. Agent doubles down on high-converting sources.
|
|
305
|
-
- **Implicit** — angle performance over time. Funnel velocity (`stageChangedAt`) shows which angles qualify fastest.
|
|
250
|
+
## State
|
|
306
251
|
|
|
307
|
-
|
|
252
|
+
The agent needs memory across runs. State is stored **server-side** via `agentState.get("lead-gen")` / `agentState.set("lead-gen", data)`.
|
|
308
253
|
|
|
309
|
-
|
|
254
|
+
**Session startup:** call `agentState.list({ keysOnly: true })` (0 credits) to discover all stored keys. Load `lead-gen` state if present.
|
|
310
255
|
|
|
311
|
-
|
|
256
|
+
What to store in agentState (lightweight, operational):
|
|
257
|
+
- Which discovery sources have been processed (post URLs scraped, search angles run)
|
|
258
|
+
- Resolved parameters cache (`{ "France": "geo-id-123" }`)
|
|
259
|
+
- Campaign map (`{ angleId: campaignId }`)
|
|
260
|
+
- Agent notes (what worked/didn't, angles to try next)
|
|
312
261
|
|
|
313
|
-
|
|
314
|
-
-
|
|
315
|
-
-
|
|
316
|
-
-
|
|
317
|
-
- Campaign map: `{ angleId: campaignId }` — maps angles to lead-gen campaigns
|
|
318
|
-
- Client feedback: `{ profileUrl, verdict, impliedAdjustment }`
|
|
319
|
-
|
|
320
|
-
**Agent notes** (strategic continuity):
|
|
321
|
-
- What worked/didn't in last run
|
|
322
|
-
- Angles to try next
|
|
323
|
-
- Observations about lead patterns
|
|
324
|
-
- Credit budget status
|
|
325
|
-
|
|
326
|
-
Server-side state survives device changes and is accessible from any agent instance. No local files needed.
|
|
262
|
+
What NOT to store in agentState:
|
|
263
|
+
- Profile data (auto-saved to contacts DB by visitProfile)
|
|
264
|
+
- Conversation data (auto-saved by findConversation)
|
|
265
|
+
- Contact lists (live in the contacts DB)
|
|
327
266
|
|
|
328
267
|
## Cron
|
|
329
268
|
|
|
330
269
|
```json
|
|
331
270
|
{
|
|
332
|
-
"name": "
|
|
333
|
-
"
|
|
334
|
-
"schedule": { "kind": "cron", "expr": "0 8 * * *", "tz": "Europe/Paris" },
|
|
271
|
+
"name": "BeReach Pipeline",
|
|
272
|
+
"schedule": { "kind": "cron", "expr": "*/15 * * * *", "tz": "Europe/Paris" },
|
|
335
273
|
"sessionTarget": "isolated",
|
|
336
274
|
"wakeMode": "now",
|
|
337
275
|
"payload": {
|
|
338
276
|
"kind": "agentTurn",
|
|
339
|
-
"message": "
|
|
340
|
-
"timeoutSeconds":
|
|
277
|
+
"message": "Pipeline check.\n1. bereach_contacts_search({ hasProfileData: 'false' }) -- pending enrichment?\n2. If 0: exit. Nothing to do.\n3. If > 0: enrich 5-10 profiles. visitProfile (auto-saves) + findConversation (auto-saves). Skip errors, try next.\n4. After batch: report count enriched + remaining.",
|
|
278
|
+
"timeoutSeconds": 600
|
|
341
279
|
},
|
|
342
280
|
"delivery": { "mode": "announce", "channel": "webchat", "bestEffort": true }
|
|
343
281
|
}
|
|
344
282
|
```
|
|
345
283
|
|
|
346
|
-
|
|
284
|
+
Runs every 15 minutes, 24/7. If nothing pending, exits after 1 tool call (cheapest possible turn). If work pending, processes 5-10 profiles then exits. The contacts DB IS the queue.
|
|
285
|
+
|
|
286
|
+
**NEVER set `lightContext: true`** — this skips SOUL.md injection and breaks the agent.
|
|
347
287
|
|
|
348
288
|
## Cost estimation
|
|
349
289
|
|
|
@@ -372,7 +312,7 @@ Check `getCredits` at the start of each run. If `isUnlimited` is true, skip cred
|
|
|
372
312
|
|
|
373
313
|
**Feed (1 credit):** `linkedInScrapers.getFeed` — scan home feed for relevant content and authors
|
|
374
314
|
|
|
375
|
-
**Contacts DB (0 credits):** `contacts.
|
|
315
|
+
**Contacts DB (0 credits):** `contacts.addContacts`, `contacts.getByUrl`, `contacts.getFullContact`, `contacts.bulkUpdate`, `contacts.createCampaign`, `contacts.listCampaigns`, `contacts.updateCampaign`, `contacts.listContacts`, `contacts.updateContact`, `contacts.searchContacts`, `contacts.getStats`, `contacts.logActivity`, `contacts.getActivities`
|
|
376
316
|
|
|
377
317
|
**Agent state (0 credits):** `agentState.list`, `agentState.get`, `agentState.set`, `agentState.patch`, `agentState.delete`
|
|
378
318
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bereach-lead-magnet
|
|
3
3
|
description: "Lead magnet workflow — deliver a resource to everyone who engages with a LinkedIn post."
|
|
4
|
-
lastUpdatedAt:
|
|
4
|
+
lastUpdatedAt: 1773675510
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
<!--
|
|
@@ -84,7 +84,7 @@ State persistence uses the Agent State API at `/api/agent-state/{key}`. The agen
|
|
|
84
84
|
- **PATCH** `/api/agent-state/{key}` — shallow merge into existing state: `{ data: { ... } }`. Use for progressive saves (e.g. saving `previousTotal` every 10 profiles without reading full state). 0 credits.
|
|
85
85
|
- **DELETE** `/api/agent-state/{key}` — remove a key entirely. Use to clean up stale or obsolete state. 0 credits.
|
|
86
86
|
|
|
87
|
-
Auth: `Authorization: Bearer $BEREACH_API_KEY`. Base URL:
|
|
87
|
+
Auth: `Authorization: Bearer $BEREACH_API_KEY`. Base URL: `https://api.berea.ch`.
|
|
88
88
|
|
|
89
89
|
## Tone
|
|
90
90
|
|
|
@@ -186,13 +186,13 @@ This guarantees: no comments lost on timeout, no duplicate processing, no wasted
|
|
|
186
186
|
|
|
187
187
|
### Contacts DB integration
|
|
188
188
|
|
|
189
|
-
|
|
189
|
+
Commenters are auto-created as contacts by `collectComments` and `collectLikes` (auto-upsert). Scripts do NOT need to manually upsert each commenter.
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
1. `upsertContact(linkedinUrl, name, campaignSlug)` — creates/finds the contact and links to a `lead_magnet` campaign (see Helpers). Batched: accumulate profiles and upsert in bulk every 10 profiles.
|
|
193
|
-
2. After performing actions (DM, reply, like), log via the BeReach API as usual (`campaignSlug` dedup). The contacts activity log is optional — upsert is the minimum for cross-skill visibility.
|
|
191
|
+
For campaign membership and cross-skill visibility, use `contacts.addContacts` to link commenters to the lead magnet campaign. `addContacts` is a full upsert — creates the contact if missing, updates if exists, links to campaign. Use it for campaign membership only, not for raw contact creation.
|
|
194
192
|
|
|
195
|
-
|
|
193
|
+
After performing actions (DM, reply, like), log via `contacts.logActivity` for the activity trail. The `campaignSlug` dedup on BeReach API calls handles action tracking.
|
|
194
|
+
|
|
195
|
+
On first run per campaign, create the campaign entity if it doesn't exist: `contacts.createCampaign({ name: "LM: " + campaignSlug, type: "lead_magnet" })`. The API returns the generated `id` (campaignId) — use that for `addContacts` calls. Cache created campaignId in agentState to avoid re-creating. Note: `campaignSlug` (user-defined) is used for action dedup on API calls, while `campaignId` (system-generated) is used for contacts/campaign membership tools.
|
|
196
196
|
|
|
197
197
|
## Pre-flight
|
|
198
198
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
lastUpdatedAt: 1773663610
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
<!--
|
|
6
|
+
AUTO-GENERATED FILE — DO NOT EDIT
|
|
7
|
+
Source of truth: skills/ directory
|
|
8
|
+
Edit the source file, then run: pnpm build:plugins
|
|
9
|
+
Any direct edit to this file WILL be overwritten.
|
|
10
|
+
-->
|
|
11
|
+
|
|
12
|
+
# BeReach Agent Rules
|
|
13
|
+
|
|
14
|
+
If you are a sub-agent or spawned session:
|
|
15
|
+
|
|
16
|
+
1. You have access to bereach_* tools for LinkedIn automation.
|
|
17
|
+
2. Follow the rules in SOUL.md — especially auto-save behavior and dedup via campaignSlug.
|
|
18
|
+
3. Never use raw HTTP calls. Only use registered tools.
|
|
19
|
+
4. Report results concisely when done.
|
|
20
|
+
5. If you encounter a rate limit (429), wait and retry up to 3 times before skipping.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
lastUpdatedAt: 1773665416
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
<!--
|
|
6
|
+
AUTO-GENERATED FILE — DO NOT EDIT
|
|
7
|
+
Source of truth: skills/ directory
|
|
8
|
+
Edit the source file, then run: pnpm build:plugins
|
|
9
|
+
Any direct edit to this file WILL be overwritten.
|
|
10
|
+
-->
|
|
11
|
+
|
|
12
|
+
<!-- bereach-workspace-v2 -->
|
|
13
|
+
|
|
14
|
+
## Identity
|
|
15
|
+
|
|
16
|
+
You are a BeReach-powered LinkedIn automation agent.
|
|
17
|
+
For ANY LinkedIn task, use bereach_* tools. Never use raw HTTP.
|
|
18
|
+
|
|
19
|
+
## Session Startup [MANDATORY before greeting]
|
|
20
|
+
|
|
21
|
+
1. bereach_state_list(keysOnly: true) — discover existing context
|
|
22
|
+
2. If "user-profile" key exists: bereach_state_get("user-profile")
|
|
23
|
+
3. If any active campaign state found: check progress, resume if needed
|
|
24
|
+
4. bereach_get_credits — check budget
|
|
25
|
+
5. bereach_get_limits — check daily limits
|
|
26
|
+
|
|
27
|
+
## Tools by Category
|
|
28
|
+
|
|
29
|
+
### Search
|
|
30
|
+
|
|
31
|
+
- bereach_search_people: LinkedIn people search (keywords, title, location, industry, company)
|
|
32
|
+
- bereach_search_posts: post search (keywords, date, content type)
|
|
33
|
+
- bereach_search_companies: company search (keywords, size, industry)
|
|
34
|
+
- bereach_search_jobs: job search
|
|
35
|
+
- bereach_search_by_url: parse and run search from a LinkedIn URL
|
|
36
|
+
- bereach_resolve_parameters: resolve text to LinkedIn IDs (GEO, COMPANY, INDUSTRY, SCHOOL)
|
|
37
|
+
|
|
38
|
+
### Scrape Engagement
|
|
39
|
+
|
|
40
|
+
- bereach_collect_likes: profiles who liked a post. Auto-creates contacts.
|
|
41
|
+
- bereach_collect_comments: post commenters with text. Auto-creates contacts.
|
|
42
|
+
- bereach_collect_comment_replies: replies to a comment thread. Auto-creates contacts.
|
|
43
|
+
- bereach_collect_posts: posts from a profile. Auto-creates contact.
|
|
44
|
+
- bereach_collect_hashtag_posts: posts by hashtag. Auto-creates contacts.
|
|
45
|
+
- bereach_collect_saved_posts: user's saved posts
|
|
46
|
+
- bereach_visit_profile: visit profile, extract full data (1 credit, cached 24h for 1st degree). Auto-saves profileData to contact.
|
|
47
|
+
- bereach_visit_company: visit company page
|
|
48
|
+
|
|
49
|
+
### Outreach Actions
|
|
50
|
+
|
|
51
|
+
- bereach_connect_profile: send connection request (30/day max, visit first)
|
|
52
|
+
- bereach_send_message: send DM (supports conversationUrn fallback)
|
|
53
|
+
- bereach_accept_invitation: accept received invitation
|
|
54
|
+
- bereach_list_invitations: list received invitations
|
|
55
|
+
- bereach_list_sent_invitations: list sent invitations
|
|
56
|
+
- bereach_withdraw_invitation: withdraw sent invitation
|
|
57
|
+
|
|
58
|
+
### Content Engagement
|
|
59
|
+
|
|
60
|
+
- bereach_like_post: like/react to a post
|
|
61
|
+
- bereach_comment_on_post: comment on a post
|
|
62
|
+
- bereach_reply_to_comment: reply to a comment
|
|
63
|
+
- bereach_like_comment: like a comment
|
|
64
|
+
- bereach_follow_profile: follow a profile
|
|
65
|
+
- bereach_publish_post: publish or schedule a post
|
|
66
|
+
|
|
67
|
+
### Inbox
|
|
68
|
+
|
|
69
|
+
- bereach_list_conversations: list inbox
|
|
70
|
+
- bereach_search_conversations: search by keyword
|
|
71
|
+
- bereach_find_conversation: find by participant (O(1) lookup). Auto-saves conversationData to contact.
|
|
72
|
+
- bereach_get_messages: message history for a conversation
|
|
73
|
+
- bereach_archive_conversation: archive
|
|
74
|
+
|
|
75
|
+
### Contacts CRM
|
|
76
|
+
|
|
77
|
+
- bereach_contacts_search: search contacts (stage, tag, score, follow-up date, draft DM)
|
|
78
|
+
- bereach_contacts_stats: funnel metrics
|
|
79
|
+
- bereach_contacts_get_by_url: look up by LinkedIn URL (full contact + activities)
|
|
80
|
+
- bereach_contacts_get_full: get contact by internal ID (full context + activities)
|
|
81
|
+
- bereach_contacts_get_activities: chronological activity log for a contact
|
|
82
|
+
- bereach_contacts_upsert: create/update contacts — for manual imports only (scraping auto-creates contacts)
|
|
83
|
+
- bereach_contacts_update: update stage, score, notes, tags, draft DM
|
|
84
|
+
- bereach_contacts_log_activity: log activities (auto-syncs outreachStatus)
|
|
85
|
+
- bereach_contacts_bulk_update: batch update up to 500
|
|
86
|
+
- bereach_contacts_add: add contacts to a campaign (full upsert — creates if missing, links to campaign)
|
|
87
|
+
- bereach_contacts_list: list contacts in a specific campaign
|
|
88
|
+
|
|
89
|
+
### Campaigns
|
|
90
|
+
|
|
91
|
+
- bereach_contacts_create_campaign: create campaign with context (markdown playbook)
|
|
92
|
+
- bereach_contacts_list_campaigns: list campaigns with context
|
|
93
|
+
- bereach_contacts_update_campaign: update campaign settings
|
|
94
|
+
- bereach_campaign_status: per-profile action status (dedup check)
|
|
95
|
+
- bereach_campaign_sync: mark actions completed without performing them
|
|
96
|
+
- bereach_campaign_stats: aggregate campaign stats
|
|
97
|
+
|
|
98
|
+
### Account
|
|
99
|
+
|
|
100
|
+
- bereach_get_credits: credit balance (isUnlimited? skip budgeting)
|
|
101
|
+
- bereach_get_limits: rate limit status
|
|
102
|
+
- bereach_get_profile: your LinkedIn profile (0 credits)
|
|
103
|
+
- bereach_get_followers: your followers
|
|
104
|
+
- bereach_get_own_posts: your recent posts
|
|
105
|
+
- bereach_get_profile_views: who viewed your profile
|
|
106
|
+
- bereach_get_feed: LinkedIn feed
|
|
107
|
+
- bereach_refresh_profile: force refresh from LinkedIn
|
|
108
|
+
|
|
109
|
+
### Agent Memory
|
|
110
|
+
|
|
111
|
+
- bereach_state_list: list all state keys (keysOnly=true for overview)
|
|
112
|
+
- bereach_state_get: get state for a key
|
|
113
|
+
- bereach_state_set: save/replace state
|
|
114
|
+
- bereach_state_patch: merge fields into state
|
|
115
|
+
- bereach_state_delete: delete state
|
|
116
|
+
|
|
117
|
+
## Rules
|
|
118
|
+
|
|
119
|
+
- Dedup: pass campaignSlug on every action. Duplicates return duplicate:true, cost nothing.
|
|
120
|
+
- Visit before connecting: looks natural to LinkedIn.
|
|
121
|
+
- Connection requests: 30/day. Check pendingConnection from visit response first.
|
|
122
|
+
- Credits: when isUnlimited=true, skip credit budgeting.
|
|
123
|
+
- Language: respond in user's language. DMs: match conversation language.
|
|
124
|
+
- Formatting: tables for contacts (Name, Title, Company, Score). No raw IDs/URNs.
|
|
125
|
+
- Auto-save: visitProfile, findConversation, collectComments, collectLikes, collectPosts, searchPeople and other scrape/search tools all auto-create/update contacts. Do NOT manually save profile or conversation data. Do NOT use contacts.upsertContacts for data that was just scraped/visited.
|
|
126
|
+
- State saves: only save pipeline progress (phase, scraped sources) to agentState. Never store profile data in state.
|
|
127
|
+
|
|
128
|
+
## Sub-Skills — MANDATORY Loading Table
|
|
129
|
+
|
|
130
|
+
You MUST load the corresponding sub-skill BEFORE attempting any task that matches
|
|
131
|
+
the triggers below. The tool listing above is for quick lookups only — workflows
|
|
132
|
+
require the full sub-skill instructions.
|
|
133
|
+
|
|
134
|
+
| Sub-skill | LOAD WHEN user request matches ANY of these | Path |
|
|
135
|
+
| ------------- | -------------------------------------------------- | -------------------- |
|
|
136
|
+
| Lead Gen | find leads, search prospects, scrape engagement, | sub/lead-gen.md |
|
|
137
|
+
| | build pipeline, qualify contacts, enrich profiles, | |
|
|
138
|
+
| | ICP, funnel, grow database, hashtag, Sales Nav | |
|
|
139
|
+
| Lead Magnet | comment-to-DM, resource delivery, post giveaway, | sub/lead-magnet.md |
|
|
140
|
+
| | auto-accept invitations, lead magnet campaign | |
|
|
141
|
+
| SDK Reference | write script, generate code, TypeScript, SDK, | sdk-reference.md |
|
|
142
|
+
| | automate, method signature, batch job | |
|
|
143
|
+
|
|
144
|
+
Detection: scan the user's message for these concepts regardless of language.
|
|
145
|
+
"Trouve-moi des leads" = Lead Gen. "Set up a comment giveaway" = Lead Magnet.
|
|
146
|
+
When in doubt, load the skill — false positives cost nothing, false negatives
|
|
147
|
+
mean the agent operates without critical workflow knowledge.
|
|
148
|
+
|
|
149
|
+
NEVER attempt a multi-step lead gen, lead magnet, or script generation workflow
|
|
150
|
+
using only the tool listing above. Always load the sub-skill first.
|
|
151
|
+
|
|
152
|
+
<!-- /bereach-workspace -->
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getClient } from "../client";
|
|
2
|
+
|
|
3
|
+
const API_BASE = "https://api.berea.ch";
|
|
4
|
+
|
|
5
|
+
export function registerSetupCommand(api: any) {
|
|
6
|
+
if (!api?.registerCommand) return;
|
|
7
|
+
api.registerCommand({
|
|
8
|
+
name: "bereach-setup",
|
|
9
|
+
description: "Set up or re-initialize BeReach workspace (SOUL.md, crons). Run after first install or to force-refresh.",
|
|
10
|
+
handler: async () => {
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetch(`${API_BASE}/api/onboarding/soul`);
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
return { text: `Failed to fetch SOUL template: ${res.status} ${res.statusText}` };
|
|
15
|
+
}
|
|
16
|
+
const { soul } = await res.json();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
text: [
|
|
20
|
+
"BeReach setup ready. To complete setup:",
|
|
21
|
+
"",
|
|
22
|
+
"1. Copy the SOUL.md template below into your workspace SOUL.md",
|
|
23
|
+
" (between the <!-- bereach-workspace-v2 --> markers)",
|
|
24
|
+
"",
|
|
25
|
+
"2. Set up the pipeline cron (every 15min):",
|
|
26
|
+
' openclaw cron add --name "BeReach Pipeline" --expr "*/15 * * * *"',
|
|
27
|
+
"",
|
|
28
|
+
"Template content has been loaded. Say 'apply bereach setup' to have me write it.",
|
|
29
|
+
].join("\n"),
|
|
30
|
+
data: { soulTemplate: soul },
|
|
31
|
+
};
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return { text: `BeReach setup failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { setApiKey } from "./client";
|
|
2
2
|
import { registerAllTools } from "./tools";
|
|
3
3
|
import { registerCommands } from "./commands";
|
|
4
|
+
import { registerSetupCommand } from "./commands/setup";
|
|
5
|
+
|
|
6
|
+
const BEREACH_MARKER = "bereach-workspace-v";
|
|
4
7
|
|
|
5
8
|
function getApiKey(api: any): string | undefined {
|
|
6
9
|
const key =
|
|
@@ -15,4 +18,22 @@ export default function register(api: any) {
|
|
|
15
18
|
setApiKey(apiKey);
|
|
16
19
|
registerAllTools(api);
|
|
17
20
|
registerCommands(api);
|
|
21
|
+
registerSetupCommand(api);
|
|
22
|
+
|
|
23
|
+
if (api.on) {
|
|
24
|
+
api.on("before_agent_start", async (ctx: any) => {
|
|
25
|
+
try {
|
|
26
|
+
const soulContent = ctx?.bootstrapFiles?.["SOUL.md"] ?? ctx?.soul ?? "";
|
|
27
|
+
if (typeof soulContent === "string" && !soulContent.includes(BEREACH_MARKER)) {
|
|
28
|
+
if (ctx.addSystemNote) {
|
|
29
|
+
ctx.addSystemNote(
|
|
30
|
+
"BeReach workspace not configured. Load the BeReach skill for initial setup, or run /bereach-setup."
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Hook errors should never break agent startup
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
18
39
|
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exhaustive SDK method verification test.
|
|
3
|
+
*
|
|
4
|
+
* Validates that ALL methods documented in sdk-reference.md actually exist
|
|
5
|
+
* on the bereach@1.3.2 SDK class with the correct getter paths.
|
|
6
|
+
*
|
|
7
|
+
* Run: npx tsx src/test-sdk-methods.mts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Bereach } from "bereach";
|
|
11
|
+
|
|
12
|
+
const sdk = new Bereach({ token: "test-verification-only" });
|
|
13
|
+
|
|
14
|
+
interface MethodCheck {
|
|
15
|
+
getter: string;
|
|
16
|
+
method: string;
|
|
17
|
+
source: "sdk" | "docs" | "both";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const EXPECTED_SDK_METHODS: MethodCheck[] = [
|
|
21
|
+
// ── scrapers (Scrapers) ────────────────────────────────────────
|
|
22
|
+
{ getter: "scrapers", method: "collectPosts", source: "both" },
|
|
23
|
+
{ getter: "scrapers", method: "visitProfile", source: "both" },
|
|
24
|
+
|
|
25
|
+
// ── linkedinScrapers (LinkedinScrapers1) ────────────────────────
|
|
26
|
+
{ getter: "linkedinScrapers", method: "collectLikes", source: "both" },
|
|
27
|
+
{ getter: "linkedinScrapers", method: "collectComments", source: "both" },
|
|
28
|
+
{ getter: "linkedinScrapers", method: "visitCompany", source: "both" },
|
|
29
|
+
{ getter: "linkedinScrapers", method: "listSavedPosts", source: "both" },
|
|
30
|
+
{ getter: "linkedinScrapers", method: "collectHashtagPosts", source: "both" },
|
|
31
|
+
|
|
32
|
+
// ── linkedInScrapers (LinkedInScrapers2) ────────────────────────
|
|
33
|
+
{ getter: "linkedInScrapers", method: "collectCommentReplies", source: "both" },
|
|
34
|
+
{ getter: "linkedInScrapers", method: "getFeed", source: "both" },
|
|
35
|
+
|
|
36
|
+
// ── linkedInSearch (LinkedInSearch1) ────────────────────────────
|
|
37
|
+
{ getter: "linkedInSearch", method: "search", source: "both" },
|
|
38
|
+
{ getter: "linkedInSearch", method: "searchCompanies", source: "both" },
|
|
39
|
+
{ getter: "linkedInSearch", method: "resolveParameters", source: "both" },
|
|
40
|
+
|
|
41
|
+
// ── linkedinSearch (LinkedinSearch2) ────────────────────────────
|
|
42
|
+
{ getter: "linkedinSearch", method: "searchPosts", source: "both" },
|
|
43
|
+
{ getter: "linkedinSearch", method: "searchPeople", source: "both" },
|
|
44
|
+
{ getter: "linkedinSearch", method: "searchJobs", source: "both" },
|
|
45
|
+
{ getter: "linkedinSearch", method: "searchByUrl", source: "both" },
|
|
46
|
+
|
|
47
|
+
// ── actions (Actions) ──────────────────────────────────────────
|
|
48
|
+
{ getter: "actions", method: "connectProfile", source: "both" },
|
|
49
|
+
{ getter: "actions", method: "sendMessage", source: "both" },
|
|
50
|
+
{ getter: "actions", method: "listSentInvitations", source: "both" },
|
|
51
|
+
{ getter: "actions", method: "followCompany", source: "both" },
|
|
52
|
+
|
|
53
|
+
// ── linkedinActions (LinkedinActions1) ──────────────────────────
|
|
54
|
+
{ getter: "linkedinActions", method: "listInvitations", source: "both" },
|
|
55
|
+
{ getter: "linkedinActions", method: "acceptInvitation", source: "both" },
|
|
56
|
+
{ getter: "linkedinActions", method: "replyToComment", source: "both" },
|
|
57
|
+
{ getter: "linkedinActions", method: "likePost", source: "both" },
|
|
58
|
+
{ getter: "linkedinActions", method: "withdrawInvitation", source: "both" },
|
|
59
|
+
{ getter: "linkedinActions", method: "followProfile", source: "both" },
|
|
60
|
+
{ getter: "linkedinActions", method: "editPost", source: "both" },
|
|
61
|
+
{ getter: "linkedinActions", method: "editComment", source: "both" },
|
|
62
|
+
{ getter: "linkedinActions", method: "repostPost", source: "both" },
|
|
63
|
+
{ getter: "linkedinActions", method: "unlikePost", source: "both" },
|
|
64
|
+
{ getter: "linkedinActions", method: "unlikeComment", source: "both" },
|
|
65
|
+
{ getter: "linkedinActions", method: "unsavePost", source: "both" },
|
|
66
|
+
{ getter: "linkedinActions", method: "unfollowCompany", source: "both" },
|
|
67
|
+
|
|
68
|
+
// ── linkedInActions (LinkedInActions2) ──────────────────────────
|
|
69
|
+
{ getter: "linkedInActions", method: "likeComment", source: "both" },
|
|
70
|
+
{ getter: "linkedInActions", method: "publishPost", source: "both" },
|
|
71
|
+
{ getter: "linkedInActions", method: "createComment", source: "both" },
|
|
72
|
+
{ getter: "linkedInActions", method: "declineInvitation", source: "both" },
|
|
73
|
+
{ getter: "linkedInActions", method: "unfollowProfile", source: "both" },
|
|
74
|
+
{ getter: "linkedInActions", method: "savePost", source: "both" },
|
|
75
|
+
|
|
76
|
+
// ── profile (Profile) ─────────────────────────────────────────
|
|
77
|
+
{ getter: "profile", method: "get", source: "both" },
|
|
78
|
+
{ getter: "profile", method: "listAccounts", source: "both" },
|
|
79
|
+
{ getter: "profile", method: "updateAccount", source: "both" },
|
|
80
|
+
{ getter: "profile", method: "refresh", source: "both" },
|
|
81
|
+
{ getter: "profile", method: "getPosts", source: "both" },
|
|
82
|
+
{ getter: "profile", method: "getFollowers", source: "both" },
|
|
83
|
+
{ getter: "profile", method: "getLimits", source: "both" },
|
|
84
|
+
{ getter: "profile", method: "getCredits", source: "both" },
|
|
85
|
+
{ getter: "profile", method: "getProfileViews", source: "both" },
|
|
86
|
+
{ getter: "profile", method: "getSearchAppearances", source: "both" },
|
|
87
|
+
{ getter: "profile", method: "getPostAnalytics", source: "both" },
|
|
88
|
+
{ getter: "profile", method: "getFollowerAnalytics", source: "both" },
|
|
89
|
+
|
|
90
|
+
// ── companyPages (CompanyPages) ────────────────────────────────
|
|
91
|
+
{ getter: "companyPages", method: "list", source: "both" },
|
|
92
|
+
{ getter: "companyPages", method: "getPosts", source: "both" },
|
|
93
|
+
{ getter: "companyPages", method: "getPermissions", source: "both" },
|
|
94
|
+
{ getter: "companyPages", method: "getAnalytics", source: "both" },
|
|
95
|
+
|
|
96
|
+
// ── linkedinChat (LinkedinChat1) ──────────────────────────────
|
|
97
|
+
{ getter: "linkedinChat", method: "listInboxConversations", source: "both" },
|
|
98
|
+
{ getter: "linkedinChat", method: "getMessages", source: "both" },
|
|
99
|
+
{ getter: "linkedinChat", method: "markAllRead", source: "both" },
|
|
100
|
+
{ getter: "linkedinChat", method: "listArchived", source: "both" },
|
|
101
|
+
{ getter: "linkedinChat", method: "react", source: "both" },
|
|
102
|
+
{ getter: "linkedinChat", method: "sendTypingIndicator", source: "both" },
|
|
103
|
+
{ getter: "linkedinChat", method: "getUnreadCount", source: "both" },
|
|
104
|
+
|
|
105
|
+
// ── chat (Chat) ───────────────────────────────────────────────
|
|
106
|
+
{ getter: "chat", method: "searchConversations", source: "both" },
|
|
107
|
+
{ getter: "chat", method: "findConversation", source: "both" },
|
|
108
|
+
{ getter: "chat", method: "archive", source: "both" },
|
|
109
|
+
{ getter: "chat", method: "unreact", source: "both" },
|
|
110
|
+
|
|
111
|
+
// ── linkedInChat (LinkedInChat2) ──────────────────────────────
|
|
112
|
+
{ getter: "linkedInChat", method: "markSeen", source: "both" },
|
|
113
|
+
{ getter: "linkedInChat", method: "star", source: "both" },
|
|
114
|
+
{ getter: "linkedInChat", method: "unstar", source: "both" },
|
|
115
|
+
{ getter: "linkedInChat", method: "listStarred", source: "both" },
|
|
116
|
+
{ getter: "linkedInChat", method: "unarchive", source: "both" },
|
|
117
|
+
|
|
118
|
+
// ── campaigns (Campaigns) ─────────────────────────────────────
|
|
119
|
+
{ getter: "campaigns", method: "filter", source: "sdk" },
|
|
120
|
+
{ getter: "campaigns", method: "getStatus", source: "both" },
|
|
121
|
+
{ getter: "campaigns", method: "sync", source: "both" },
|
|
122
|
+
{ getter: "campaigns", method: "getStats", source: "both" },
|
|
123
|
+
|
|
124
|
+
// ── contacts (Contacts) ───────────────────────────────────────
|
|
125
|
+
{ getter: "contacts", method: "upsert", source: "both" },
|
|
126
|
+
|
|
127
|
+
// ── salesNavigatorSearch (SalesNavigatorSearch) ────────────────
|
|
128
|
+
{ getter: "salesNavigatorSearch", method: "search", source: "both" },
|
|
129
|
+
{ getter: "salesNavigatorSearch", method: "searchCompanies", source: "both" },
|
|
130
|
+
|
|
131
|
+
// ── salesNav (SalesNav) ───────────────────────────────────────
|
|
132
|
+
{ getter: "salesNav", method: "searchPeople", source: "both" },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
// Also verify the plugin definitions.ts handler strings
|
|
136
|
+
const PLUGIN_HANDLERS = [
|
|
137
|
+
"linkedinScrapers.collectLikes",
|
|
138
|
+
"linkedinScrapers.collectComments",
|
|
139
|
+
"linkedInScrapers.collectCommentReplies",
|
|
140
|
+
"scrapers.collectPosts",
|
|
141
|
+
"scrapers.visitProfile",
|
|
142
|
+
"linkedinScrapers.visitCompany",
|
|
143
|
+
"linkedinScrapers.collectHashtagPosts",
|
|
144
|
+
"linkedinScrapers.listSavedPosts",
|
|
145
|
+
"linkedInSearch.search",
|
|
146
|
+
"linkedinSearch.searchPosts",
|
|
147
|
+
"linkedinSearch.searchPeople",
|
|
148
|
+
"linkedInSearch.searchCompanies",
|
|
149
|
+
"linkedinSearch.searchJobs",
|
|
150
|
+
"linkedinSearch.searchByUrl",
|
|
151
|
+
"linkedInSearch.resolveParameters",
|
|
152
|
+
"actions.connectProfile",
|
|
153
|
+
"linkedinActions.listInvitations",
|
|
154
|
+
"linkedinActions.acceptInvitation",
|
|
155
|
+
"actions.sendMessage",
|
|
156
|
+
"linkedinActions.replyToComment",
|
|
157
|
+
"linkedInActions.likeComment",
|
|
158
|
+
"linkedInActions.publishPost",
|
|
159
|
+
"linkedinActions.likePost",
|
|
160
|
+
"linkedInActions.createComment",
|
|
161
|
+
"linkedinActions.followProfile",
|
|
162
|
+
"actions.listSentInvitations",
|
|
163
|
+
"linkedinActions.withdrawInvitation",
|
|
164
|
+
"chat.archive",
|
|
165
|
+
"linkedinChat.listInboxConversations",
|
|
166
|
+
"chat.searchConversations",
|
|
167
|
+
"chat.findConversation",
|
|
168
|
+
"linkedinChat.getMessages",
|
|
169
|
+
"profile.get",
|
|
170
|
+
"profile.refresh",
|
|
171
|
+
"profile.getPosts",
|
|
172
|
+
"profile.getFollowers",
|
|
173
|
+
"profile.getLimits",
|
|
174
|
+
"profile.getCredits",
|
|
175
|
+
"profile.getProfileViews",
|
|
176
|
+
"linkedInScrapers.getFeed",
|
|
177
|
+
"campaigns.getStatus",
|
|
178
|
+
"campaigns.sync",
|
|
179
|
+
"campaigns.getStats",
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
let pass = 0;
|
|
183
|
+
let fail = 0;
|
|
184
|
+
const failures: string[] = [];
|
|
185
|
+
|
|
186
|
+
console.log("=== BeReach SDK v1.3.2 Method Verification ===\n");
|
|
187
|
+
|
|
188
|
+
// Part 1: Verify all expected getters exist on SDK
|
|
189
|
+
console.log("--- Part 1: Getter existence ---\n");
|
|
190
|
+
const getterNames = [...new Set(EXPECTED_SDK_METHODS.map((m) => m.getter))];
|
|
191
|
+
for (const getter of getterNames) {
|
|
192
|
+
const ns = (sdk as Record<string, unknown>)[getter];
|
|
193
|
+
if (ns && typeof ns === "object") {
|
|
194
|
+
console.log(` PASS sdk.${getter} exists (${ns.constructor.name})`);
|
|
195
|
+
pass++;
|
|
196
|
+
} else {
|
|
197
|
+
console.log(` FAIL sdk.${getter} is missing or not an object`);
|
|
198
|
+
failures.push(`Getter sdk.${getter} missing`);
|
|
199
|
+
fail++;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Part 2: Verify all expected methods exist on the correct getter
|
|
204
|
+
console.log("\n--- Part 2: Method existence (78 expected) ---\n");
|
|
205
|
+
for (const { getter, method } of EXPECTED_SDK_METHODS) {
|
|
206
|
+
const ns = (sdk as Record<string, unknown>)[getter] as
|
|
207
|
+
| Record<string, unknown>
|
|
208
|
+
| undefined;
|
|
209
|
+
if (!ns) {
|
|
210
|
+
console.log(` FAIL sdk.${getter}.${method} — getter missing`);
|
|
211
|
+
failures.push(`sdk.${getter}.${method} — getter missing`);
|
|
212
|
+
fail++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (typeof ns[method] === "function") {
|
|
216
|
+
console.log(` PASS sdk.${getter}.${method}`);
|
|
217
|
+
pass++;
|
|
218
|
+
} else {
|
|
219
|
+
console.log(` FAIL sdk.${getter}.${method} — not a function (got ${typeof ns[method]})`);
|
|
220
|
+
failures.push(`sdk.${getter}.${method} — not a function`);
|
|
221
|
+
fail++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Part 3: Verify all plugin handler strings resolve to real SDK methods
|
|
226
|
+
console.log("\n--- Part 3: Plugin handler verification ---\n");
|
|
227
|
+
for (const handler of PLUGIN_HANDLERS) {
|
|
228
|
+
const [getter, method] = handler.split(".");
|
|
229
|
+
const ns = (sdk as Record<string, unknown>)[getter] as
|
|
230
|
+
| Record<string, unknown>
|
|
231
|
+
| undefined;
|
|
232
|
+
if (!ns) {
|
|
233
|
+
console.log(` FAIL handler "${handler}" — getter sdk.${getter} missing`);
|
|
234
|
+
failures.push(`Plugin handler "${handler}" — getter missing`);
|
|
235
|
+
fail++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (typeof ns[method] === "function") {
|
|
239
|
+
console.log(` PASS handler "${handler}"`);
|
|
240
|
+
pass++;
|
|
241
|
+
} else {
|
|
242
|
+
console.log(` FAIL handler "${handler}" — method not a function`);
|
|
243
|
+
failures.push(`Plugin handler "${handler}" — method not found`);
|
|
244
|
+
fail++;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Part 4: Detect undocumented SDK methods (methods on getters not in our list)
|
|
249
|
+
console.log("\n--- Part 4: Undocumented SDK methods (on getters but not in docs) ---\n");
|
|
250
|
+
const documentedMethods = new Set(
|
|
251
|
+
EXPECTED_SDK_METHODS.map((m) => `${m.getter}.${m.method}`)
|
|
252
|
+
);
|
|
253
|
+
for (const getter of getterNames) {
|
|
254
|
+
const ns = (sdk as Record<string, unknown>)[getter] as
|
|
255
|
+
| Record<string, unknown>
|
|
256
|
+
| undefined;
|
|
257
|
+
if (!ns) continue;
|
|
258
|
+
const proto = Object.getPrototypeOf(ns);
|
|
259
|
+
const allProps = [
|
|
260
|
+
...Object.getOwnPropertyNames(proto),
|
|
261
|
+
...Object.getOwnPropertyNames(ns),
|
|
262
|
+
];
|
|
263
|
+
for (const prop of allProps) {
|
|
264
|
+
if (prop === "constructor" || prop.startsWith("_") || prop.startsWith("#")) continue;
|
|
265
|
+
const key = `${getter}.${prop}`;
|
|
266
|
+
if (!documentedMethods.has(key) && typeof (ns as Record<string, unknown>)[prop] === "function") {
|
|
267
|
+
console.log(` WARN Undocumented: sdk.${key}()`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Summary
|
|
273
|
+
console.log("\n=== SUMMARY ===");
|
|
274
|
+
console.log(` Total checks: ${pass + fail}`);
|
|
275
|
+
console.log(` Passed: ${pass}`);
|
|
276
|
+
console.log(` Failed: ${fail}`);
|
|
277
|
+
if (failures.length > 0) {
|
|
278
|
+
console.log("\n FAILURES:");
|
|
279
|
+
for (const f of failures) {
|
|
280
|
+
console.log(` - ${f}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
console.log("");
|
|
284
|
+
process.exit(fail > 0 ? 1 : 0);
|
package/src/tools/definitions.ts
CHANGED
|
@@ -22,7 +22,7 @@ export const definitions: ToolDefinition[] = [
|
|
|
22
22
|
|
|
23
23
|
{
|
|
24
24
|
name: "bereach_collect_likes",
|
|
25
|
-
description: "Scrape LinkedIn post likes. Returns paginated list of profiles who liked a post.",
|
|
25
|
+
description: "Scrape LinkedIn post likes. Returns paginated list of profiles who liked a post. Auto-creates a contact record for each liker.",
|
|
26
26
|
handler: "linkedinScrapers.collectLikes",
|
|
27
27
|
parameters: {
|
|
28
28
|
type: "object",
|
|
@@ -37,7 +37,7 @@ export const definitions: ToolDefinition[] = [
|
|
|
37
37
|
|
|
38
38
|
{
|
|
39
39
|
name: "bereach_collect_comments",
|
|
40
|
-
description: "Scrape LinkedIn post comments. Returns paginated list of commenters with comment text and URNs.",
|
|
40
|
+
description: "Scrape LinkedIn post comments. Returns paginated list of commenters with comment text and URNs. Auto-creates a contact record for each commenter.",
|
|
41
41
|
handler: "linkedinScrapers.collectComments",
|
|
42
42
|
parameters: {
|
|
43
43
|
type: "object",
|
|
@@ -53,7 +53,7 @@ export const definitions: ToolDefinition[] = [
|
|
|
53
53
|
|
|
54
54
|
{
|
|
55
55
|
name: "bereach_collect_comment_replies",
|
|
56
|
-
description: "Scrape replies to a LinkedIn comment. Returns paginated replies for a specific comment URN.",
|
|
56
|
+
description: "Scrape replies to a LinkedIn comment. Returns paginated replies for a specific comment URN. Auto-creates a contact record for each replier.",
|
|
57
57
|
handler: "linkedInScrapers.collectCommentReplies",
|
|
58
58
|
parameters: {
|
|
59
59
|
type: "object",
|
|
@@ -68,7 +68,7 @@ export const definitions: ToolDefinition[] = [
|
|
|
68
68
|
|
|
69
69
|
{
|
|
70
70
|
name: "bereach_collect_posts",
|
|
71
|
-
description: "Scrape LinkedIn profile posts. Returns paginated list of posts from a profile.",
|
|
71
|
+
description: "Scrape LinkedIn profile posts. Returns paginated list of posts from a profile. Auto-creates/updates a contact for the profile.",
|
|
72
72
|
handler: "scrapers.collectPosts",
|
|
73
73
|
parameters: {
|
|
74
74
|
type: "object",
|
|
@@ -84,7 +84,7 @@ export const definitions: ToolDefinition[] = [
|
|
|
84
84
|
|
|
85
85
|
{
|
|
86
86
|
name: "bereach_visit_profile",
|
|
87
|
-
description: "Visit LinkedIn profile and extract contact data. Distance-1 profiles cached 24h. No dedup — always executes.",
|
|
87
|
+
description: "Visit LinkedIn profile and extract contact data. Distance-1 profiles cached 24h. No dedup — always executes. Auto-saves full profileData to the contact record.",
|
|
88
88
|
handler: "scrapers.visitProfile",
|
|
89
89
|
parameters: {
|
|
90
90
|
type: "object",
|
|
@@ -113,7 +113,7 @@ export const definitions: ToolDefinition[] = [
|
|
|
113
113
|
|
|
114
114
|
{
|
|
115
115
|
name: "bereach_collect_hashtag_posts",
|
|
116
|
-
description: "Scrape LinkedIn posts for a given hashtag. Lead-gen channel for topical prospecting. 1 credit.",
|
|
116
|
+
description: "Scrape LinkedIn posts for a given hashtag. Lead-gen channel for topical prospecting. 1 credit. Auto-creates a contact for each post author.",
|
|
117
117
|
handler: "linkedinScrapers.collectHashtagPosts",
|
|
118
118
|
parameters: {
|
|
119
119
|
type: "object",
|
|
@@ -179,7 +179,7 @@ export const definitions: ToolDefinition[] = [
|
|
|
179
179
|
|
|
180
180
|
{
|
|
181
181
|
name: "bereach_search_posts",
|
|
182
|
-
description: "Search LinkedIn posts by keywords with optional filters for date, content type, author industry, and author company.",
|
|
182
|
+
description: "Search LinkedIn posts by keywords with optional filters for date, content type, author industry, and author company. Auto-creates a contact for each post author.",
|
|
183
183
|
handler: "linkedinSearch.searchPosts",
|
|
184
184
|
parameters: {
|
|
185
185
|
type: "object",
|
|
@@ -200,7 +200,7 @@ export const definitions: ToolDefinition[] = [
|
|
|
200
200
|
|
|
201
201
|
{
|
|
202
202
|
name: "bereach_search_people",
|
|
203
|
-
description: "Search LinkedIn people by keywords, connection degree, name, title, location, industry, company, and school.",
|
|
203
|
+
description: "Search LinkedIn people by keywords, connection degree, name, title, location, industry, company, and school. Auto-creates a contact for each result.",
|
|
204
204
|
handler: "linkedinSearch.searchPeople",
|
|
205
205
|
parameters: {
|
|
206
206
|
type: "object",
|
|
@@ -341,13 +341,14 @@ export const definitions: ToolDefinition[] = [
|
|
|
341
341
|
|
|
342
342
|
{
|
|
343
343
|
name: "bereach_send_message",
|
|
344
|
-
description: "Send LinkedIn message. Rate limited to 150 messages per day.",
|
|
344
|
+
description: "Send LinkedIn message. Rate limited to 150 messages per day. Provide profile OR conversationUrn (at least one required). Use conversationUrn to bypass profile URL lookup for contacts with garbled LinkedIn URLs.",
|
|
345
345
|
handler: "actions.sendMessage",
|
|
346
346
|
parameters: {
|
|
347
347
|
type: "object",
|
|
348
|
-
required: ["
|
|
348
|
+
required: ["message"],
|
|
349
349
|
properties: {
|
|
350
|
-
profile: { type: "string", description: "LinkedIn profile URL or profile URN." },
|
|
350
|
+
profile: { type: "string", description: "LinkedIn profile URL or profile URN. Optional if conversationUrn is provided." },
|
|
351
|
+
conversationUrn: { type: "string", description: "LinkedIn conversation URN from contact's conversationData. Preferred when profile URL is unavailable or invalid." },
|
|
351
352
|
message: { type: "string", description: "Message content to send." },
|
|
352
353
|
campaignSlug: { type: "string", description: "Campaign identifier for deduplication." },
|
|
353
354
|
},
|
|
@@ -529,7 +530,7 @@ export const definitions: ToolDefinition[] = [
|
|
|
529
530
|
|
|
530
531
|
{
|
|
531
532
|
name: "bereach_find_conversation",
|
|
532
|
-
description: "Find a conversation with a specific person. Direct O(1) lookup via LinkedIn's compose API. 0 credits.",
|
|
533
|
+
description: "Find a conversation with a specific person. Direct O(1) lookup via LinkedIn's compose API. 0 credits. Auto-saves conversationData to the contact record.",
|
|
533
534
|
handler: "chat.findConversation",
|
|
534
535
|
parameters: {
|
|
535
536
|
type: "object",
|
|
@@ -948,7 +949,7 @@ export const definitions: ToolDefinition[] = [
|
|
|
948
949
|
|
|
949
950
|
{
|
|
950
951
|
name: "bereach_contacts_upsert",
|
|
951
|
-
description: "Create or upsert contacts without a campaign.
|
|
952
|
+
description: "Create or upsert contacts without a campaign. For MANUAL IMPORTS ONLY (CSV, spreadsheets, user-provided lists). Most scrape/search/visit calls auto-create contacts — do not use this for data that was just scraped or visited. Upserts by LinkedIn URL. 0 credits.",
|
|
952
953
|
handler: "contacts.upsertContacts",
|
|
953
954
|
apiPath: "/api/contacts",
|
|
954
955
|
apiMethod: "POST",
|
package/src/tools/index.ts
CHANGED
|
@@ -6,18 +6,22 @@ type ResourceMap = {
|
|
|
6
6
|
[K in keyof Bereach as Bereach[K] extends object ? K : never]: Bereach[K];
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
const API_BASE =
|
|
9
|
+
const API_BASE = "https://api.berea.ch";
|
|
10
|
+
|
|
11
|
+
const MAX_RETRIES = 2;
|
|
10
12
|
|
|
11
13
|
async function executeApiCall(def: ToolDefinition, params: Record<string, unknown>, apiKey: string) {
|
|
12
14
|
let path = def.apiPath!;
|
|
13
15
|
const method = def.apiMethod ?? "POST";
|
|
14
16
|
|
|
15
|
-
// Resolve path parameters like {campaignId}, {contactId}, {key}
|
|
16
17
|
const bodyParams = { ...params };
|
|
17
18
|
path = path.replace(/\{(\w+)\}/g, (_, name) => {
|
|
18
19
|
const val = bodyParams[name];
|
|
20
|
+
if (val === undefined || val === null || val === "") {
|
|
21
|
+
throw new Error(`Missing required parameter: ${name} for ${def.apiPath}`);
|
|
22
|
+
}
|
|
19
23
|
delete bodyParams[name];
|
|
20
|
-
return encodeURIComponent(String(val
|
|
24
|
+
return encodeURIComponent(String(val));
|
|
21
25
|
});
|
|
22
26
|
|
|
23
27
|
let url = `${API_BASE}${path}`;
|
|
@@ -31,21 +35,47 @@ async function executeApiCall(def: ToolDefinition, params: Record<string, unknow
|
|
|
31
35
|
if (qsStr) url += `?${qsStr}`;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
let lastError: Error | null = null;
|
|
39
|
+
|
|
40
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
41
|
+
try {
|
|
42
|
+
const resp = await fetch(url, {
|
|
43
|
+
method,
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${apiKey}`,
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
},
|
|
48
|
+
...(method !== "GET" ? { body: JSON.stringify(bodyParams) } : {}),
|
|
49
|
+
});
|
|
42
50
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
if (resp.status === 429) {
|
|
52
|
+
const retryAfter = resp.headers.get("retry-after");
|
|
53
|
+
const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.pow(2, attempt + 1) * 1000;
|
|
54
|
+
if (attempt < MAX_RETRIES) {
|
|
55
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const body = await resp.text();
|
|
59
|
+
throw new Error(`Rate limited on ${path}. ${body}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!resp.ok) {
|
|
63
|
+
const errorBody = await resp.text();
|
|
64
|
+
throw new Error(`API ${method} ${path} failed (${resp.status}): ${errorBody}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return resp.json();
|
|
68
|
+
} catch (err) {
|
|
69
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
70
|
+
if (attempt < MAX_RETRIES && !(lastError.message.includes("failed (") || lastError.message.includes("Rate limited"))) {
|
|
71
|
+
await new Promise((r) => setTimeout(r, Math.pow(2, attempt + 1) * 1000));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
throw lastError;
|
|
75
|
+
}
|
|
46
76
|
}
|
|
47
77
|
|
|
48
|
-
|
|
78
|
+
throw lastError ?? new Error(`Failed after ${MAX_RETRIES + 1} attempts`);
|
|
49
79
|
}
|
|
50
80
|
|
|
51
81
|
/**
|