bereach-openclaw 0.2.13 → 0.2.15

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "bereach",
3
3
  "name": "BeReach",
4
- "version": "0.2.13",
4
+ "version": "0.2.15",
5
5
  "description": "LinkedIn outreach automation — 33 tools, auto-reply commands",
6
6
  "configSchema": {
7
7
  "type": "object",
@@ -10,6 +10,7 @@
10
10
  "BEREACH_API_KEY": {
11
11
  "type": "string",
12
12
  "minLength": 1,
13
+ "pattern": "^brc_",
13
14
  "description": "BeReach API token (starts with brc_)"
14
15
  }
15
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bereach-openclaw",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "BeReach LinkedIn automation plugin for OpenClaw",
5
5
  "license": "AGPL-3.0",
6
6
  "scripts": {
@@ -1,7 +1,8 @@
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. Requires BEREACH_API_KEY."
4
- lastUpdatedAt: 1772619338
4
+ lastUpdatedAt: 1772673508
5
+ metadata: { "openclaw": { "requires": { "env": ["BEREACH_API_KEY"] }, "primaryEnv": "BEREACH_API_KEY" } }
5
6
  ---
6
7
 
7
8
  # BeReach — LinkedIn Outreach Automation
@@ -16,9 +17,16 @@ Automate LinkedIn prospection and engagement via BeReach.
16
17
 
17
18
  | Sub-skill | Keywords | URL | lastUpdatedAt |
18
19
  | --------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------- |
19
- | Lead Magnet | lead magnet, comment to DM, resource delivery, post giveaway, auto-accept invitations, cron, recap, campaign stats, pause | sub/lead-magnet.md | 1772619338 |
20
+ | Lead Magnet | lead magnet, comment to DM, resource delivery, post giveaway, auto-accept invitations, scheduled run, recap, campaign stats, pause | sub/lead-magnet.md | 1772673508 |
20
21
  | OpenClaw Optimization | openclaw, model, opus, sonnet, haiku, config, SOUL.md, heartbeat, prompt caching, AI cost reduction, /model | openclaw-optimization.md | 1772619338 |
21
- | SDK Reference | sdk, method, parameter, signature, reference, api, script | sdk-reference.md | 1772619338 |
22
+ | SDK Reference | sdk, method, parameter, signature, reference, api, script | sdk-reference.md | 1772672714 |
23
+
24
+ ### Supporting files
25
+
26
+ Load these when the user's request matches the keywords above:
27
+
28
+ - For lead magnet campaigns (comment-to-DM, resource delivery, scheduled runs), see [Lead Magnet](sub/lead-magnet.md)
29
+ - For SDK method signatures and parameters when writing scripts, see [SDK Reference](sdk-reference.md)
22
30
 
23
31
  ## Installation
24
32
 
@@ -28,7 +36,7 @@ Tools are already configured by the plugin — no manual setup needed.
28
36
 
29
37
  ### SDK (script generation)
30
38
 
31
- The TypeScript SDK provides typed methods for all BeReach operations. Use it when generating scripts (cron jobs, batch automations).
39
+ The TypeScript SDK provides typed methods for all BeReach operations. Use it when generating scripts (scheduled automations, batch jobs).
32
40
 
33
41
  ```bash
34
42
  npm add bereach
@@ -47,6 +55,14 @@ const client = new Bereach({ token });
47
55
 
48
56
  The SDK auto-reads `BEREACH_API_KEY` from the environment. NEVER hardcode tokens in scripts.
49
57
 
58
+
59
+
60
+ 1. `GET SKILL.md` → compare frontmatter `lastUpdatedAt` with local copy
61
+ 2. If remote > local: re-download main skill, replace local copy
62
+ 3. Compare sub-skill `lastUpdatedAt` values — re-fetch any that changed
63
+ 4. Log: "BeReach skill updated" or "BeReach skill is up to date"
64
+ 5. Never skip this check
65
+
50
66
  ## Onboarding
51
67
 
52
68
  1. **Token** — read `BEREACH_API_KEY` from environment with no default/fallback value. If not set, stop immediately.
@@ -63,6 +79,7 @@ The SDK auto-reads `BEREACH_API_KEY` from the environment. NEVER hardcode tokens
63
79
 
64
80
  ### Script mode (SDK)
65
81
 
82
+ - Lead magnet scripts use the SDK. Tools are for interactive agent use only.
66
83
  - Import from the `bereach` SDK exclusively. If a method doesn't exist, the operation doesn't exist.
67
84
  - Scripts MUST be TypeScript (`.ts`). Run with `npx tsx script.ts`.
68
85
  - Run `npx tsc --noEmit` before executing to catch type errors.
@@ -114,24 +131,30 @@ These are technical constraints BeReach requires. Everything else, adapt as need
114
131
 
115
132
  Each workflow is detailed in its sub-skill. Load the relevant sub-skill when needed.
116
133
 
117
- - **Lead Magnet** — deliver a resource to everyone who engages with a post (comments, likes, invitations). → Lead Magnet sub-skill
134
+ - **Lead Magnet** — deliver a resource to everyone who engages with a post (comments, likes, invitations). Multi-campaign config at `~/.bereach/lead-magnet.json`. → Lead Magnet sub-skill
118
135
 
119
136
  More workflows coming soon. You can build your own using the SDK methods and tools listed above.
120
137
 
121
138
  ### Cron
122
139
 
123
- Crons are OpenClaw scheduled tasks. Create entries in `~/.openclaw/crons.json`:
140
+ Crons are OpenClaw scheduled tasks. Create entries in `~/.openclaw/cron/jobs.json` (via `openclaw cron add`).
141
+
142
+ **Lead Magnet** — 3 global crons. Config at `~/.openclaw/lead-magnet.json`. Each cron runs one script that loops over all enabled campaigns:
124
143
 
125
144
  ```json
126
- {
127
- "id": "bereach-{workflow}",
128
- "every": "30m",
129
- "skill": "bereach",
130
- "sessionTarget": "spawn",
131
- "prompt": "Run {workflow} script and report results"
132
- }
145
+ { "id": "lm-comments", "every": "1h", "skill": "bereach", "sessionTarget": "spawn", "prompt": "Run lm-comments.ts and report recap" }
146
+ { "id": "lm-invitations", "every": "1h", "skill": "bereach", "sessionTarget": "spawn", "prompt": "Run lm-invitations.ts and report recap" }
147
+ { "id": "lm-connections", "every": "1h", "skill": "bereach", "sessionTarget": "spawn", "prompt": "Run lm-connections.ts and report recap" }
133
148
  ```
134
149
 
135
150
  Always use `"sessionTarget": "spawn"` for crons that execute scripts. This spawns a sub-agent in an isolated session — the script runs independently and pushes results back to the user when done, without blocking the main session. Never use `exec()` directly from a cron prompt; it blocks until the script finishes.
136
151
 
152
+ **Update check** — detect-only, never auto-upgrade:
153
+
154
+ ```json
155
+ { "id": "bereach-update-check", "every": "1d", "skill": "bereach", "sessionTarget": "spawn", "prompt": "Check if a new BeReach plugin version is available. Compare local version from package.json with `npm view bereach-openclaw version`. If newer: notify user once: 'BeReach vX.Y.Z is available (current: vA.B.C). Say upgrade bereach when ready.' Do NOT upgrade automatically." }
156
+ ```
157
+
158
+ When the user says "upgrade bereach": follow the upgrade workflow in `upgrade-openclaw-bereach.md` — save user state, uninstall, reinstall, recreate scripts from new skill, wait for user confirmation before launching crons.
159
+
137
160
  Remove with `openclaw cron remove <id>`.
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: bereach-sdk-reference
3
3
  description: "Complete SDK method reference — parameters, types, and descriptions for all BeReach operations."
4
- lastUpdatedAt: 1772619338
4
+ lastUpdatedAt: 1772672714
5
5
  ---
6
6
 
7
7
  # BeReach SDK Reference
8
8
 
9
9
  > Auto-generated from OpenAPI spec. This is the single source of truth for SDK method signatures.
10
- > Use `client.<resource>.<method>(params)` — see [BeReach Skill](bereach-skill.md) for behavioral rules.
10
+ > Use `client.<resource>.<method>(params)` — see [BeReach Skill](SKILL.md) for behavioral rules.
11
11
 
12
12
  ## linkedinScrapers
13
13
 
@@ -21,6 +21,17 @@ Scrape LinkedIn post likes. Returns paginated list of profiles who liked a post.
21
21
  - **start** (integer, min 0) — Pagination offset (multiples of 200).
22
22
  - **count** (integer, 0-200) — Number of likes to fetch per page (0-200, default 200). Use count=0 for a free total-only check.
23
23
 
24
+ **Returns:**
25
+
26
+ - **profiles** (array)
27
+ - **count** (number)
28
+ - **total** (number)
29
+ - **start** (number)
30
+ - **hasMore** (boolean)
31
+ - **previousTotal** (number | null) — The total from your last call to this endpoint for the same post (null on first call). Compare with total to detect new likes without client-side tracking.
32
+ - **creditsUsed** (number) — Credits consumed by this call.
33
+ - **retryAfter** (number) — Seconds to wait before next call.
34
+
24
35
  ### collectComments
25
36
 
26
37
  Scrape LinkedIn post comments. Returns paginated list of commenters with comment text and URNs.
@@ -32,6 +43,19 @@ Scrape LinkedIn post comments. Returns paginated list of commenters with comment
32
43
  - **count** (integer, 0-100) — Number of comments to fetch per page (0-100, default 100). Use count=0 for a free total-only check.
33
44
  - **campaignSlug** (string) — When provided, each profile includes actionsCompleted and knownDistance for campaign-aware scraping.
34
45
 
46
+ **Returns:**
47
+
48
+ - **profiles** (array)
49
+ - **count** (number)
50
+ - **total** (number)
51
+ - **start** (number)
52
+ - **hasMore** (boolean)
53
+ - **previousTotal** (number | null) — The total from your last call to this endpoint for the same post (null on first call). Compare with total to detect new comments without client-side tracking.
54
+ - **processedCount** (number, optional) — Number of returned profiles that already have a completed 'message' action in this campaign. Only present when campaignSlug is provided.
55
+ - **newCount** (number, optional) — Number of returned profiles that have NOT yet been messaged in this campaign. Only present when campaignSlug is provided.
56
+ - **creditsUsed** (number) — Credits consumed by this call.
57
+ - **retryAfter** (number) — Seconds to wait before next call.
58
+
35
59
  ### collectCommentReplies
36
60
 
37
61
  Scrape replies to a LinkedIn comment. Returns paginated replies for a specific comment URN.
@@ -42,6 +66,18 @@ Scrape replies to a LinkedIn comment. Returns paginated replies for a specific c
42
66
  - **start** (integer, min 0) — Pagination offset (default 0).
43
67
  - **count** (integer, 0-100) — Number of replies to fetch per page (0-100, default 100). Use count=0 for a free total-only check.
44
68
 
69
+ **Returns:**
70
+
71
+ - **replies** (array)
72
+ - **count** (number)
73
+ - **total** (number)
74
+ - **start** (number)
75
+ - **hasMore** (boolean)
76
+ - **parentCommentUrn** (string)
77
+ - **previousTotal** (number | null) — The total from your last call to this endpoint for the same comment (null on first call). Compare with total to detect new replies without client-side tracking.
78
+ - **creditsUsed** (number) — Credits consumed by this call.
79
+ - **retryAfter** (number) — Seconds to wait before next call.
80
+
45
81
  ### collectPosts
46
82
 
47
83
  Scrape LinkedIn profile posts. Returns paginated list of posts from a profile.
@@ -53,6 +89,18 @@ Scrape LinkedIn profile posts. Returns paginated list of posts from a profile.
53
89
  - **start** (integer, min 0) — Pagination offset (default 0).
54
90
  - **paginationToken** (string) — Pagination token from a previous response to fetch the next page.
55
91
 
92
+ **Returns:**
93
+
94
+ - **posts** (array)
95
+ - **count** (number)
96
+ - **total** (number)
97
+ - **start** (number)
98
+ - **hasMore** (boolean)
99
+ - **paginationToken** (string | null)
100
+ - **previousTotal** (number | null) — The total from your last call to this endpoint for the same profile (null on first call). Compare with total to detect new posts without client-side tracking.
101
+ - **creditsUsed** (number) — Credits consumed by this call.
102
+ - **retryAfter** (number) — Seconds to wait before next call.
103
+
56
104
  ### visitProfile
57
105
 
58
106
  Visit LinkedIn profile and extract contact data. Distance-1 profiles cached 24h. No dedup — always executes.
@@ -63,6 +111,28 @@ Visit LinkedIn profile and extract contact data. Distance-1 profiles cached 24h.
63
111
  - **campaignSlug** (string) — Optional campaign identifier for tracking only. No dedup — visit always executes.
64
112
  - **includePosts** (boolean) — When true, fetches the last 5 posts from the profile. Defaults to false.
65
113
 
114
+ **Returns:**
115
+
116
+ - **firstName** (string)
117
+ - **lastName** (string)
118
+ - **headline** (string | null)
119
+ - **publicIdentifier** (string)
120
+ - **profileUrl** (string)
121
+ - **profileUrn** (string, optional) — LinkedIn profile URN (e.g. 'urn:li:fsd_profile:ACoAAA...'). Use this for matching against inbox participants.
122
+ - **imageUrl** (string | null)
123
+ - **email** (string | null, optional)
124
+ - **location** (string | null, optional)
125
+ - **company** (string | null, optional) — Current company name (from most recent position)
126
+ - **position** (string | null, optional) — Current job title (from most recent position)
127
+ - **memberDistance** (number | null, optional)
128
+ - **pendingConnection** (string)
129
+ - **positions** (array, optional) — Work experience positions
130
+ - **educations** (array, optional) — Education entries
131
+ - **lastPosts** (array, optional) — Last 5 posts from the profile (only present when includePosts is true)
132
+ - **cached** (boolean) — true if this result was served from cache (0 credits). Distance-1 profiles are cached for 24h.
133
+ - **creditsUsed** (number) — Credits consumed by this call.
134
+ - **retryAfter** (number) — Seconds to wait before next call.
135
+
66
136
  ### visitCompany
67
137
 
68
138
  Visit LinkedIn company page and extract profile data including description, industry, employee count, headquarters, and more.
@@ -72,6 +142,34 @@ Visit LinkedIn company page and extract profile data including description, indu
72
142
  - **companyUrl** (string, required) — LinkedIn company URL (e.g., 'https://www.linkedin.com/company/openai') or universal name (e.g., 'openai').
73
143
  - **includeWorkplacePolicy** (boolean) — Include workplace policy data such as hybrid/remote status and benefits. Costs 1 extra credit.
74
144
 
145
+ **Returns:**
146
+
147
+ - **id** (string) — LinkedIn numeric company ID
148
+ - **universalName** (string) — Company URL slug
149
+ - **name** (string)
150
+ - **description** (string | null)
151
+ - **url** (string) — LinkedIn company page URL
152
+ - **websiteUrl** (string | null) — External website URL
153
+ - **industry** (string | null)
154
+ - **employeeCount** (number | null) — Exact employee count on LinkedIn
155
+ - **employeeCountRange** (object)
156
+ - **end** (number | null) — Employee count range
157
+ - **logoUrl** (string | null)
158
+ - **coverImageUrl** (string | null)
159
+ - **followerCount** (number | null)
160
+ - **specialities** (array)
161
+ - **tagline** (string | null)
162
+ - **isVerified** (boolean)
163
+ - **foundedOn** (object | null)
164
+ - **phone** (string | null)
165
+ - **callToAction** (object)
166
+ - **url** (string | null)
167
+ - **hashtags** (array)
168
+ - **affiliatedCompanies** (array) — Showcase / affiliated pages
169
+ - **similarCompanies** (array) — Similar companies suggested by LinkedIn
170
+ - **creditsUsed** (number) — Credits consumed by this call.
171
+ - **retryAfter** (number) — Seconds to wait before next call.
172
+
75
173
  ## linkedinSearch
76
174
 
77
175
  ### unifiedSearch
@@ -106,6 +204,13 @@ Unified LinkedIn Search — search posts, people, companies, or jobs with a sing
106
204
  - **start** (integer, min 0) — Pagination offset (default 0).
107
205
  - **count** (integer, 1-50) — Results per page (default 10, max 50).
108
206
 
207
+ **Returns:**
208
+
209
+ - **items** (unknown)
210
+ - **hasMore** (boolean)
211
+ - **creditsUsed** (number) — Credits consumed by this call.
212
+ - **retryAfter** (number) — Seconds to wait before next call.
213
+
109
214
  ### searchPosts
110
215
 
111
216
  Search LinkedIn posts by keywords with optional filters for date, content type, author industry, and author company.
@@ -122,6 +227,13 @@ Search LinkedIn posts by keywords with optional filters for date, content type,
122
227
  - **start** (integer, min 0) — Pagination offset (default 0).
123
228
  - **count** (integer, 1-50) — Results per page (default 10, max 50).
124
229
 
230
+ **Returns:**
231
+
232
+ - **items** (unknown)
233
+ - **hasMore** (boolean)
234
+ - **creditsUsed** (number) — Credits consumed by this call.
235
+ - **retryAfter** (number) — Seconds to wait before next call.
236
+
125
237
  ### searchPeople
126
238
 
127
239
  Search LinkedIn people by keywords, connection degree, name, title, location, industry, company, and school.
@@ -144,6 +256,13 @@ Search LinkedIn people by keywords, connection degree, name, title, location, in
144
256
  - **start** (integer, min 0) — Pagination offset (default 0).
145
257
  - **count** (integer, 1-50) — Results per page (default 10, max 50).
146
258
 
259
+ **Returns:**
260
+
261
+ - **items** (unknown)
262
+ - **hasMore** (boolean)
263
+ - **creditsUsed** (number) — Credits consumed by this call.
264
+ - **retryAfter** (number) — Seconds to wait before next call.
265
+
147
266
  ### searchCompanies
148
267
 
149
268
  Search LinkedIn companies by keywords, location, industry, and company size.
@@ -158,6 +277,13 @@ Search LinkedIn companies by keywords, location, industry, and company size.
158
277
  - **start** (integer, min 0) — Pagination offset (default 0).
159
278
  - **count** (integer, 1-50) — Results per page (default 10, max 50).
160
279
 
280
+ **Returns:**
281
+
282
+ - **items** (unknown)
283
+ - **hasMore** (boolean)
284
+ - **creditsUsed** (number) — Credits consumed by this call.
285
+ - **retryAfter** (number) — Seconds to wait before next call.
286
+
161
287
  ### searchJobs
162
288
 
163
289
  Search LinkedIn jobs by keywords, location, job type, experience level, and workplace type.
@@ -175,6 +301,13 @@ Search LinkedIn jobs by keywords, location, job type, experience level, and work
175
301
  - **start** (integer, min 0) — Pagination offset (default 0).
176
302
  - **count** (integer, 1-50) — Results per page (default 10, max 50).
177
303
 
304
+ **Returns:**
305
+
306
+ - **items** (unknown)
307
+ - **hasMore** (boolean)
308
+ - **creditsUsed** (number) — Credits consumed by this call.
309
+ - **retryAfter** (number) — Seconds to wait before next call.
310
+
178
311
  ### searchByUrl
179
312
 
180
313
  Search LinkedIn by URL. Automatically extracts category, keywords, and filters from a LinkedIn search URL.
@@ -185,6 +318,13 @@ Search LinkedIn by URL. Automatically extracts category, keywords, and filters f
185
318
  - **start** (integer, min 0) — Override pagination offset.
186
319
  - **count** (integer, 1-50) — Override results per page (default 10, max 50).
187
320
 
321
+ **Returns:**
322
+
323
+ - **items** (unknown)
324
+ - **hasMore** (boolean)
325
+ - **creditsUsed** (number) — Credits consumed by this call.
326
+ - **retryAfter** (number) — Seconds to wait before next call.
327
+
188
328
  ### resolveParameters
189
329
 
190
330
  Resolve text to LinkedIn search parameter IDs (typeahead). Prerequisite for using location, industry, company, or school filters.
@@ -195,6 +335,13 @@ Resolve text to LinkedIn search parameter IDs (typeahead). Prerequisite for usin
195
335
  - **keywords** (string, required) — Text to resolve into LinkedIn IDs.
196
336
  - **limit** (integer, 1-50) — Max results (default 10, max 50).
197
337
 
338
+ **Returns:**
339
+
340
+ - **items** (array)
341
+ - **count** (number)
342
+ - **creditsUsed** (number) — Credits consumed by this call.
343
+ - **retryAfter** (number) — Seconds to wait before next call.
344
+
198
345
  ## linkedinActions
199
346
 
200
347
  ### connectProfile
@@ -206,6 +353,13 @@ Send LinkedIn connection request. Deduplicates by profile within a campaign.
206
353
  - **profile** (string, required) — LinkedIn profile URL or profile URN.
207
354
  - **campaignSlug** (string) — Campaign identifier for deduplication.
208
355
 
356
+ **Returns:**
357
+
358
+ - **message** (string)
359
+ - **duplicate** (boolean, optional) — True if a connection request was already successfully sent to this profile this week
360
+ - **creditsUsed** (number) — Credits consumed by this call.
361
+ - **retryAfter** (number) — Seconds to wait before next call.
362
+
209
363
  ### listInvitations
210
364
 
211
365
  List received LinkedIn connection invitations. Returns pending invitations with IDs needed to accept them.
@@ -215,6 +369,15 @@ List received LinkedIn connection invitations. Returns pending invitations with
215
369
  - **start** (integer, min 0) — Pagination offset (default 0).
216
370
  - **count** (integer, 1-100) — Number of invitations to return (default 10, max 100).
217
371
 
372
+ **Returns:**
373
+
374
+ - **invitations** (array)
375
+ - **total** (number) — Total number of pending invitations
376
+ - **start** (number)
377
+ - **count** (number)
378
+ - **creditsUsed** (number) — Credits consumed by this call.
379
+ - **retryAfter** (number) — Seconds to wait before next call.
380
+
218
381
  ### acceptInvitation
219
382
 
220
383
  Accept a LinkedIn connection invitation. Requires invitationId and sharedSecret from the list invitations endpoint.
@@ -227,6 +390,12 @@ Accept a LinkedIn connection invitation. Requires invitationId and sharedSecret
227
390
  - **firstName** (string) — Sender's first name. Recommended for reliability.
228
391
  - **lastName** (string) — Sender's last name. Recommended for reliability.
229
392
 
393
+ **Returns:**
394
+
395
+ - **message** (string)
396
+ - **creditsUsed** (number) — Credits consumed by this call.
397
+ - **retryAfter** (number) — Seconds to wait before next call.
398
+
230
399
  ### sendMessage
231
400
 
232
401
  Send LinkedIn message. Rate limited to 80 messages per day.
@@ -237,6 +406,13 @@ Send LinkedIn message. Rate limited to 80 messages per day.
237
406
  - **message** (string, required) — Message content to send.
238
407
  - **campaignSlug** (string) — Campaign identifier for deduplication.
239
408
 
409
+ **Returns:**
410
+
411
+ - **messageId** (string)
412
+ - **duplicate** (boolean, optional) — True if this action was already executed for the given campaignSlug + target.
413
+ - **creditsUsed** (number) — Credits consumed by this call.
414
+ - **retryAfter** (number) — Seconds to wait before next call.
415
+
240
416
  ### replyToComment
241
417
 
242
418
  Reply to a LinkedIn comment. Use the commentUrn from the comments endpoint directly.
@@ -247,6 +423,13 @@ Reply to a LinkedIn comment. Use the commentUrn from the comments endpoint direc
247
423
  - **message** (string, required) — Reply message text.
248
424
  - **campaignSlug** (string) — Campaign identifier for deduplication.
249
425
 
426
+ **Returns:**
427
+
428
+ - **replyUrn** (string, optional) — URN of the created reply comment
429
+ - **duplicate** (boolean, optional) — True if this action was already executed for the given campaignSlug + target.
430
+ - **creditsUsed** (number) — Credits consumed by this call.
431
+ - **retryAfter** (number) — Seconds to wait before next call.
432
+
250
433
  ### likeComment
251
434
 
252
435
  Like (react to) a LinkedIn comment. Use the commentUrn from the comments endpoint directly.
@@ -257,6 +440,13 @@ Like (react to) a LinkedIn comment. Use the commentUrn from the comments endpoin
257
440
  - **reactionType** (string) — Reaction type (default: LIKE). Values: LIKE, LOVE, CELEBRATE, SUPPORT, FUNNY, INSIGHTFUL.
258
441
  - **campaignSlug** (string) — Campaign identifier for deduplication.
259
442
 
443
+ **Returns:**
444
+
445
+ - **resourceKey** (string, optional) — Resource key of the created reaction
446
+ - **duplicate** (boolean, optional) — True if this action was already executed for the given campaignSlug + target.
447
+ - **creditsUsed** (number) — Credits consumed by this call.
448
+ - **retryAfter** (number) — Seconds to wait before next call.
449
+
260
450
  ### publishPost
261
451
 
262
452
  Publish or schedule a LinkedIn post. Supports text, images, mentions, and visibility control.
@@ -271,6 +461,14 @@ Publish or schedule a LinkedIn post. Supports text, images, mentions, and visibi
271
461
  - **mentions** (object[]) — Profile mentions with text positions.
272
462
  - **campaignSlug** (string) — Campaign identifier for deduplication.
273
463
 
464
+ **Returns:**
465
+
466
+ - **shareUrn** (string)
467
+ - **activityUrn** (string | null) — Activity URN (only available for instant mode)
468
+ - **duplicate** (boolean, optional) — True if this action was already executed for the given campaignSlug + target.
469
+ - **creditsUsed** (number) — Credits consumed by this call.
470
+ - **retryAfter** (number) — Seconds to wait before next call.
471
+
274
472
  ## linkedinChat
275
473
 
276
474
  ### listConversations
@@ -281,6 +479,13 @@ List LinkedIn inbox conversations with participants, last message, and read stat
281
479
 
282
480
  - **nextCursor** (string) — Pagination cursor from a previous response.
283
481
 
482
+ **Returns:**
483
+
484
+ - **conversations** (array)
485
+ - **nextCursor** (string | null) — Cursor for fetching next page
486
+ - **creditsUsed** (number) — Credits consumed by this call.
487
+ - **retryAfter** (number) — Seconds to wait before next call.
488
+
284
489
  ### searchConversations
285
490
 
286
491
  Search LinkedIn inbox conversations by keyword. 0 credits.
@@ -290,6 +495,13 @@ Search LinkedIn inbox conversations by keyword. 0 credits.
290
495
  - **keywords** (string, required) — Search keywords.
291
496
  - **nextCursor** (string) — Pagination cursor from a previous response.
292
497
 
498
+ **Returns:**
499
+
500
+ - **conversations** (array)
501
+ - **nextCursor** (string | null) — Cursor for fetching next page
502
+ - **creditsUsed** (number) — Credits consumed by this call.
503
+ - **retryAfter** (number) — Seconds to wait before next call.
504
+
293
505
  ### findConversation
294
506
 
295
507
  Find a conversation with a specific person. Direct O(1) lookup via LinkedIn's compose API. 0 credits.
@@ -299,6 +511,13 @@ Find a conversation with a specific person. Direct O(1) lookup via LinkedIn's co
299
511
  - **profile** (string, required) — Profile URL or URN for direct conversation lookup.
300
512
  - **includeMessages** (boolean) — If true, also return the conversation's recent messages (0 extra credits). Default: false.
301
513
 
514
+ **Returns:**
515
+
516
+ - **found** (boolean)
517
+ - **messages** (array | null)
518
+ - **creditsUsed** (number) — Credits consumed by this call.
519
+ - **retryAfter** (number) — Seconds to wait before next call.
520
+
302
521
  ### getMessages
303
522
 
304
523
  Read messages from a LinkedIn conversation. 0 credits.
@@ -308,6 +527,13 @@ Read messages from a LinkedIn conversation. 0 credits.
308
527
  - **conversationUrn** (string, required) — Full conversation URN as returned by list/search conversations.
309
528
  - **deliveredAt** (integer) — Timestamp (ms) of the oldest message from previous page — pass this to load older messages.
310
529
 
530
+ **Returns:**
531
+
532
+ - **messages** (array)
533
+ - **prevCursor** (number | null) — deliveredAt timestamp (ms) of the oldest message — pass as 'deliveredAt' to load older messages. Null when no more messages.
534
+ - **creditsUsed** (number) — Credits consumed by this call.
535
+ - **retryAfter** (number) — Seconds to wait before next call.
536
+
311
537
  ## profile
312
538
 
313
539
  ### getLinkedInProfile
@@ -318,6 +544,11 @@ Get authenticated user's LinkedIn profile from the database. No LinkedIn API cal
318
544
 
319
545
  No parameters.
320
546
 
547
+ **Returns:**
548
+
549
+ - **creditsUsed** (number) — Credits consumed by this call.
550
+ - **retryAfter** (number) — Seconds to wait before next call.
551
+
321
552
  ### refresh
322
553
 
323
554
  Refresh authenticated user's LinkedIn profile by fetching latest data from LinkedIn.
@@ -326,6 +557,12 @@ Refresh authenticated user's LinkedIn profile by fetching latest data from Linke
326
557
 
327
558
  No parameters.
328
559
 
560
+ **Returns:**
561
+
562
+ - **refreshed** (true)
563
+ - **creditsUsed** (number) — Credits consumed by this call.
564
+ - **retryAfter** (number) — Seconds to wait before next call.
565
+
329
566
  ### getPosts
330
567
 
331
568
  Get authenticated user's LinkedIn posts.
@@ -336,6 +573,17 @@ Get authenticated user's LinkedIn posts.
336
573
  - **start** (integer, min 0) — Pagination offset (default 0).
337
574
  - **paginationToken** (string) — Pagination token from a previous response.
338
575
 
576
+ **Returns:**
577
+
578
+ - **posts** (array)
579
+ - **count** (number)
580
+ - **total** (number)
581
+ - **start** (number)
582
+ - **hasMore** (boolean)
583
+ - **paginationToken** (string | null)
584
+ - **creditsUsed** (number) — Credits consumed by this call.
585
+ - **retryAfter** (number) — Seconds to wait before next call.
586
+
339
587
  ### getFollowers
340
588
 
341
589
  Get authenticated user's LinkedIn followers.
@@ -345,6 +593,16 @@ Get authenticated user's LinkedIn followers.
345
593
  - **start** (integer, min 0) — Pagination offset (default 0).
346
594
  - **count** (integer, 1-50) — Number of followers to fetch per page (default 10, max 50).
347
595
 
596
+ **Returns:**
597
+
598
+ - **followers** (array)
599
+ - **count** (number)
600
+ - **totalFollowers** (number)
601
+ - **start** (number)
602
+ - **hasMore** (boolean)
603
+ - **creditsUsed** (number) — Credits consumed by this call.
604
+ - **retryAfter** (number) — Seconds to wait before next call.
605
+
348
606
  ### getLimits
349
607
 
350
608
  Get current LinkedIn rate limit status for all action types. 0 credits.
@@ -353,6 +611,13 @@ Get current LinkedIn rate limit status for all action types. 0 credits.
353
611
 
354
612
  No parameters.
355
613
 
614
+ **Returns:**
615
+
616
+ - **multiplier** (number) — Workspace limit multiplier applied to all base limits (default 1.0)
617
+ - **limits** (object)
618
+ - **creditsUsed** (number) — Credits consumed by this call.
619
+ - **retryAfter** (number) — Seconds to wait before next call.
620
+
356
621
  ### getCredits
357
622
 
358
623
  Get current BeReach credit balance including used, remaining, and percentage. 0 credits.
@@ -361,6 +626,16 @@ Get current BeReach credit balance including used, remaining, and percentage. 0
361
626
 
362
627
  No parameters.
363
628
 
629
+ **Returns:**
630
+
631
+ - **credits** (object)
632
+ - **current** (number) — Number of credits used in the current billing period
633
+ - **limit** (number) — Maximum credits available for the workspace
634
+ - **remaining** (number) — Credits remaining before hitting the limit
635
+ - **percentage** (number)
636
+ - **creditsUsed** (number) — Credits consumed by this call.
637
+ - **retryAfter** (number) — Seconds to wait before next call.
638
+
364
639
  ## campaigns
365
640
 
366
641
  ### getStatus
@@ -372,6 +647,12 @@ Query per-profile action status within a campaign. Returns which actions have be
372
647
  - **campaignSlug** (string, required) — Campaign identifier.
373
648
  - **profiles** (string[], required) — LinkedIn profile URLs or URNs to check status for.
374
649
 
650
+ **Returns:**
651
+
652
+ - **profiles** (array)
653
+ - **creditsUsed** (number) — Credits consumed by this call.
654
+ - **retryAfter** (number) — Seconds to wait before next call.
655
+
375
656
  ### syncActions
376
657
 
377
658
  Mark actions as completed without performing them on LinkedIn. Use when actions were performed outside the API. 0 credits.
@@ -381,6 +662,12 @@ Mark actions as completed without performing them on LinkedIn. Use when actions
381
662
  - **campaignSlug** (string, required) — Campaign identifier.
382
663
  - **profiles** (object[], required) — Profiles and actions to mark as completed. Values: message, reply, like, visit, connect.
383
664
 
665
+ **Returns:**
666
+
667
+ - **synced** (array)
668
+ - **creditsUsed** (number) — Credits consumed by this call.
669
+ - **retryAfter** (number) — Seconds to wait before next call.
670
+
384
671
  ### getStats
385
672
 
386
673
  Get aggregate campaign statistics: per-action counts, unique profiles, and total credits used. 0 credits.
@@ -388,3 +675,10 @@ Get aggregate campaign statistics: per-action counts, unique profiles, and total
388
675
  `client.campaigns.getStats(params)`
389
676
 
390
677
  - **campaignSlug** (string, required) — Campaign identifier.
678
+
679
+ **Returns:**
680
+
681
+ - **stats** (Record<string, …>)
682
+ - **totalProfiles** (number) — Unique profiles processed in this campaign
683
+ - **creditsUsed** (number) — Total credits consumed by this campaign (sum of action counts). Also serves as the per-call creditsUsed (always 0 for this endpoint).
684
+ - **retryAfter** (number) — Seconds to wait before next call of the same type (always 0 for campaign queries).
@@ -1,16 +1,16 @@
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: 1772619338
4
+ lastUpdatedAt: 1772673508
5
5
  ---
6
6
 
7
7
  # BeReach Lead Magnet Skill
8
8
 
9
- > Sub-skill of [BeReach](bereach-skill.md). Deliver a resource to everyone who engages with a LinkedIn post.
9
+ > Sub-skill of [BeReach](SKILL.md). Deliver a resource to everyone who engages with a LinkedIn post.
10
10
 
11
11
  ## Prerequisites
12
12
 
13
- Requires [BeReach SKILL.md](bereach-skill.md) (constraints, SDK setup, anti-hallucination rules) and [SDK Reference](sdk-reference.md) for method signatures and parameters. Do NOT generate code without both loaded.
13
+ Requires [BeReach SKILL.md](SKILL.md) (constraints, SDK setup, anti-hallucination rules) and [SDK Reference](sdk-reference.md) for method signatures and parameters. Do NOT generate code without both loaded.
14
14
 
15
15
  ## Script generation rules
16
16
 
@@ -22,28 +22,64 @@ All script output and chatbot messages are **for the end user**. Write like you'
22
22
 
23
23
  ## Objective
24
24
 
25
- **Every person who engages with the post must receive the resource via DM as soon as they are distance 1.** Comment, like, invitation — the channel doesn't matter. The person gets the resource the moment a DM is possible.
25
+ **Every person who engages with the post must receive the resource via DM as soon as they are distance 1.** Comment, invitation — the channel doesn't matter. The person gets the resource the moment a DM is possible.
26
26
 
27
27
  ## Configuration
28
28
 
29
- Only ask for these on activation. If `POST_URL` is omitted, call `getPosts` and let the user pick.
29
+ All campaigns are defined in a single JSON file: `~/.bereach/lead-magnet.json`. The agent creates or edits this file when adding campaigns. On first run: if the file is missing or empty, the agent asks the user for campaign data and writes the file.
30
30
 
31
+ ### Schema
32
+
33
+ ```json
34
+ {
35
+ "campaigns": [
36
+ {
37
+ "campaignSlug": "lm-feb-2026",
38
+ "postUrl": "https://linkedin.com/feed/update/urn:li:activity:...",
39
+ "resourceLink": "https://example.com/resource",
40
+ "dmTemplate": "{firstName}, voici la ressource: {link}",
41
+ "dmExamples": ["Hey {firstName}, c'est ici: {link}", "..."],
42
+ "commentExamples": {
43
+ "distance1": ["C'est parti !", "Envoi en cours"],
44
+ "distance2": ["Connecte-toi pour l'avoir", "On se connecte ?"]
45
+ },
46
+ "language": "fr",
47
+ "timezone": "Europe/Paris",
48
+ "activeHours": { "start": "07:00", "end": "23:00" },
49
+ "enabled": true
50
+ }
51
+ ]
52
+ }
31
53
  ```
32
- POST_URL=<LinkedIn post URL>
33
- CAMPAIGN_SLUG=<unique name, e.g. "lm-feb-2026">
34
- DM_TEMPLATE=<DM with {firstName} and {link} placeholders, e.g. "{firstName}, voici la ressource: {link}">
35
- RESOURCE_LINK=<URL to share, or empty if DM is plain text>
36
- ```
37
54
 
38
- `DM_TEMPLATE` supports `{firstName}` (commenter's first name) and `{link}` (the `RESOURCE_LINK` value). For richer personalization (headline, company), use visit data from the profile response when available.
55
+ ### Per-campaign fields
56
+
57
+ | Field | Required | Description |
58
+ | --- | --- | --- |
59
+ | campaignSlug | yes | Unique ID for dedup |
60
+ | postUrl | yes | LinkedIn post URL |
61
+ | resourceLink | yes/no | URL to share in DM; empty = plain text, disables DM guard |
62
+ | dmTemplate | yes | Base pattern with `{firstName}`, `{link}` |
63
+ | dmExamples | no | List of DM variations (random pick); if absent, generate from template |
64
+ | commentExamples | no | `{ distance1: string[], distance2: string[] }`; if absent, use defaults |
65
+ | language | no | Post language (default: detect from post) |
66
+ | timezone | no | User timezone (default: from getLinkedInProfile) |
67
+ | activeHours | no | `{ start, end }` (default: 07:00–23:00) |
68
+ | enabled | no | `true` = run (default: true) |
69
+
70
+ Ask the user if their DM includes a link to share. If yes, set `resourceLink` — this enables the DM guard (inbox dedup). If no link, set `resourceLink` to empty — the DM guard is disabled and only server-side `campaignSlug` dedup applies.
71
+
72
+ **Validation**: if `resourceLink` is empty or missing, `dmTemplate` must NOT contain `{link}`. If it does, strip `{link}` from the template at runtime and warn the user.
39
73
 
40
- Ask the user if their DM includes a link to share. If yes, store it in `RESOURCE_LINK` — this enables the DM guard (inbox dedup). If no link, set `RESOURCE_LINK` to empty — the DM guard is disabled and only server-side `campaignSlug` dedup applies.
74
+ ## Config validation
75
+
76
+ At script start, validate the config: reject invalid JSON, require all required fields per campaign, reject duplicate `campaignSlug` values. Skip campaigns with missing or malformed data — never silently ignore errors.
41
77
 
42
78
  ## Tracking
43
79
 
44
80
  BeReach is the source of truth for action tracking (`campaignSlug` dedup).
45
81
 
46
- For the incremental comment check (`previousTotal`), use a local state file **per campaign** as a fallback — this survives process restarts and avoids re-processing if the cache resets. Only store the comment total counter locally; all action tracking stays on BeReach.
82
+ For the incremental comment check (`previousTotal`), use `~/.bereach/lead-magnet-state.json` with `{ [campaignSlug]: { previousTotal } }` — this survives process restarts and avoids re-processing if the cache resets. Only store the comment total counter locally; all action tracking stays on BeReach.
47
83
 
48
84
  - **Check status**: `getStatus` with `campaignSlug` and `profiles` → returns per-profile booleans (`message`, `reply`, `like`, `visit`, `connect`)
49
85
  - **Manual sync**: `syncActions` with `campaignSlug` and `profiles: [{ profile, actions }]` → mark actions as completed without performing them (e.g. manual DMs done outside the bot)
@@ -58,23 +94,36 @@ All 3 scripts must perform pre-flight at the start of each run:
58
94
 
59
95
  ## Architecture
60
96
 
61
- Split into 3 independent scripts. Each script:
97
+ **3 scripts** (one per workflow): `lm-comments.ts`, `lm-invitations.ts`, `lm-connections.ts`. **3 scheduled runs** (global) — each runs one script that loops over all enabled campaigns.
98
+
99
+ Each script:
62
100
 
63
- 1. Acquires a file lock **scoped to the campaign slug + script** (e.g. using a lock file with `fs.open` exclusive flag). If locked, exit immediately — a previous run is still active.
64
- 2. Runs its logic.
65
- 3. Prints recap and releases lock on exit.
101
+ 1. Reads `~/.bereach/lead-magnet.json`
102
+ 2. Filters `campaigns` where `enabled !== false`
103
+ 3. For each campaign: acquire lock `lm-{slug}-{script}` (see "Lock mechanism" below). If locked, skip that campaign — a previous run is still active.
104
+ 4. Runs its logic for the campaign.
105
+ 5. Prints recap per campaign, releases lock, moves to next campaign.
106
+ 6. At end: aggregate recap for all campaigns.
66
107
 
67
- The 3 scripts share `CAMPAIGN_SLUG` — BeReach dedup ensures no action is performed twice across scripts.
108
+ BeReach dedup ensures no action is performed twice across scripts (same `campaignSlug`).
68
109
 
69
110
  ### Multiple campaigns
70
111
 
71
- A user can run lead magnets on several posts simultaneously. Each campaign has its own set of 3 scripts and 3 crons. Lock files and state files must include the campaign slug to avoid collisions between campaigns.
112
+ All campaigns run in the same 3 scheduled runs. The config file lists all campaigns; each script iterates over them. Lock IDs: `lm-{slug}-comments`, `lm-{slug}-invitations`, `lm-{slug}-connections` (per campaign). State: `~/.bereach/lead-magnet-state.json` with `{ [campaignSlug]: { previousTotal } }`.
72
113
 
73
114
  All campaigns share the same LinkedIn rate limits. If multiple campaigns run at the same time, they compete for the same quota. BeReach handles conflicts automatically.
74
115
 
116
+ ### Lock mechanism
117
+
118
+ Before running a script for a campaign, acquire a lock to prevent concurrent runs. One lock per campaign per script.
119
+
120
+ - If the lock is already held by an active process, skip that campaign.
121
+ - If the lock is stale (older than 30 minutes) or the holding process is dead, force-acquire.
122
+ - Always release the lock on exit (normal, error, or signal).
123
+
75
124
  ### Script 1 — Comments
76
125
 
77
- Scrape commenters and engage them.
126
+ For each campaign (from config): scrape commenters and engage them.
78
127
 
79
128
  1. **Check first** (mandatory): `collectComments` with `count: 0` → store `total`. On subsequent rounds, compare with previous `total`. If unchanged, skip this round. This saves credits and rate limit slots.
80
129
  2. **Fetch**: `collectComments` with `campaignSlug` → response: `profiles[]` (NOT "comments"). Paginate: while `hasMore`, increment `start` by `count`.
@@ -92,17 +141,18 @@ Scrape commenters and engage them.
92
141
 
93
142
  ### Script 2 — Invitations
94
143
 
95
- Accept pending invitations and deliver the resource.
144
+ Accept pending invitations and deliver the resource to campaign-related invitees.
96
145
 
97
146
  - `listInvitations` → response: `invitations[]`. Each has: `invitationId`, `sharedSecret`, `fromMember: { name, profileUrl }`. Paginate: while `total > start + count`.
147
+ - **Campaign attribution**: `listInvitations` returns ALL pending invitations. Cross-reference each inviter against campaign commenter lists to find which campaign (if any) they belong to. Accept all invitations, but only DM if campaign-matched.
98
148
  - For each invitation:
99
- 1. `acceptInvitation` with `invitationId` and `sharedSecret` — only these 2 required params
100
- 2. `visitProfile` with `fromMember.profileUrl` and `campaignSlug`
101
- 3. If `memberDistance === 1`: **DM dedup** (see "DM dedup" section) DM if not already sent.
149
+ 1. `acceptInvitation` with `invitationId` and `sharedSecret`
150
+ 2. If campaign matched: `visitProfile` with matched `campaignSlug`, then **DM dedup** → DM if not already sent.
151
+ 3. If no campaign match (organic invitation): accept only, no DM.
102
152
 
103
153
  ### Script 3 — New connections
104
154
 
105
- Check if distance 2+ commenters have now connected.
155
+ For each campaign (from config): check if distance 2+ commenters have now connected.
106
156
 
107
157
  - Re-fetch commenters: `collectComments` with `campaignSlug` — paginate all
108
158
  - Filter: `actionsCompleted.message === false` and `knownDistance > 1` (skip profiles where `knownDistance` is null — they haven't been visited yet and belong to Script 1)
@@ -122,9 +172,9 @@ Check in order. If either says "already sent", skip the DM.
122
172
 
123
173
  **Layer 1 — `actionsCompleted.message`** (free, within campaign). Already checked in the script loop — `true` → skip.
124
174
 
125
- **Layer 2 — `/find` inbox lookup** (0 credits, cross-campaign). Only when `RESOURCE_LINK` is set. Call `findConversation` with `includeMessages: true` → `{ found, messages }`. `messages` is at the **top level**, not nested. If any message text contains `RESOURCE_LINK`: skip DM, call `syncActions` to mark done. If found but no link in messages: pass `messages` as context for DM tone adaptation. The function must return both skip decision and messages.
175
+ **Layer 2 — `/find` inbox lookup** (0 credits, cross-campaign). Only when `resourceLink` is set. Call `findConversation` with `includeMessages: true` → `{ found, messages }`. `messages` is at the **top level**, not nested. If any message text contains `resourceLink`: skip DM, call `syncActions` to mark done. If found but no link in messages: pass `messages` as context for DM tone adaptation. The function must return both skip decision and messages.
126
176
 
127
- **Fail-safe**: if `findConversation` errors or all retries fail, **skip this profile** — do NOT send the DM. A failed lookup treated as "not found" causes duplicates. The profile will be retried next cron run.
177
+ **Fail-safe**: if `findConversation` errors or all retries fail, **skip this profile** — do NOT send the DM. A failed lookup treated as "not found" causes duplicates. The profile will be retried next run.
128
178
 
129
179
  ## Language
130
180
 
@@ -134,21 +184,21 @@ For lead magnet, the default language is the **post language** — everyone comm
134
184
 
135
185
  - Distance 1: acknowledge, tell them you're sending it / it's in their DMs
136
186
  - Distance 2+: tell them to connect with you to receive it. Never mention DMs.
137
- - Language = post language (see above). The `generateReply()` function should accept a `language` parameter and maintain separate pools per language (at minimum FR and EN).
187
+ - Use `commentExamples.distance1` and `commentExamples.distance2` from config if present; else generate from language (at least 5 per distance per language).
138
188
  - Short, casual, 3-5 words.
139
- - Use random selection over a pool of varied templates (at least 5 per distance per language). Vary phrasing and tone to avoid LinkedIn spam detection.
189
+ - Random selection over the pool. Vary phrasing and tone to avoid LinkedIn spam detection.
140
190
 
141
191
  ## DM content
142
192
 
143
- - Use `DM_TEMPLATE` as the **base pattern** same meaning, same `{link}`, but **always randomize phrasing**. Generate at least 10 variations of the template at boot (different wording, different greeting, different emoji, different sentence structure). Pick a random variation for each DM. Never send the same literal text twice in a row.
144
- - Language = post language.
145
- - **When conversation history exists** (DM guard fetched messages, no `RESOURCE_LINK` found): adapt the tone — more casual, shorter. The conversation messages are context.
193
+ - Use `dmExamples` from config if present (random pick); else generate at least 10 variations from `dmTemplate` at boot (different wording, greeting, emoji, sentence structure). Pick a random variation for each DM. Never send the same literal text twice in a row.
194
+ - Language = post language (from config or detect from post).
195
+ - **When conversation history exists** (DM guard fetched messages, no `resourceLink` found): adapt the tone — more casual, shorter. The conversation messages are context.
146
196
 
147
197
  ## Time window
148
198
 
149
- Scripts only run between **7:00 and 23:00** in the user's timezone (default). At the start of each run, check the current local time. If outside the window, exit immediately with a short log ("Outside active hours, skipping"). The user can override the window if they ask.
199
+ Scripts only run between **7:00 and 23:00** in the user's timezone (default). Use `activeHours` from config if present; else default 07:00–23:00. At the start of each run (per campaign), check the current local time. If outside the window, skip that campaign with a short log ("Outside active hours, skipping").
150
200
 
151
- Detect timezone from `getLinkedInProfile` → `location` (e.g. "Paris, France" → Europe/Paris). If ambiguous, ask the user once and store it in the campaign state file.
201
+ Use `timezone` from config if present; else detect from `getLinkedInProfile` → `location` (e.g. "Paris, France" → Europe/Paris). If ambiguous, ask the user once and store in the campaign config.
152
202
 
153
203
  ## Pacing
154
204
 
@@ -156,21 +206,21 @@ Follow pacing rules from the main skill (Constraints #2). No exceptions — slee
156
206
 
157
207
  ## Cron
158
208
 
159
- Three independent crons per campaign. All run every 1h by default. Cron IDs must include the campaign slug to avoid collisions when running multiple campaigns.
209
+ Three global crons. Config at `~/.openclaw/lead-magnet.json`. Each cron runs one script that loops over all enabled campaigns.
160
210
 
161
211
  All crons MUST use `"sessionTarget": "spawn"` to avoid blocking the main session. Each spawned sub-agent runs the script independently and pushes the recap back to the user when done.
162
212
 
163
213
  ```json
164
- { "id": "lm-{slug}-comments", "every": "1h", "skill": "bereach", "sessionTarget": "spawn", "prompt": "Run lm-{slug}-comments.ts and report recap" }
165
- { "id": "lm-{slug}-invitations", "every": "1h", "skill": "bereach", "sessionTarget": "spawn", "prompt": "Run lm-{slug}-invitations.ts and report recap" }
166
- { "id": "lm-{slug}-connections", "every": "1h", "skill": "bereach", "sessionTarget": "spawn", "prompt": "Run lm-{slug}-connections.ts and report recap" }
214
+ { "id": "lm-comments", "every": "1h", "skill": "bereach", "sessionTarget": "spawn", "prompt": "Run lm-comments.ts and report recap" }
215
+ { "id": "lm-invitations", "every": "1h", "skill": "bereach", "sessionTarget": "spawn", "prompt": "Run lm-invitations.ts and report recap" }
216
+ { "id": "lm-connections", "every": "1h", "skill": "bereach", "sessionTarget": "spawn", "prompt": "Run lm-connections.ts and report recap" }
167
217
  ```
168
218
 
169
- Each script acquires a file lock scoped to campaign + script before running. If the lock is held (previous run still active), exit immediately — do NOT queue or wait.
219
+ Each script acquires a per-campaign lock (`lm-{slug}-comments`, etc.) before running that campaign. If the lock is held (previous run still active), skip that campaign — do NOT queue or wait.
170
220
 
171
221
  ### Pause / Resume
172
222
 
173
- The user can pause or resume any script independently. When multiple campaigns are active, each campaign's scripts are paused/resumed separately.
223
+ The user can pause or resume any campaign by setting `enabled: false` in `~/.bereach/lead-magnet.json` for that campaign.
174
224
 
175
225
  ### Recap (print after each run)
176
226
 
@@ -196,5 +246,5 @@ All enabled by default. The user can ask to disable any of these to save credits
196
246
  - **Likes** on comments (saves 1 credit/comment)
197
247
  - **Connection re-checks** (saves visit + DM credits)
198
248
  - **Comment replies** (saves 1 credit/comment)
199
- - **DM guard** — conversation lookup before DMing (0 credits, auto-disabled when no `RESOURCE_LINK`)
249
+ - **DM guard** — conversation lookup before DMing (0 credits, auto-disabled when no `resourceLink`)
200
250
  - **Reply guard** — hasReplyFromPostAuthor check (free, but can be skipped if user wants to re-reply)
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Tool definitions generated from the BeReach OpenAPI spec.
3
3
  *
4
- * Source of truth: apps/web2/apps/web/src/lib/openapi/schemas/linkedin.ts
5
- * apps/web2/apps/web/src/lib/openapi/schemas/campaigns.ts
4
+ * Source of truth: apps/web/src/lib/openapi/schemas/linkedin.ts
5
+ * apps/web/src/lib/openapi/schemas/campaigns.ts
6
6
  *
7
7
  * Each definition maps 1:1 to an OpenAPI endpoint and an SDK method.
8
8
  * Deprecated fields (actionSlug) are excluded.
@@ -1,6 +1,11 @@
1
+ import type { Bereach } from "bereach";
1
2
  import { getClient } from "./../client";
2
3
  import { definitions } from "./definitions";
3
4
 
5
+ type ResourceMap = {
6
+ [K in keyof Bereach as Bereach[K] extends object ? K : never]: Bereach[K];
7
+ };
8
+
4
9
  /**
5
10
  * Registers all 33 BeReach tools with the OpenClaw agent.
6
11
  * Format per https://docs.openclaw.ai/plugins/agent-tools
@@ -15,7 +20,9 @@ export function registerAllTools(api: any) {
15
20
  async execute(_id: string, params: Record<string, unknown>) {
16
21
  const client = getClient();
17
22
  const [resource, method] = def.handler.split(".");
18
- const result = await (client as any)[resource][method](params);
23
+ const res = client[resource as keyof ResourceMap];
24
+ const fn = (res as Record<string, (...args: unknown[]) => Promise<unknown>>)[method];
25
+ const result = await fn(params);
19
26
  return {
20
27
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
21
28
  };
@@ -1,38 +0,0 @@
1
- #!/usr/bin/env tsx
2
- /// <reference types="node" />
3
- /**
4
- * Simulates OpenClaw plugin registration to catch config/trim errors.
5
- * Run: pnpm exec tsx scripts/test-register.ts
6
- * Or: BEREACH_API_KEY=brc_your_key pnpm exec tsx scripts/test-register.ts
7
- */
8
-
9
- import register from "../src/index";
10
-
11
- const apiKey = process.env.BEREACH_API_KEY || "brc_test_placeholder";
12
-
13
- const mockApi = {
14
- pluginConfig: { BEREACH_API_KEY: apiKey },
15
- registerTool: () => {},
16
- registerCommand: () => {},
17
- registerCli: () => {},
18
- };
19
-
20
- console.log("Testing plugin register with config (simulates OpenClaw load)...");
21
- try {
22
- register(mockApi);
23
- console.log("✓ register() completed — no trim/undefined errors");
24
- } catch (err: any) {
25
- console.error("✗ register() failed:", err?.message || err);
26
- process.exit(1);
27
- }
28
-
29
- console.log("\nTesting plugin register WITHOUT config (should load, fail on first use)...");
30
- try {
31
- register({ pluginConfig: undefined, registerTool: () => {}, registerCommand: () => {}, registerCli: () => {} });
32
- console.log("✓ register() completed — plugin loads without config (lazy init)");
33
- } catch (err: any) {
34
- console.error("✗ register() failed with missing config:", err?.message || err);
35
- process.exit(1);
36
- }
37
-
38
- console.log("\n✓ All tests passed");