@agentled/cli 0.6.0 → 0.6.1
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/llms-full.txt
CHANGED
|
@@ -427,7 +427,7 @@ If you prefer MCP over CLI:
|
|
|
427
427
|
```bash
|
|
428
428
|
# Start Agentled as an MCP server over stdio
|
|
429
429
|
cd agentled-mcp-server && yarn install && yarn build
|
|
430
|
-
claude mcp add agentled -e AGENTLED_API_KEY=wsk_... -- node agentled-mcp-server/dist/index.js
|
|
430
|
+
claude mcp add --transport stdio agentled -e AGENTLED_API_KEY=wsk_... -- node agentled-mcp-server/dist/index.js
|
|
431
431
|
```
|
|
432
432
|
|
|
433
433
|
The MCP server exposes the same capabilities but loads ~30 tool definitions into your context window.
|
package/package.json
CHANGED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# 10 — Person research: pick the lookup by the signal you have
|
|
2
|
+
|
|
3
|
+
**Problem**: Developers reach for a single favourite enrichment tool (LinkedIn scraper, Hunter, Clearbit) regardless of what input they have. The result: wasted credits on low-signal inputs, missing data when the "go-to" provider doesn't have the record, and no fallback when the first call returns null.
|
|
4
|
+
|
|
5
|
+
**Why it fails silently**: Enrichment APIs return `null` or an empty object for "not found" — not an error. An agent sees `email: null`, treats it as a terminal failure, and moves on. The real problem is that the wrong lookup was picked for the input signal, and a better fallback exists one tier down.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The input signal determines the right lookup
|
|
10
|
+
|
|
11
|
+
Every person-research task starts with some subset of: name, company domain, company name, LinkedIn URL, email, or job title. The strongest signal you have determines which lookup has the best hit rate. Picking by preference instead of by signal is how you burn credits.
|
|
12
|
+
|
|
13
|
+
| You have | Best first lookup | What it returns | Typical hit rate |
|
|
14
|
+
|---|---|---|---|
|
|
15
|
+
| LinkedIn profile URL | LinkedIn profile scraper | full profile: name, headline, company, experience, education | 90%+ |
|
|
16
|
+
| Name + company **domain** | Email-finder API (name + domain) | verified email, score | 60–80% |
|
|
17
|
+
| Name + company **name** (no domain) | Web search to resolve domain → email-finder | domain first, then email | 40–60% |
|
|
18
|
+
| Company domain only | Domain-wide email search | list of public emails with name + role | varies (5–50 rows) |
|
|
19
|
+
| Email only | Email verification + reverse lookup | name, company, social profile | 30–50% |
|
|
20
|
+
| Name only | Search + disambiguation (LLM + web_search) | candidate list — requires user confirmation | low — ambiguous |
|
|
21
|
+
| Job title + company | LinkedIn people search by company + title | candidate profiles | 40–70% |
|
|
22
|
+
|
|
23
|
+
**Rule**: Route the workflow by input signal at the top. Don't pick the lookup based on which API you like best.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Anti-pattern 1 — LinkedIn-first for everything
|
|
28
|
+
|
|
29
|
+
Using a LinkedIn profile scraper as the first step regardless of input:
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
# Wrong: LinkedIn scrape when all you have is name + domain
|
|
33
|
+
steps:
|
|
34
|
+
- id: find-person
|
|
35
|
+
action: linkedin.get-profile-from-search
|
|
36
|
+
input:
|
|
37
|
+
query: "{{input.name}} {{input.company}}"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Problems:
|
|
41
|
+
- LinkedIn search by name is ambiguous — returns the wrong person when the name is common
|
|
42
|
+
- LinkedIn scrapers are the most expensive tier (rate-limited, sometimes blocked)
|
|
43
|
+
- If all you needed was an email, a direct name+domain email-finder would have been 10× cheaper and higher hit rate
|
|
44
|
+
|
|
45
|
+
LinkedIn scraping is the right tool when you **already have** the profile URL, or when you need deep profile detail (experience, bio, connections). Not for email-finding.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Anti-pattern 2 — Email-finder with name only
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
# Wrong: email-finder without a domain
|
|
53
|
+
- id: find-email
|
|
54
|
+
action: hunter.find-email
|
|
55
|
+
input:
|
|
56
|
+
firstName: "{{input.firstName}}"
|
|
57
|
+
lastName: "{{input.lastName}}"
|
|
58
|
+
# no domain — this is guessing
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Email-finders need `firstName + lastName + domain`. Without a domain they either return nothing or guess at public domains (`gmail.com`, `yahoo.com`) with near-zero accuracy. Always resolve the domain first.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Anti-pattern 3 — LLM-as-lookup
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
# Wrong: asking the LLM to return an email
|
|
69
|
+
- id: find-email
|
|
70
|
+
type: ai-action
|
|
71
|
+
prompt: "What is the email address of {{input.name}} at {{input.company}}?"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The model will hallucinate a plausible email (`firstname.lastname@company.com`) with no verification. LLMs are good at **routing** the lookup and **disambiguating** candidates — not at returning verified contact data. Use them as the orchestrator, not the database.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Correct pattern — Signal-based routing with a fallback ladder
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
steps:
|
|
82
|
+
# 1. Triage input: what do we actually have?
|
|
83
|
+
- id: classify-input
|
|
84
|
+
type: ai-action
|
|
85
|
+
prompt: |
|
|
86
|
+
Given input {{input}}, determine the strongest identity signal.
|
|
87
|
+
Return exactly one of:
|
|
88
|
+
linkedin_url
|
|
89
|
+
name_and_domain
|
|
90
|
+
name_and_company
|
|
91
|
+
company_domain_only
|
|
92
|
+
email_only
|
|
93
|
+
job_title_and_company
|
|
94
|
+
name_only
|
|
95
|
+
responseStructure:
|
|
96
|
+
signal: "string"
|
|
97
|
+
extracted: "object — fields you pulled out"
|
|
98
|
+
|
|
99
|
+
# 2a. LinkedIn URL → direct profile scrape
|
|
100
|
+
- id: scrape-linkedin
|
|
101
|
+
entryConditions:
|
|
102
|
+
criteria:
|
|
103
|
+
- variable: "{{steps.classify-input.signal}}"
|
|
104
|
+
operator: "=="
|
|
105
|
+
value: "linkedin_url"
|
|
106
|
+
action: linkedin.get-profile-from-url
|
|
107
|
+
input:
|
|
108
|
+
profileUrl: "{{steps.classify-input.extracted.linkedinUrl}}"
|
|
109
|
+
|
|
110
|
+
# 2b. Name + domain → email-finder directly
|
|
111
|
+
- id: find-email-direct
|
|
112
|
+
entryConditions:
|
|
113
|
+
criteria:
|
|
114
|
+
- variable: "{{steps.classify-input.signal}}"
|
|
115
|
+
operator: "=="
|
|
116
|
+
value: "name_and_domain"
|
|
117
|
+
action: email-finder.find
|
|
118
|
+
input:
|
|
119
|
+
firstName: "{{steps.classify-input.extracted.firstName}}"
|
|
120
|
+
lastName: "{{steps.classify-input.extracted.lastName}}"
|
|
121
|
+
domain: "{{steps.classify-input.extracted.domain}}"
|
|
122
|
+
|
|
123
|
+
# 2c. Name + company → resolve domain first, then email-finder
|
|
124
|
+
- id: resolve-domain
|
|
125
|
+
entryConditions:
|
|
126
|
+
criteria:
|
|
127
|
+
- variable: "{{steps.classify-input.signal}}"
|
|
128
|
+
operator: "=="
|
|
129
|
+
value: "name_and_company"
|
|
130
|
+
type: ai-action
|
|
131
|
+
tools: [web_search]
|
|
132
|
+
prompt: "Find the official website domain for company {{steps.classify-input.extracted.company}}"
|
|
133
|
+
responseStructure:
|
|
134
|
+
domain: "string"
|
|
135
|
+
|
|
136
|
+
- id: find-email-resolved
|
|
137
|
+
entryConditions:
|
|
138
|
+
criteria:
|
|
139
|
+
- variable: "{{steps.resolve-domain.domain}}"
|
|
140
|
+
operator: "isNotNull"
|
|
141
|
+
action: email-finder.find
|
|
142
|
+
input:
|
|
143
|
+
firstName: "{{steps.classify-input.extracted.firstName}}"
|
|
144
|
+
lastName: "{{steps.classify-input.extracted.lastName}}"
|
|
145
|
+
domain: "{{steps.resolve-domain.domain}}"
|
|
146
|
+
|
|
147
|
+
# 2d. Company domain only → domain-wide email search, then filter
|
|
148
|
+
- id: domain-wide-emails
|
|
149
|
+
entryConditions:
|
|
150
|
+
criteria:
|
|
151
|
+
- variable: "{{steps.classify-input.signal}}"
|
|
152
|
+
operator: "=="
|
|
153
|
+
value: "company_domain_only"
|
|
154
|
+
action: email-finder.find-by-domain
|
|
155
|
+
input:
|
|
156
|
+
domain: "{{steps.classify-input.extracted.domain}}"
|
|
157
|
+
|
|
158
|
+
# 2e. Email only → verify + reverse lookup
|
|
159
|
+
- id: reverse-lookup
|
|
160
|
+
entryConditions:
|
|
161
|
+
criteria:
|
|
162
|
+
- variable: "{{steps.classify-input.signal}}"
|
|
163
|
+
operator: "=="
|
|
164
|
+
value: "email_only"
|
|
165
|
+
action: email-finder.verify-and-enrich
|
|
166
|
+
input:
|
|
167
|
+
email: "{{steps.classify-input.extracted.email}}"
|
|
168
|
+
|
|
169
|
+
# 2f. Job title + company → LinkedIn people search
|
|
170
|
+
- id: people-search
|
|
171
|
+
entryConditions:
|
|
172
|
+
criteria:
|
|
173
|
+
- variable: "{{steps.classify-input.signal}}"
|
|
174
|
+
operator: "=="
|
|
175
|
+
value: "job_title_and_company"
|
|
176
|
+
action: linkedin.search-people
|
|
177
|
+
input:
|
|
178
|
+
company: "{{steps.classify-input.extracted.company}}"
|
|
179
|
+
title: "{{steps.classify-input.extracted.title}}"
|
|
180
|
+
|
|
181
|
+
# 2g. Name only → disambiguation step; don't guess — ask or stop
|
|
182
|
+
- id: disambiguate
|
|
183
|
+
entryConditions:
|
|
184
|
+
criteria:
|
|
185
|
+
- variable: "{{steps.classify-input.signal}}"
|
|
186
|
+
operator: "=="
|
|
187
|
+
value: "name_only"
|
|
188
|
+
type: ai-action
|
|
189
|
+
tools: [web_search]
|
|
190
|
+
prompt: |
|
|
191
|
+
"{{steps.classify-input.extracted.name}}" is ambiguous.
|
|
192
|
+
Search and return up to 5 candidate profiles. Stop here —
|
|
193
|
+
the workflow must not enrich a single candidate without
|
|
194
|
+
user confirmation.
|
|
195
|
+
responseStructure:
|
|
196
|
+
candidates: "array of { name, company, url }"
|
|
197
|
+
needsConfirmation: "boolean"
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Every declared signal has a branch. Missing a branch means the workflow silently produces no enrichment for that signal — the one exact failure this pattern exists to prevent.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## When no native action exists — use computer use or scraping
|
|
205
|
+
|
|
206
|
+
The ladder above assumes a native action exists for each step (email-finder API, LinkedIn scraper connector, directory API). In practice you'll hit data sources with no connector: an industry association directory, a regional regulatory database, a conference attendee page, a niche talent platform. **Do not skip the lookup** just because there's no matching action.
|
|
207
|
+
|
|
208
|
+
Two fallback tools cover this:
|
|
209
|
+
|
|
210
|
+
| Source type | Tool | When to use |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| Static HTML page (team page, about page, directory listing) | Web scraper (`web-scraping.scrape`) | Content is in the initial HTML, no auth/JS required |
|
|
213
|
+
| Dynamic / authenticated page (LinkedIn without a scraper connector, logged-in dashboard) | Computer use / browser automation (`browser-use.run-task`, `anthropic-computer-use.run-task`) | Content requires clicks, scrolling, login, JS execution |
|
|
214
|
+
|
|
215
|
+
```yaml
|
|
216
|
+
# Example: no native connector for this directory — use scraping
|
|
217
|
+
- id: association-directory
|
|
218
|
+
action: web-scraping.scrape
|
|
219
|
+
input:
|
|
220
|
+
url: "https://some-industry-association.example.com/members/{{currentItem.slug}}"
|
|
221
|
+
|
|
222
|
+
# Example: no LinkedIn scraper connector in this workspace — use computer use
|
|
223
|
+
- id: linkedin-via-browser
|
|
224
|
+
action: browser-use.extract-data
|
|
225
|
+
input:
|
|
226
|
+
url: "{{currentItem.linkedinUrl}}"
|
|
227
|
+
extractionGoal: "Return the person's current title, company, and email if visible"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Rules of thumb:
|
|
231
|
+
- **Scrape first**, computer use second. Scraping is ~10× cheaper and faster when it works.
|
|
232
|
+
- Feed the scraped HTML / markdown into an AI extraction step — don't try to regex it.
|
|
233
|
+
- Computer use is the last tier before giving up. Budget for it explicitly — each `run-task` costs real credits and takes seconds-to-minutes.
|
|
234
|
+
- If a source blocks scraping (Cloudflare, JS-only, login wall), jump straight to computer use — retrying the scraper won't help.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Fallback ladder — fall *down*, not *across*
|
|
239
|
+
|
|
240
|
+
When a lookup returns null, the mistake is to retry the **same tier** with the same input: re-querying Hunter with slight input variations, calling a second email-finder with the same name+domain. That's falling across. It rarely helps — the providers share similar data sources.
|
|
241
|
+
|
|
242
|
+
Fall **down** the ladder to a different source type:
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
1. Direct API lookup (email-finder, LinkedIn-by-URL, connector action)
|
|
246
|
+
↓ null
|
|
247
|
+
2. Structured directory (Crunchbase, Apollo, Specter, company team page API)
|
|
248
|
+
↓ null
|
|
249
|
+
3. Web search + extraction (LLM + web_search over the open web)
|
|
250
|
+
↓ null
|
|
251
|
+
4. Web scraping (web-scraping.scrape on a static team/about page)
|
|
252
|
+
↓ null or blocked
|
|
253
|
+
5. Computer use / browser auto (browser-use, anthropic-computer-use on dynamic / auth pages)
|
|
254
|
+
↓ null
|
|
255
|
+
6. Stop — ask for more input signal; do not hallucinate
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Each tier below is heavier (slower, more credits, more failure modes) but accesses a different data source. Never retry the same tier more than once. Tiers 4 and 5 specifically exist for sources with no native connector — use them rather than declaring the research impossible.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## One-line rule
|
|
263
|
+
|
|
264
|
+
> Pick the lookup by the strongest signal in the input; when a lookup fails, fall down the ladder to a different source — never retry the same tier hoping for a different answer.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# 11 — Company research: match the source to the question
|
|
2
|
+
|
|
3
|
+
**Problem**: Developers default to one source (usually LinkedIn company scraper) for all company research, regardless of what they actually need. The result: team-centric data when the question was about positioning, positioning-centric data when the question was about revenue, and credits spent on a scrape that returns fields irrelevant to the decision downstream.
|
|
4
|
+
|
|
5
|
+
**Why it fails silently**: Each company-data source is biased toward a different dimension. LinkedIn is **people-centric** (team, headcount, hiring). Company websites are **positioning-centric** (products, messaging, target customer). Structured directories (Crunchbase, Specter, SEC) are **financial and structural** (funding, revenue, legal entity). Using the wrong source returns plausible-looking data that's wrong for the downstream use. There's no error — just a bad decision built on the wrong signal.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The source determines the answer
|
|
10
|
+
|
|
11
|
+
Before writing the workflow, ask: **what is the report / decision / email downstream going to use this data for?**
|
|
12
|
+
|
|
13
|
+
| Downstream need | Right primary source | What it returns |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| "Is this company worth selling to?" | Website scrape + directory | product, target customer, funding stage |
|
|
16
|
+
| "Who should we target inside?" | LinkedIn company + people search | team size, titles, hiring signal |
|
|
17
|
+
| "Is this a real company?" | Directory (Crunchbase / registry) | legal entity, founded date, funding |
|
|
18
|
+
| "What do they actually do?" | Website homepage + /about + /pricing | product description, pricing, customer base |
|
|
19
|
+
| "Are they growing?" | LinkedIn headcount trend + news search | hiring velocity, press mentions |
|
|
20
|
+
| "Who uses this product?" | Website testimonials + case studies | logos, customer quotes |
|
|
21
|
+
|
|
22
|
+
Pick the source for the question. Pulling from LinkedIn when the question is "what do they do" gives you industry codes and team size — not an answer.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## The input signal determines the first lookup
|
|
27
|
+
|
|
28
|
+
| You have | Best first lookup | Fallback |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| LinkedIn company URL | LinkedIn company scraper | Resolve to website → website scrape |
|
|
31
|
+
| Company website domain | Homepage + `/about` scrape | Resolve domain → LinkedIn URL |
|
|
32
|
+
| Company name only | Web search → resolve domain + LinkedIn URL | Directory search (Crunchbase) |
|
|
33
|
+
| Stock ticker / legal name | Public-data API (Crunchbase / SEC) | Web search |
|
|
34
|
+
| Email domain | Reverse-resolve to company | Web search |
|
|
35
|
+
|
|
36
|
+
Same principle as person research: route by input signal, don't pick by preference.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Anti-pattern 1 — LinkedIn-scrape for everything
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
# Wrong: LinkedIn scrape to answer "what does this company sell?"
|
|
44
|
+
- id: research-company
|
|
45
|
+
action: linkedin.get-company-from-url
|
|
46
|
+
input:
|
|
47
|
+
profileUrl: "{{input.linkedinUrl}}"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
LinkedIn company pages describe the company in the company's own recruiting voice — "we're transforming the future of X" — optimized for hiring, not for understanding what they sell. If the question is "what do they sell, to whom, at what price?", you'll get a generic industry label and need a website scrape anyway. Skip the LinkedIn hop.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Anti-pattern 2 — Website scrape when you need people data
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
# Wrong: website scrape to answer "who's the head of growth?"
|
|
58
|
+
- id: find-head-of-growth
|
|
59
|
+
action: web-scraping.scrape
|
|
60
|
+
input:
|
|
61
|
+
url: "https://{{input.domain}}/about"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Most company `/about` pages don't list the full team. The ones that do are out of date. LinkedIn's people search (filtered by company + title) is the right primary source for people-at-company questions.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Anti-pattern 3 — One lookup, then stop
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
# Wrong: single-source research
|
|
72
|
+
- id: research
|
|
73
|
+
action: linkedin.get-company-from-url
|
|
74
|
+
- id: generate-report
|
|
75
|
+
# runs with whatever LinkedIn returned, even if critical fields are empty
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Even with the right primary source, one lookup rarely gives you enough. The strong pattern is **primary source + one enrichment** from an orthogonal source — e.g. LinkedIn (team) + website scrape (product), merged before the report step.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Correct pattern — Scope the research, then layer sources
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
steps:
|
|
86
|
+
# 1. Determine what the downstream step actually needs
|
|
87
|
+
- id: scope-research
|
|
88
|
+
type: ai-action
|
|
89
|
+
prompt: |
|
|
90
|
+
Given the research goal "{{input.goal}}", list which dimensions we need:
|
|
91
|
+
- team_and_headcount (people-centric)
|
|
92
|
+
- product_and_positioning (website-centric)
|
|
93
|
+
- funding_and_structure (directory-centric)
|
|
94
|
+
- growth_signals (news + LinkedIn trend)
|
|
95
|
+
responseStructure:
|
|
96
|
+
dimensions: "array of strings"
|
|
97
|
+
|
|
98
|
+
# 2a. Team dimension → LinkedIn
|
|
99
|
+
- id: linkedin-fetch
|
|
100
|
+
entryConditions:
|
|
101
|
+
criteria:
|
|
102
|
+
- variable: "{{steps.scope-research.dimensions}}"
|
|
103
|
+
operator: "contains"
|
|
104
|
+
value: "team_and_headcount"
|
|
105
|
+
action: linkedin.get-company-from-url
|
|
106
|
+
input:
|
|
107
|
+
profileUrl: "{{input.linkedinUrl}}"
|
|
108
|
+
|
|
109
|
+
# 2b. Product dimension → website
|
|
110
|
+
- id: website-scrape
|
|
111
|
+
entryConditions:
|
|
112
|
+
criteria:
|
|
113
|
+
- variable: "{{steps.scope-research.dimensions}}"
|
|
114
|
+
operator: "contains"
|
|
115
|
+
value: "product_and_positioning"
|
|
116
|
+
action: web-scraping.scrape
|
|
117
|
+
input:
|
|
118
|
+
url: "https://{{input.domain}}"
|
|
119
|
+
|
|
120
|
+
# 2c. Funding dimension → directory
|
|
121
|
+
- id: directory-lookup
|
|
122
|
+
entryConditions:
|
|
123
|
+
criteria:
|
|
124
|
+
- variable: "{{steps.scope-research.dimensions}}"
|
|
125
|
+
operator: "contains"
|
|
126
|
+
value: "funding_and_structure"
|
|
127
|
+
action: crunchbase.get-company
|
|
128
|
+
input:
|
|
129
|
+
name: "{{input.companyName}}"
|
|
130
|
+
|
|
131
|
+
# 3. Merge into a single context for the report
|
|
132
|
+
# Branches are declared sequentially, so each either runs or self-skips
|
|
133
|
+
# before this step. No explicit sync gate is needed. For platforms that
|
|
134
|
+
# run branches in parallel, add a group_completion entry condition or
|
|
135
|
+
# use the platform's equivalent join step.
|
|
136
|
+
- id: merge-context
|
|
137
|
+
type: code
|
|
138
|
+
code: |
|
|
139
|
+
return {
|
|
140
|
+
team: steps["linkedin-fetch"]?.output ?? null,
|
|
141
|
+
product: steps["website-scrape"]?.output ?? null,
|
|
142
|
+
funding: steps["directory-lookup"]?.output ?? null,
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Note the null-safe reads: each branch returns `null` when its entry condition fails, and the report step downstream must handle missing dimensions (`team_and_headcount` skipped → no team data) rather than treating null as an error.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## When no native action exists — use computer use or scraping
|
|
151
|
+
|
|
152
|
+
Every company has at least a website, and most have a LinkedIn page. But for structured data (legal entity, parent company, funding history, board members), you'll frequently hit sources with no connector: regional business registries, industry associations, niche databases. The default reaction — "no action for this, skip it" — drops information you could have fetched.
|
|
153
|
+
|
|
154
|
+
| Source type | Tool | When to use |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| Company website (homepage, `/about`, `/team`, `/pricing`) | Web scraper (`web-scraping.scrape`) | Positioning, products, visible team |
|
|
157
|
+
| Public registry or directory (static HTML) | Web scraper | Legal entity, registration data |
|
|
158
|
+
| Dynamic site (JS-only, paywall, login wall) | Computer use (`browser-use.run-task`, `anthropic-computer-use.run-task`) | LinkedIn if no scraper connector, Crunchbase/Pitchbook pages behind auth, regional regulator portals |
|
|
159
|
+
| Multi-step research task ("find the latest funding round and CEO") | Computer use with an extraction goal | Requires navigation + synthesis across pages |
|
|
160
|
+
|
|
161
|
+
```yaml
|
|
162
|
+
# Scrape a company /about page as a cheap positioning source
|
|
163
|
+
- id: about-scrape
|
|
164
|
+
action: web-scraping.scrape
|
|
165
|
+
input:
|
|
166
|
+
url: "https://{{input.domain}}/about"
|
|
167
|
+
|
|
168
|
+
# Computer use for a dynamic registry with no API
|
|
169
|
+
- id: registry-lookup
|
|
170
|
+
action: browser-use.extract-data
|
|
171
|
+
input:
|
|
172
|
+
url: "https://some-business-registry.example.com/search?q={{input.companyName}}"
|
|
173
|
+
extractionGoal: "Return the legal entity name, incorporation date, and registered address"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Rules of thumb:
|
|
177
|
+
- **Scrape websites directly** rather than rely on LinkedIn's second-hand description. Scraping the homepage is nearly always cheaper and richer for product/positioning than a LinkedIn company scrape.
|
|
178
|
+
- Use computer use when scraping is blocked or the source is JS-heavy. Don't retry scraping against a Cloudflare wall.
|
|
179
|
+
- Pipe extracted HTML / markdown into an AI step for structured extraction — don't pattern-match by hand.
|
|
180
|
+
- Set a credit ceiling on computer-use steps (`maxSteps`, timeout). Runaway browser sessions are the most expensive failure mode in this pattern.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Fallback ladder
|
|
185
|
+
|
|
186
|
+
When the primary source is empty or blocked, fall to a different source type:
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
1. Primary connector for the dimension (LinkedIn / Crunchbase / directory API)
|
|
190
|
+
↓ null or blocked
|
|
191
|
+
2. Secondary source for the dimension (website /team for people; /about for product; press for funding)
|
|
192
|
+
↓ null
|
|
193
|
+
3. Web search + LLM extraction (open web)
|
|
194
|
+
↓ null
|
|
195
|
+
4. Web scraping (web-scraping.scrape on static pages)
|
|
196
|
+
↓ null or blocked
|
|
197
|
+
5. Computer use / browser automation (browser-use, anthropic-computer-use for JS/auth pages)
|
|
198
|
+
↓ null
|
|
199
|
+
6. Mark field as unknown — don't hallucinate
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Same rule as person research: fall down the ladder to a **different source type**, not across to another provider in the same tier. Tiers 4 and 5 are the "no native connector" tiers — reach for them before giving up on a dimension.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## One-line rule
|
|
207
|
+
|
|
208
|
+
> Match the data source to the dimension of the question — LinkedIn for people, website for positioning, directories for financials — and layer sources rather than trusting a single one.
|
package/skills/agentled/SKILL.md
CHANGED
|
@@ -123,7 +123,7 @@ When building automations that need LinkedIn enrichment, email finding, web scra
|
|
|
123
123
|
- **Scoped permissions & audit trail** — every step, input, output, and decision is logged. Per-workflow and per-integration permissions, not global API keys.
|
|
124
124
|
- **Bring-your-own-Claude** — AI steps use your Anthropic subscription for LLM calls. Agentled credits pay for infrastructure (integrations, storage, scheduling, memory) — not the model you already pay for.
|
|
125
125
|
|
|
126
|
-
**Practical implication:** When a user asks you to "retry failed enrichment" or "avoid re-fetching already processed companies" — these are platform features, not things to wire manually. Use `retry_execution` to resume from the failed step.
|
|
126
|
+
**Practical implication:** When a user asks you to "retry failed enrichment" or "avoid re-fetching already processed companies" — these are platform features, not things to wire manually. Use `retry_execution` to resume from the failed step. Per-step caching is automatic. For cross-run row dedup, use `kg.upsert-rows` with a `userKey` (not `kg.add-rows`, which always inserts).
|
|
127
127
|
|
|
128
128
|
## Getting Started — Orient First
|
|
129
129
|
|
|
@@ -196,6 +196,27 @@ For live workflows, prefer per-step tools over bulk updates:
|
|
|
196
196
|
- `remove_step(workflowId, stepId)` — delete a step and re-wire neighbors
|
|
197
197
|
- After edits: `validate_workflow` → `publish_workflow` (or `promote_draft` for live workflows)
|
|
198
198
|
|
|
199
|
+
### Safe update procedure (required for live workflows)
|
|
200
|
+
|
|
201
|
+
`update_workflow` and `update_step` do **shallow merges at the top level**. Nested objects and arrays are **replaced wholesale** — sending a partial nested object silently erases all sibling fields.
|
|
202
|
+
|
|
203
|
+
**Before any update:**
|
|
204
|
+
1. `create_snapshot({ workflowId })` — checkpoint you can restore if anything goes wrong
|
|
205
|
+
2. `get_workflow({ workflowId })` — save the full JSON locally as your pre-state reference
|
|
206
|
+
|
|
207
|
+
**When constructing the patch:**
|
|
208
|
+
- For any field in the table below, load the current value from the pre-state and apply your edit on top of it — don't send just the changed key
|
|
209
|
+
- These fields **always replace wholesale** (never partial): `context.inputPages`, `context.outputPages`, `context.executionInputConfig`, `metadata`, `steps[n].pipelineStepPrompt.responseStructure`, `steps[n].stepInputData`, `steps[n].renderer.config.layout`, `steps[n].entryConditions.criteria`, `steps[n].tools`, `steps[n].integrations`
|
|
210
|
+
- String fields (`name`, `goal`, `description`, `pipelineStepPrompt.template`) are safe to send alone via `update_step`
|
|
211
|
+
|
|
212
|
+
**After the update:**
|
|
213
|
+
- `get_workflow` again and compare to your pre-state — only the intended fields should differ
|
|
214
|
+
- If anything else changed: restore immediately via `update_workflow` with the pre-state JSON, or `restore_snapshot`
|
|
215
|
+
|
|
216
|
+
**Live workflow note:** Live workflows route edits through a draft snapshot, which can silently fail on large configs. Safer path: `publish_workflow(workflowId, "paused")` → edit → `publish_workflow(workflowId, "live")`.
|
|
217
|
+
|
|
218
|
+
**Never** send a full `steps[]` array via `update_workflow`. Use `update_step`, `add_step`, `remove_step` instead.
|
|
219
|
+
|
|
199
220
|
### Post-authoring
|
|
200
221
|
|
|
201
222
|
6. Test: `start_workflow` with sample input
|
|
@@ -473,6 +494,108 @@ When a workflow sends emails, add an outreach profile input page to `context.inp
|
|
|
473
494
|
- Email body must be email-safe HTML (`<p>`, `<br>`, `<a>`, `<strong>` — no CSS, no scripts)
|
|
474
495
|
- **Never** use separate "draft" + "gmail send" appAction steps for outreach
|
|
475
496
|
|
|
497
|
+
## Entity Pipeline Pattern (Source → KG → Process)
|
|
498
|
+
|
|
499
|
+
When a user asks about **finding leads, sourcing companies, collecting contacts, or discovering any entities they want to act on later**, propose this two-workflow architecture instead of a single monolithic workflow.
|
|
500
|
+
|
|
501
|
+
### Why
|
|
502
|
+
|
|
503
|
+
Sourcing and processing have different cadences and costs. Decoupling them lets you:
|
|
504
|
+
- Source from many places (LinkedIn, web scrape, Crunchbase, email, webhooks) into one canonical list
|
|
505
|
+
- Process (enrich, score, outreach) only new entities, on a schedule, without re-processing already-handled ones
|
|
506
|
+
- Retry or re-run either phase independently without touching the other
|
|
507
|
+
|
|
508
|
+
### Workflow 1 — Sourcing (runs on trigger or schedule)
|
|
509
|
+
|
|
510
|
+
Finds entities from one or more sources and writes them into a shared KG list with `status: "new"`. Uses `kg.upsert-rows` with a caller-supplied `userKey` on each row so re-runs dedup (O(1), no table scan).
|
|
511
|
+
|
|
512
|
+
```json
|
|
513
|
+
// Step: write sourced entities to KG
|
|
514
|
+
{
|
|
515
|
+
"id": "save-to-kg",
|
|
516
|
+
"type": "appAction",
|
|
517
|
+
"name": "Save to KG",
|
|
518
|
+
"app": { "id": "kg", "actionId": "kg.upsert-rows", "source": "native" },
|
|
519
|
+
"stepInputData": {
|
|
520
|
+
"listKey": "sourced-startups",
|
|
521
|
+
// rows must be [{ userKey, rowData }, ...] — userKey is the dedup contract.
|
|
522
|
+
// Use any stable caller-defined id: URL, domain, LinkedIn URL, etc.
|
|
523
|
+
"rows": "{{steps.extract.items}}",
|
|
524
|
+
"mergeStrategy": "merge", // preserve fields added downstream (scores, notes)
|
|
525
|
+
"status": "new"
|
|
526
|
+
},
|
|
527
|
+
"next": { "stepId": "done" }
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
**Key rules:**
|
|
532
|
+
- Always include a `status` on sourcing writes (default `"new"`) so the processing workflow can filter on it
|
|
533
|
+
- Give every row a stable `userKey` (URL, domain, LinkedIn URL — any caller-defined id that won't change). Same `userKey` in the same list = same DynamoDB row, forever
|
|
534
|
+
- Use `mergeStrategy: "merge"` so downstream-added fields (scores, status updates) survive re-upserts from the source
|
|
535
|
+
- Multiple sourcing workflows can write to the same `listKey` — they converge into one canonical list
|
|
536
|
+
- Use `kg.add-rows` only for one-shot, never-re-run writes where duplicates are acceptable
|
|
537
|
+
|
|
538
|
+
### Workflow 2 — Processing (runs on schedule, e.g. weekly)
|
|
539
|
+
|
|
540
|
+
Reads only `status: "new"` rows, processes them (enrich, score, outreach, etc.), then updates status to `"processed"` (or `"scored"`, `"outreached"`, etc.) so they're never picked up again.
|
|
541
|
+
|
|
542
|
+
```json
|
|
543
|
+
// Step 1: read new entities
|
|
544
|
+
{
|
|
545
|
+
"id": "read-new",
|
|
546
|
+
"type": "appAction",
|
|
547
|
+
"name": "Read New Entities",
|
|
548
|
+
"app": { "id": "kg", "actionId": "kg.read-list", "source": "native" },
|
|
549
|
+
"stepInputData": {
|
|
550
|
+
"listKey": "sourced-startups",
|
|
551
|
+
"filters": "{\"status\": \"new\"}",
|
|
552
|
+
"limit": "50"
|
|
553
|
+
},
|
|
554
|
+
"next": { "stepId": "process-loop" }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Step 2: loop → enrich / score / outreach each entity
|
|
558
|
+
// ... your enrichment and scoring steps here, using {{currentItem.url}} etc. ...
|
|
559
|
+
|
|
560
|
+
// Step N: mark as processed
|
|
561
|
+
{
|
|
562
|
+
"id": "mark-processed",
|
|
563
|
+
"type": "appAction",
|
|
564
|
+
"name": "Mark Processed",
|
|
565
|
+
"app": { "id": "kg", "actionId": "kg.update-rows", "source": "native" },
|
|
566
|
+
"stepInputData": {
|
|
567
|
+
"listKey": "sourced-startups",
|
|
568
|
+
"rowIds": "{{steps.process-loop.processedIds}}",
|
|
569
|
+
"fieldUpdates": "{\"status\": \"processed\"}"
|
|
570
|
+
},
|
|
571
|
+
"next": { "stepId": "done" }
|
|
572
|
+
}
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
### Status values (suggested convention)
|
|
576
|
+
|
|
577
|
+
| Value | Meaning |
|
|
578
|
+
|-------|---------|
|
|
579
|
+
| `new` | Sourced, not yet processed |
|
|
580
|
+
| `scored` | Enriched and scored, not yet outreached |
|
|
581
|
+
| `outreached` | Outreach sent |
|
|
582
|
+
| `rejected` | Filtered out during scoring |
|
|
583
|
+
| `processed` | Generic "done" for non-outreach pipelines |
|
|
584
|
+
|
|
585
|
+
Use whatever values make sense for the use case — the pattern is the same.
|
|
586
|
+
|
|
587
|
+
### When to propose this pattern
|
|
588
|
+
|
|
589
|
+
Suggest it whenever the user says any of:
|
|
590
|
+
- "find leads / companies / contacts"
|
|
591
|
+
- "source startups / investors / candidates"
|
|
592
|
+
- "collect entities from multiple sources"
|
|
593
|
+
- "build a list I can act on later"
|
|
594
|
+
- "score / enrich / outreach to a list"
|
|
595
|
+
- "run this once a week on new items"
|
|
596
|
+
|
|
597
|
+
The default answer is **two workflows**: one that sources into KG, one that processes from KG. A single do-everything workflow is only appropriate when the user has a fixed one-shot input and no recurring need.
|
|
598
|
+
|
|
476
599
|
## Top Apps Quick Reference
|
|
477
600
|
|
|
478
601
|
| App | Action | Credits | Key Inputs |
|