@synap-core/workspace-templates 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -115
- package/agents/chatgpt.agent.yaml +13 -0
- package/agents/claude-code.agent.yaml +24 -0
- package/agents/claude-desktop.agent.yaml +13 -0
- package/agents/codex.agent.yaml +21 -0
- package/agents/cursor.agent.yaml +15 -0
- package/agents/discord.agent.yaml +13 -0
- package/agents/generic.agent.yaml +13 -0
- package/agents/goose.agent.yaml +13 -0
- package/agents/grok.agent.yaml +13 -0
- package/agents/hermes.agent.yaml +18 -0
- package/agents/opencode.agent.yaml +13 -0
- package/agents/openwebui.agent.yaml +13 -0
- package/agents/proton-agent.agent.yaml +13 -0
- package/agents/raycast.agent.yaml +13 -0
- package/agents/vscode.agent.yaml +13 -0
- package/agents/windsurf.agent.yaml +13 -0
- package/agents/zed.agent.yaml +13 -0
- package/capabilities/agency-skills.capability.json +1 -1
- package/capabilities/discord-bot.capability.json +2 -2
- package/capabilities/unipile-linkedin.capability.json +23 -1
- package/dist/index.js +159 -2
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/src/catalog-safety.test.ts +1 -1
- package/src/crm-absorbs-outreach.template.test.ts +5 -0
- package/src/crm-find-contact.template.test.ts +121 -0
- package/src/crm.yaml +141 -2
- package/src/lead-outreach-send-door.test.ts +37 -0
- package/src/templates.ts +142 -2
- package/src/unipile-linkedin-find-profile.test.ts +2 -1
- package/src/unipile-lookups-read-only.test.ts +66 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synap-core/workspace-templates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
|
|
6
6
|
"repository": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./skills/*": "./skills/*",
|
|
22
22
|
"./automations/*": "./automations/*",
|
|
23
23
|
"./views/*": "./views/*",
|
|
24
|
+
"./agents/*": "./agents/*",
|
|
24
25
|
"./package.json": "./package.json"
|
|
25
26
|
},
|
|
26
27
|
"files": [
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
"skills",
|
|
31
32
|
"automations",
|
|
32
33
|
"views",
|
|
34
|
+
"agents",
|
|
33
35
|
"retired.yaml"
|
|
34
36
|
],
|
|
35
37
|
"dependencies": {
|
|
@@ -172,7 +172,7 @@ describe("(b) every skill reference resolves", () => {
|
|
|
172
172
|
|
|
173
173
|
// ── (c) the content directories ship, the build reads none of them ─────────
|
|
174
174
|
|
|
175
|
-
const CONTENT_DIRS = ["capabilities", "skills", "automations", "views"];
|
|
175
|
+
const CONTENT_DIRS = ["capabilities", "skills", "automations", "views", "agents"];
|
|
176
176
|
|
|
177
177
|
describe("(c) catalog content ships in the npm bundle; the build reads only src/*.yaml", () => {
|
|
178
178
|
const pkg = JSON.parse(readFileSync(join(PKG, "package.json"), "utf8"));
|
|
@@ -70,7 +70,12 @@ describe("CRM absorbs Outreach & Comms", () => {
|
|
|
70
70
|
["replied", "done", false],
|
|
71
71
|
["bounced", "lost", false],
|
|
72
72
|
["no_response", "lost", false],
|
|
73
|
+
["replaced", "lost", true],
|
|
73
74
|
]);
|
|
75
|
+
// `replaced` closes a draft moved into the person's channel; an agent proposes it.
|
|
76
|
+
expect(touch?.lifecycle?.options.find((o) => o.key === "replaced")?.label).toBe("Replaced");
|
|
77
|
+
const status = touch?.properties?.find((p) => p.slug === "status");
|
|
78
|
+
expect(status?.enumValues).toContain("replaced");
|
|
74
79
|
expect(crm.meta.intent?.produces?.map((p) => p.profile)).toContain("outreach-touch");
|
|
75
80
|
});
|
|
76
81
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Find the right contact" (plan 87088d2d W3b, founder 2026-11-06), pinned
|
|
3
|
+
* against the REAL YAML corpus and the real validator. The CRM app (site-crm
|
|
4
|
+
* W2) codes against these names, agreed between the two lanes:
|
|
5
|
+
*
|
|
6
|
+
* - the playbook is "Find the right contact" (slug find-the-right-contact);
|
|
7
|
+
* - its params are `company` (entity, required) and `audience` (entity,
|
|
8
|
+
* optional); a batch is one session whose targets are the companies;
|
|
9
|
+
* - the company kind carries its LinkedIn page as `linkedinUrl`.
|
|
10
|
+
*
|
|
11
|
+
* Its subject, `company`, declares no lifecycle: no stage moves anything and no
|
|
12
|
+
* gate stops it (every write goes through the pod's governance instead), and
|
|
13
|
+
* the company stays the lead.
|
|
14
|
+
*
|
|
15
|
+
* Does NOT cover what an agent does at run time (the goal is prose).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, it } from "vitest";
|
|
19
|
+
import { readYamlTemplates } from "./generate.mjs";
|
|
20
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
21
|
+
import { validateTemplate } from "./validate.js";
|
|
22
|
+
|
|
23
|
+
const NAME = "Find the right contact";
|
|
24
|
+
|
|
25
|
+
const corpus = readYamlTemplates().map((t) => t.data as WorkspaceYaml);
|
|
26
|
+
const crm = corpus.find((x) => x.meta.slug === "crm")!;
|
|
27
|
+
const profileOf = (slug: string) => crm.profiles.find((p) => p.slug === slug);
|
|
28
|
+
const pb = () => {
|
|
29
|
+
const found = crm.playbooks?.find((p) => p.name === NAME);
|
|
30
|
+
if (!found) throw new Error(`playbook "${NAME}" not in crm`);
|
|
31
|
+
return found;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("CRM — Find the right contact", () => {
|
|
35
|
+
it("crm validates against the corpus", () => {
|
|
36
|
+
expect(validateTemplate(crm, { corpus }).errors).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("the company kind carries its LinkedIn page as linkedinUrl, the identity signal person uses", () => {
|
|
40
|
+
const company = profileOf("company")?.properties.find((p) => p.slug === "linkedinUrl");
|
|
41
|
+
expect(company).toMatchObject({ valueType: "string", inputType: "url" });
|
|
42
|
+
// The same global property as the person's, never a second spelling.
|
|
43
|
+
expect(profileOf("person")?.properties.find((p) => p.slug === "linkedinUrl")).toMatchObject({ valueType: "string" });
|
|
44
|
+
const spellings = (profileOf("company")?.properties ?? []).map((p) => p.slug).filter((s) => /linkedin/i.test(s));
|
|
45
|
+
expect(spellings).toEqual(["linkedinUrl"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("is one CRM playbook, named for the slug the app uses", () => {
|
|
49
|
+
const named = corpus.flatMap((t) => (t.playbooks ?? []).filter((p) => p.name === NAME).map(() => t.meta.slug));
|
|
50
|
+
expect(named).toEqual(["crm"]);
|
|
51
|
+
expect(NAME.toLowerCase().replace(/\s+/g, "-")).toBe("find-the-right-contact");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("takes company (required) and audience (optional), and runs by hand", () => {
|
|
55
|
+
expect((pb().params ?? []).map((p) => [p.name, p.type, p.required ?? false])).toEqual([
|
|
56
|
+
["company", "entity", true],
|
|
57
|
+
["audience", "entity", false],
|
|
58
|
+
]);
|
|
59
|
+
expect(pb().goalTemplate).toMatch(/\{company\}/);
|
|
60
|
+
expect(pb().goalTemplate).toMatch(/\{audience\}/);
|
|
61
|
+
expect(pb().activators).toEqual([{ on: "manual", mode: "propose" }]);
|
|
62
|
+
expect(pb().executor).toBe("is-agent");
|
|
63
|
+
expect(pb().status).toBe("active");
|
|
64
|
+
expect(pb().grants).toEqual(["entity.create"]);
|
|
65
|
+
expect(pb().requiredIntents).toEqual(["find_people", "enrich_entity"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("works a company, which has no lifecycle: no stage moves it and no gate stops it", () => {
|
|
69
|
+
expect(pb().subjectProfile).toEqual({ profileSlug: "company" });
|
|
70
|
+
expect(profileOf("company")).not.toHaveProperty("lifecycle");
|
|
71
|
+
const stages = pb().stages ?? [];
|
|
72
|
+
expect(stages.map((s) => [s.key, s.category])).toEqual([
|
|
73
|
+
["read", "planned"],
|
|
74
|
+
["find", "started"],
|
|
75
|
+
["file", "completed"],
|
|
76
|
+
]);
|
|
77
|
+
expect(stages.filter((s) => s.subjectStatus || s.gate || s.onFail)).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("yields the person picked, linked works_at the company, and may yield nobody", () => {
|
|
81
|
+
expect((pb().stages ?? []).flatMap((s) => s.expectedOutputs ?? [])).toEqual([
|
|
82
|
+
{ kind: "person", label: "Contact picked", relationToSubject: "works_at", optional: true },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("checks its rules with criteria, within the pod's cap", () => {
|
|
87
|
+
const keys = (pb().stages ?? []).flatMap((s) => (s.criteria ?? []).map((c) => c.key));
|
|
88
|
+
expect(keys).toEqual([
|
|
89
|
+
"contact-roles-from-audience",
|
|
90
|
+
"contact-page-clear-match",
|
|
91
|
+
"contact-lookups-capped",
|
|
92
|
+
"contact-current-role",
|
|
93
|
+
"contact-identity-first",
|
|
94
|
+
"contact-linked-homed",
|
|
95
|
+
"contact-company-stays-lead",
|
|
96
|
+
"contact-report",
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("its goal holds the agreed process: roles, at most 5 profiles, identity first, home, reach, the company stays the lead, batch", () => {
|
|
101
|
+
const goal = pb().goalTemplate;
|
|
102
|
+
for (const part of [
|
|
103
|
+
/founder,\s+co-founder, CEO and CTO/,
|
|
104
|
+
/Never invent a role/,
|
|
105
|
+
/linkedin_search_people with keywords "<role> <company name>"/,
|
|
106
|
+
/linkedin_get_profile on at most 5 candidates/,
|
|
107
|
+
/two at most/,
|
|
108
|
+
/current role at THIS company/,
|
|
109
|
+
/resolve identity\s+first by their LinkedIn URL/,
|
|
110
|
+
/only their own fields/,
|
|
111
|
+
/never the company's website, domain or\s+email/,
|
|
112
|
+
/homeWorkspaceId/,
|
|
113
|
+
/POST \/channels\/linkedin-reach\/<personId>/,
|
|
114
|
+
/never set linkedinConnection or\s+linkedinInvitedAt/,
|
|
115
|
+
/never give the person the lead\s+role, never move the company's lead stage/,
|
|
116
|
+
/only on a clear match/,
|
|
117
|
+
/has targets/,
|
|
118
|
+
])
|
|
119
|
+
expect(goal).toMatch(part);
|
|
120
|
+
});
|
|
121
|
+
});
|
package/src/crm.yaml
CHANGED
|
@@ -215,6 +215,15 @@ profiles:
|
|
|
215
215
|
label: Domain
|
|
216
216
|
valueType: string
|
|
217
217
|
inputType: text
|
|
218
|
+
# The company's LinkedIn page (linkedin.com/company/...), written by
|
|
219
|
+
# "Find the right contact" on a clear match only. The pod's global
|
|
220
|
+
# `linkedinUrl` property, the one `person` uses (and an identity signal),
|
|
221
|
+
# so no new definition: the system company kind declares none (plan
|
|
222
|
+
# 87088d2d W3b, 2026-11-06).
|
|
223
|
+
- slug: linkedinUrl
|
|
224
|
+
label: LinkedIn page
|
|
225
|
+
valueType: string
|
|
226
|
+
inputType: url
|
|
218
227
|
- slug: industry
|
|
219
228
|
label: Industry
|
|
220
229
|
valueType: string
|
|
@@ -657,6 +666,11 @@ profiles:
|
|
|
657
666
|
- { key: replied, category: done }
|
|
658
667
|
- { key: bounced, category: lost }
|
|
659
668
|
- { key: no_response, category: lost }
|
|
669
|
+
# A draft whose message now lives in the person's channel as a
|
|
670
|
+
# messaging.send proposal (the one send door, d5): closed, never sent
|
|
671
|
+
# from here (6 Nov 2026). A person's call like every closed touch no
|
|
672
|
+
# process owns: an agent that moves a draft PROPOSES the move.
|
|
673
|
+
- { key: replaced, label: Replaced, category: lost, humanOnly: true }
|
|
660
674
|
properties:
|
|
661
675
|
- slug: channel
|
|
662
676
|
label: Channel
|
|
@@ -674,7 +688,7 @@ profiles:
|
|
|
674
688
|
label: Status
|
|
675
689
|
valueType: string
|
|
676
690
|
inputType: select
|
|
677
|
-
enumValues: [drafted, sent, opened, replied, bounced, no_response]
|
|
691
|
+
enumValues: [drafted, sent, opened, replied, bounced, no_response, replaced]
|
|
678
692
|
constraints: { defaultValue: drafted }
|
|
679
693
|
- slug: sentDate
|
|
680
694
|
label: Sent date
|
|
@@ -1545,6 +1559,128 @@ playbooks:
|
|
|
1545
1559
|
gate: { kind: human }
|
|
1546
1560
|
onFail: { toStage: find }
|
|
1547
1561
|
|
|
1562
|
+
# ── Find the right contact — the decider at a company with none ──────────
|
|
1563
|
+
# Plan 87088d2d W3b (founder, 6 Nov 2026): from a company lead with no contact,
|
|
1564
|
+
# find the right person, link them and home them in the CRM, so "Draft a first
|
|
1565
|
+
# message" has someone to write to. Contract with the CRM app (site-crm W2),
|
|
1566
|
+
# fixed: params `company` (entity, required) and `audience` (entity, optional);
|
|
1567
|
+
# a batch is one session whose targets are the companies.
|
|
1568
|
+
# Subject `company`, which declares no lifecycle (the shape of "Score a list"
|
|
1569
|
+
# on an audience): no stage moves anything, and the company stays the lead
|
|
1570
|
+
# (the person gets no lead role, its leadStage never moves). The LinkedIn
|
|
1571
|
+
# lookups are reads that run on their own (unipile-linkedin readOnly verbs);
|
|
1572
|
+
# every write (the person, works_at, the company's linkedinUrl, the home
|
|
1573
|
+
# space) goes through the pod's doors and its governance, never through a
|
|
1574
|
+
# gate here: the founder is never asked how a write lands. The reach is the
|
|
1575
|
+
# pod's own read (POST /channels/linkedin-reach/<personId>, backend#786), and
|
|
1576
|
+
# the home space is the entity door's governed homeWorkspaceId (backend#871).
|
|
1577
|
+
- name: Find the right contact
|
|
1578
|
+
description: Find the person to contact at a company with no contact; read the roles from its audience, look them up on LinkedIn, pick the best one or two with a reason, and file them linked to the company and homed in the CRM; one company or a batch.
|
|
1579
|
+
goalTemplate: >-
|
|
1580
|
+
Find the right person to contact at the company {company}, for the
|
|
1581
|
+
audience {audience} when one is given. BATCH: when this session also
|
|
1582
|
+
has targets (the companies it works as a batch), do all that follows
|
|
1583
|
+
for each target company, one after the other, and report each one.
|
|
1584
|
+
READ: the company (its name, website, domain and linkedinUrl) and the
|
|
1585
|
+
people the pod already links to it with works_at; when one of them
|
|
1586
|
+
already holds a role the audience names, pick them and search nothing.
|
|
1587
|
+
COMPANY PAGE: when the company has no linkedinUrl, read its website
|
|
1588
|
+
(web.read) and take the LinkedIn company page (linkedin.com/company/...)
|
|
1589
|
+
it links to. Store it as the company's linkedinUrl through the entity
|
|
1590
|
+
door only on a clear match: the company's own website links to it, or
|
|
1591
|
+
its name and its website or domain agree with the company's. Otherwise
|
|
1592
|
+
store nothing and report the page you found. ROLES: read the decision
|
|
1593
|
+
roles the audience names (its persona, segment and traits): the
|
|
1594
|
+
audience given, else the one the company targets (its targets link).
|
|
1595
|
+
When there is no audience or it names no role, look for founder,
|
|
1596
|
+
co-founder, CEO and CTO, in that order. Never invent a role. SEARCH:
|
|
1597
|
+
find the LinkedIn account with unipile_list_accounts (when several are
|
|
1598
|
+
connected, ask the person which one reads). For each role, run
|
|
1599
|
+
linkedin_search_people with keywords "<role> <company name>", then
|
|
1600
|
+
linkedin_get_profile on at most 5 candidates in all, the most likely
|
|
1601
|
+
first. These lookups only read. PICK: the best person for the
|
|
1602
|
+
audience, two at most: a current role at THIS company (in their
|
|
1603
|
+
current position, not a past one or a namesake), the most senior of
|
|
1604
|
+
the roles, the best fit with the audience, and a one-line reason for
|
|
1605
|
+
each. When no candidate passes, pick nobody and say what was searched.
|
|
1606
|
+
FILE, through the pod's doors and its governance: resolve identity
|
|
1607
|
+
first by their LinkedIn URL (synap_resolve_identity with that URL); a
|
|
1608
|
+
strong match is that person, reused, and nothing is created. Otherwise
|
|
1609
|
+
create the person with only their own fields: their name as title,
|
|
1610
|
+
jobTitle (their current title), linkedinUrl, and linkedinProviderId
|
|
1611
|
+
when the profile gives it; never the company's website, domain or
|
|
1612
|
+
email. File a new person and their works_at link to the company as ONE
|
|
1613
|
+
write (synap_capture with the person, the company by existingEntityId
|
|
1614
|
+
and the works_at relation); for a person already in the pod, add the
|
|
1615
|
+
works_at link when it is missing. Home the person in this CRM space:
|
|
1616
|
+
set their homeWorkspaceId to this space with synap_update_entity (on a
|
|
1617
|
+
pod whose entity door has no homeWorkspaceId, file them with this
|
|
1618
|
+
space's workspaceId instead). Then measure their LinkedIn reach through
|
|
1619
|
+
the pod's reach door (POST /channels/linkedin-reach/<personId>), which
|
|
1620
|
+
records it on the person; never set linkedinConnection or
|
|
1621
|
+
linkedinInvitedAt yourself. A write the pod files for review is
|
|
1622
|
+
success: keep going, never retry it, and report it as waiting for
|
|
1623
|
+
review. The company stays the lead: never give the person the lead
|
|
1624
|
+
role, never move the company's lead stage, and never draft or send a
|
|
1625
|
+
message or a connection request. REPORT, for each company: who was
|
|
1626
|
+
picked and why, their reach, whether the company's LinkedIn page was
|
|
1627
|
+
stored, only found or not found, and the next step ("Draft a first
|
|
1628
|
+
message" to the person picked or, when nobody fits, what the person
|
|
1629
|
+
could do instead).
|
|
1630
|
+
subjectProfile:
|
|
1631
|
+
profileSlug: company
|
|
1632
|
+
activators:
|
|
1633
|
+
- { on: manual, mode: propose }
|
|
1634
|
+
params:
|
|
1635
|
+
- { name: company, type: entity, label: Company, required: true }
|
|
1636
|
+
- { name: audience, type: entity, label: Audience (ICP) the company came from }
|
|
1637
|
+
executor: is-agent
|
|
1638
|
+
status: active
|
|
1639
|
+
requiredIntents: [find_people, enrich_entity]
|
|
1640
|
+
grants: [entity.create]
|
|
1641
|
+
stages:
|
|
1642
|
+
- key: read
|
|
1643
|
+
name: Read the company and the roles
|
|
1644
|
+
category: planned
|
|
1645
|
+
goal: Read the company, the people already linked to it and its LinkedIn page, find its page on its website when it has none, and list the roles to look for from the audience (founder, co-founder, CEO and CTO when it names none).
|
|
1646
|
+
criteria:
|
|
1647
|
+
- key: contact-roles-from-audience
|
|
1648
|
+
statement: The roles looked for are the ones the audience names, or founder, co-founder, CEO and CTO when there is no audience or it names none; no role was invented.
|
|
1649
|
+
check: { kind: judge }
|
|
1650
|
+
- key: contact-page-clear-match
|
|
1651
|
+
statement: The company's linkedinUrl was stored only on a clear match (its own website links to the page, or the name and the website or domain agree); otherwise nothing was stored and the page found was reported.
|
|
1652
|
+
check: { kind: judge }
|
|
1653
|
+
- key: find
|
|
1654
|
+
name: Look the people up
|
|
1655
|
+
category: started
|
|
1656
|
+
goal: Search LinkedIn for each role at the company, read at most 5 profiles, and pick the best person (two at most) with a one-line reason each.
|
|
1657
|
+
criteria:
|
|
1658
|
+
- key: contact-lookups-capped
|
|
1659
|
+
statement: linkedin_get_profile ran on at most 5 candidates per company, and only lookup verbs were called on LinkedIn.
|
|
1660
|
+
check: { kind: judge }
|
|
1661
|
+
- key: contact-current-role
|
|
1662
|
+
statement: Each person picked (two at most) holds a current role at this company and has a one-line reason, or nobody was picked and what was searched is said.
|
|
1663
|
+
check: { kind: judge }
|
|
1664
|
+
- key: file
|
|
1665
|
+
name: File the contact
|
|
1666
|
+
category: completed
|
|
1667
|
+
goal: Resolve each person picked by their LinkedIn URL, create them only if new with their own fields, link them works_at the company, home them in this CRM space, measure their reach, and report who, why, their reach and the next step.
|
|
1668
|
+
expectedOutputs:
|
|
1669
|
+
- { kind: person, label: Contact picked, relationToSubject: works_at, optional: true }
|
|
1670
|
+
criteria:
|
|
1671
|
+
- key: contact-identity-first
|
|
1672
|
+
statement: Each person picked was resolved by their LinkedIn URL first; an existing person was reused, and a new one carries only their own fields (never the company's website, domain or email).
|
|
1673
|
+
check: { kind: judge }
|
|
1674
|
+
- key: contact-linked-homed
|
|
1675
|
+
statement: Each person picked works_at the company, is homed in this CRM space and had their LinkedIn reach measured through the pod's reach door, or the write is reported as waiting for review.
|
|
1676
|
+
check: { kind: judge }
|
|
1677
|
+
- key: contact-company-stays-lead
|
|
1678
|
+
statement: The person got no lead role, the company's lead stage did not move, and no message or connection request was drafted or sent.
|
|
1679
|
+
check: { kind: judge }
|
|
1680
|
+
- key: contact-report
|
|
1681
|
+
statement: The report gives, for each company, who was picked and why, their reach, whether its LinkedIn page was stored, only found or not found, and the next step.
|
|
1682
|
+
check: { kind: judge }
|
|
1683
|
+
|
|
1548
1684
|
# ── Follow up on leads in outreach — THE one follow-up process ───────────
|
|
1549
1685
|
# CRM build plan A6 (founder Q4). A batch worker: each weekday it reads the
|
|
1550
1686
|
# channels of the leads in outreach. Due with no reply, it drafts one message
|
|
@@ -1869,7 +2005,10 @@ playbooks:
|
|
|
1869
2005
|
subjectProfile:
|
|
1870
2006
|
profileSlug: outreach-touch
|
|
1871
2007
|
statusProperty: status
|
|
1872
|
-
|
|
2008
|
+
# No stage of this process owns these: drafted and sent come before it
|
|
2009
|
+
# starts, opened is a person's record, replaced closes a draft moved into
|
|
2010
|
+
# the person's channel.
|
|
2011
|
+
humanOnlyStatuses: [drafted, sent, opened, replaced]
|
|
1873
2012
|
activators:
|
|
1874
2013
|
- { on: enters_status, status: sent, mode: run }
|
|
1875
2014
|
params:
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Lead Outreach" (the Discord Bot and Agency Skills packs) drafts through the
|
|
3
|
+
* pod's send door, like every CRM drafter (plan 87088d2d W3a, 6 Nov 2026).
|
|
4
|
+
*
|
|
5
|
+
* Its goal said "PROPOSE sending it (connect + message)" and named no door, so
|
|
6
|
+
* an agent proposed a tool's own send verb or wrote the message into a record:
|
|
7
|
+
* neither shows in CRM Messages, which lists `messaging.send` proposals. The
|
|
8
|
+
* CRM's own drafters are pinned in crm-one-send-door.template.test.ts; these
|
|
9
|
+
* two live in capability packs, which that test does not read.
|
|
10
|
+
*
|
|
11
|
+
* Does NOT cover what an agent does at run time (the goal is prose).
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
|
|
17
|
+
type Playbook = { name: string; goalTemplate: string; description: string };
|
|
18
|
+
const playbook = (pack: string) => {
|
|
19
|
+
const cap = JSON.parse(
|
|
20
|
+
readFileSync(join(import.meta.dirname, "..", "capabilities", `${pack}.capability.json`), "utf8"),
|
|
21
|
+
) as { spec: { playbooks?: Playbook[] } };
|
|
22
|
+
return (cap.spec.playbooks ?? []).find((p) => p.name === "Lead Outreach")!;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe("Lead Outreach drafts through messaging.send", () => {
|
|
26
|
+
it.each(["discord-bot", "agency-skills"])("%s: the first message is filed through messaging.send to the person, never a tool's own send verb", (pack) => {
|
|
27
|
+
const p = playbook(pack);
|
|
28
|
+
expect(p, pack).toBeDefined();
|
|
29
|
+
expect(p.goalTemplate).toMatch(/messaging\.send[^.]*to = the person's entity id/);
|
|
30
|
+
expect(p.goalTemplate).toMatch(/never a tool's own send verb|Never call a tool's own send verb/);
|
|
31
|
+
expect(p.goalTemplate).not.toMatch(/PROPOSE sending it/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("discord-bot: no outreach touch, note or task holds the message", () => {
|
|
35
|
+
expect(playbook("discord-bot").goalTemplate).toMatch(/never create an outreach touch, a note or a task holding the message/);
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/templates.ts
CHANGED
|
@@ -13272,6 +13272,12 @@ const DATA = {
|
|
|
13272
13272
|
"valueType": "string",
|
|
13273
13273
|
"inputType": "text"
|
|
13274
13274
|
},
|
|
13275
|
+
{
|
|
13276
|
+
"slug": "linkedinUrl",
|
|
13277
|
+
"label": "LinkedIn page",
|
|
13278
|
+
"valueType": "string",
|
|
13279
|
+
"inputType": "url"
|
|
13280
|
+
},
|
|
13275
13281
|
{
|
|
13276
13282
|
"slug": "industry",
|
|
13277
13283
|
"label": "Industry",
|
|
@@ -13857,6 +13863,12 @@ const DATA = {
|
|
|
13857
13863
|
{
|
|
13858
13864
|
"key": "no_response",
|
|
13859
13865
|
"category": "lost"
|
|
13866
|
+
},
|
|
13867
|
+
{
|
|
13868
|
+
"key": "replaced",
|
|
13869
|
+
"label": "Replaced",
|
|
13870
|
+
"category": "lost",
|
|
13871
|
+
"humanOnly": true
|
|
13860
13872
|
}
|
|
13861
13873
|
]
|
|
13862
13874
|
},
|
|
@@ -13895,7 +13907,8 @@ const DATA = {
|
|
|
13895
13907
|
"opened",
|
|
13896
13908
|
"replied",
|
|
13897
13909
|
"bounced",
|
|
13898
|
-
"no_response"
|
|
13910
|
+
"no_response",
|
|
13911
|
+
"replaced"
|
|
13899
13912
|
],
|
|
13900
13913
|
"constraints": {
|
|
13901
13914
|
"defaultValue": "drafted"
|
|
@@ -14880,6 +14893,132 @@ const DATA = {
|
|
|
14880
14893
|
}
|
|
14881
14894
|
]
|
|
14882
14895
|
},
|
|
14896
|
+
{
|
|
14897
|
+
"name": "Find the right contact",
|
|
14898
|
+
"description": "Find the person to contact at a company with no contact; read the roles from its audience, look them up on LinkedIn, pick the best one or two with a reason, and file them linked to the company and homed in the CRM; one company or a batch.",
|
|
14899
|
+
"goalTemplate": "Find the right person to contact at the company {company}, for the audience {audience} when one is given. BATCH: when this session also has targets (the companies it works as a batch), do all that follows for each target company, one after the other, and report each one. READ: the company (its name, website, domain and linkedinUrl) and the people the pod already links to it with works_at; when one of them already holds a role the audience names, pick them and search nothing. COMPANY PAGE: when the company has no linkedinUrl, read its website (web.read) and take the LinkedIn company page (linkedin.com/company/...) it links to. Store it as the company's linkedinUrl through the entity door only on a clear match: the company's own website links to it, or its name and its website or domain agree with the company's. Otherwise store nothing and report the page you found. ROLES: read the decision roles the audience names (its persona, segment and traits): the audience given, else the one the company targets (its targets link). When there is no audience or it names no role, look for founder, co-founder, CEO and CTO, in that order. Never invent a role. SEARCH: find the LinkedIn account with unipile_list_accounts (when several are connected, ask the person which one reads). For each role, run linkedin_search_people with keywords \"<role> <company name>\", then linkedin_get_profile on at most 5 candidates in all, the most likely first. These lookups only read. PICK: the best person for the audience, two at most: a current role at THIS company (in their current position, not a past one or a namesake), the most senior of the roles, the best fit with the audience, and a one-line reason for each. When no candidate passes, pick nobody and say what was searched. FILE, through the pod's doors and its governance: resolve identity first by their LinkedIn URL (synap_resolve_identity with that URL); a strong match is that person, reused, and nothing is created. Otherwise create the person with only their own fields: their name as title, jobTitle (their current title), linkedinUrl, and linkedinProviderId when the profile gives it; never the company's website, domain or email. File a new person and their works_at link to the company as ONE write (synap_capture with the person, the company by existingEntityId and the works_at relation); for a person already in the pod, add the works_at link when it is missing. Home the person in this CRM space: set their homeWorkspaceId to this space with synap_update_entity (on a pod whose entity door has no homeWorkspaceId, file them with this space's workspaceId instead). Then measure their LinkedIn reach through the pod's reach door (POST /channels/linkedin-reach/<personId>), which records it on the person; never set linkedinConnection or linkedinInvitedAt yourself. A write the pod files for review is success: keep going, never retry it, and report it as waiting for review. The company stays the lead: never give the person the lead role, never move the company's lead stage, and never draft or send a message or a connection request. REPORT, for each company: who was picked and why, their reach, whether the company's LinkedIn page was stored, only found or not found, and the next step (\"Draft a first message\" to the person picked or, when nobody fits, what the person could do instead).",
|
|
14900
|
+
"subjectProfile": {
|
|
14901
|
+
"profileSlug": "company"
|
|
14902
|
+
},
|
|
14903
|
+
"activators": [
|
|
14904
|
+
{
|
|
14905
|
+
"on": "manual",
|
|
14906
|
+
"mode": "propose"
|
|
14907
|
+
}
|
|
14908
|
+
],
|
|
14909
|
+
"params": [
|
|
14910
|
+
{
|
|
14911
|
+
"name": "company",
|
|
14912
|
+
"type": "entity",
|
|
14913
|
+
"label": "Company",
|
|
14914
|
+
"required": true
|
|
14915
|
+
},
|
|
14916
|
+
{
|
|
14917
|
+
"name": "audience",
|
|
14918
|
+
"type": "entity",
|
|
14919
|
+
"label": "Audience (ICP) the company came from"
|
|
14920
|
+
}
|
|
14921
|
+
],
|
|
14922
|
+
"executor": "is-agent",
|
|
14923
|
+
"status": "active",
|
|
14924
|
+
"requiredIntents": [
|
|
14925
|
+
"find_people",
|
|
14926
|
+
"enrich_entity"
|
|
14927
|
+
],
|
|
14928
|
+
"grants": [
|
|
14929
|
+
"entity.create"
|
|
14930
|
+
],
|
|
14931
|
+
"stages": [
|
|
14932
|
+
{
|
|
14933
|
+
"key": "read",
|
|
14934
|
+
"name": "Read the company and the roles",
|
|
14935
|
+
"category": "planned",
|
|
14936
|
+
"goal": "Read the company, the people already linked to it and its LinkedIn page, find its page on its website when it has none, and list the roles to look for from the audience (founder, co-founder, CEO and CTO when it names none).",
|
|
14937
|
+
"criteria": [
|
|
14938
|
+
{
|
|
14939
|
+
"key": "contact-roles-from-audience",
|
|
14940
|
+
"statement": "The roles looked for are the ones the audience names, or founder, co-founder, CEO and CTO when there is no audience or it names none; no role was invented.",
|
|
14941
|
+
"check": {
|
|
14942
|
+
"kind": "judge"
|
|
14943
|
+
}
|
|
14944
|
+
},
|
|
14945
|
+
{
|
|
14946
|
+
"key": "contact-page-clear-match",
|
|
14947
|
+
"statement": "The company's linkedinUrl was stored only on a clear match (its own website links to the page, or the name and the website or domain agree); otherwise nothing was stored and the page found was reported.",
|
|
14948
|
+
"check": {
|
|
14949
|
+
"kind": "judge"
|
|
14950
|
+
}
|
|
14951
|
+
}
|
|
14952
|
+
]
|
|
14953
|
+
},
|
|
14954
|
+
{
|
|
14955
|
+
"key": "find",
|
|
14956
|
+
"name": "Look the people up",
|
|
14957
|
+
"category": "started",
|
|
14958
|
+
"goal": "Search LinkedIn for each role at the company, read at most 5 profiles, and pick the best person (two at most) with a one-line reason each.",
|
|
14959
|
+
"criteria": [
|
|
14960
|
+
{
|
|
14961
|
+
"key": "contact-lookups-capped",
|
|
14962
|
+
"statement": "linkedin_get_profile ran on at most 5 candidates per company, and only lookup verbs were called on LinkedIn.",
|
|
14963
|
+
"check": {
|
|
14964
|
+
"kind": "judge"
|
|
14965
|
+
}
|
|
14966
|
+
},
|
|
14967
|
+
{
|
|
14968
|
+
"key": "contact-current-role",
|
|
14969
|
+
"statement": "Each person picked (two at most) holds a current role at this company and has a one-line reason, or nobody was picked and what was searched is said.",
|
|
14970
|
+
"check": {
|
|
14971
|
+
"kind": "judge"
|
|
14972
|
+
}
|
|
14973
|
+
}
|
|
14974
|
+
]
|
|
14975
|
+
},
|
|
14976
|
+
{
|
|
14977
|
+
"key": "file",
|
|
14978
|
+
"name": "File the contact",
|
|
14979
|
+
"category": "completed",
|
|
14980
|
+
"goal": "Resolve each person picked by their LinkedIn URL, create them only if new with their own fields, link them works_at the company, home them in this CRM space, measure their reach, and report who, why, their reach and the next step.",
|
|
14981
|
+
"expectedOutputs": [
|
|
14982
|
+
{
|
|
14983
|
+
"kind": "person",
|
|
14984
|
+
"label": "Contact picked",
|
|
14985
|
+
"relationToSubject": "works_at",
|
|
14986
|
+
"optional": true
|
|
14987
|
+
}
|
|
14988
|
+
],
|
|
14989
|
+
"criteria": [
|
|
14990
|
+
{
|
|
14991
|
+
"key": "contact-identity-first",
|
|
14992
|
+
"statement": "Each person picked was resolved by their LinkedIn URL first; an existing person was reused, and a new one carries only their own fields (never the company's website, domain or email).",
|
|
14993
|
+
"check": {
|
|
14994
|
+
"kind": "judge"
|
|
14995
|
+
}
|
|
14996
|
+
},
|
|
14997
|
+
{
|
|
14998
|
+
"key": "contact-linked-homed",
|
|
14999
|
+
"statement": "Each person picked works_at the company, is homed in this CRM space and had their LinkedIn reach measured through the pod's reach door, or the write is reported as waiting for review.",
|
|
15000
|
+
"check": {
|
|
15001
|
+
"kind": "judge"
|
|
15002
|
+
}
|
|
15003
|
+
},
|
|
15004
|
+
{
|
|
15005
|
+
"key": "contact-company-stays-lead",
|
|
15006
|
+
"statement": "The person got no lead role, the company's lead stage did not move, and no message or connection request was drafted or sent.",
|
|
15007
|
+
"check": {
|
|
15008
|
+
"kind": "judge"
|
|
15009
|
+
}
|
|
15010
|
+
},
|
|
15011
|
+
{
|
|
15012
|
+
"key": "contact-report",
|
|
15013
|
+
"statement": "The report gives, for each company, who was picked and why, their reach, whether its LinkedIn page was stored, only found or not found, and the next step.",
|
|
15014
|
+
"check": {
|
|
15015
|
+
"kind": "judge"
|
|
15016
|
+
}
|
|
15017
|
+
}
|
|
15018
|
+
]
|
|
15019
|
+
}
|
|
15020
|
+
]
|
|
15021
|
+
},
|
|
14883
15022
|
{
|
|
14884
15023
|
"name": "Follow up on leads in outreach",
|
|
14885
15024
|
"description": "Each weekday, read the channels of the leads in outreach; summarise a reply and propose the next step, or, when the follow-up is due with no reply, draft the next message in the angle of its outreach campaign as a proposed message for a person to approve; the agent never sends.",
|
|
@@ -15086,7 +15225,8 @@ const DATA = {
|
|
|
15086
15225
|
"humanOnlyStatuses": [
|
|
15087
15226
|
"drafted",
|
|
15088
15227
|
"sent",
|
|
15089
|
-
"opened"
|
|
15228
|
+
"opened",
|
|
15229
|
+
"replaced"
|
|
15090
15230
|
],
|
|
15091
15231
|
"activators": [
|
|
15092
15232
|
{
|
|
@@ -70,7 +70,8 @@ describe("linkedin_find_profile — a strict match or none", () => {
|
|
|
70
70
|
it("is a read the pod runs at once, routed by enrich_entity, and carries no install-time value", () => {
|
|
71
71
|
expect(verb.kind).toBe("code");
|
|
72
72
|
expect(verb.intent).toBe("enrich_entity");
|
|
73
|
-
|
|
73
|
+
// Declared read-only: the pod runs it with no review (plan 87088d2d W3c).
|
|
74
|
+
expect(verb.metadata).toEqual({ verbType: "read", readOnly: true });
|
|
74
75
|
expect(verb.parameters).toEqual({ fullName: "string", company: "string", accountId: "string" });
|
|
75
76
|
expect(verb.requires).toEqual(["unipile_linkedin"]);
|
|
76
77
|
expect(verb.code).not.toMatch(/\{\{\w+\}\}/);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unipile — a lookup runs on its own, a write waits for review (plan 87088d2d
|
|
3
|
+
* W3c, 6 Nov 2026).
|
|
4
|
+
*
|
|
5
|
+
* Every Unipile lookup used to wait for a review: the applier seeds each verb
|
|
6
|
+
* at `propose`, and only a verb whose definition DECLARES
|
|
7
|
+
* `metadata.readOnly: true` is run by the pod's gate with no review
|
|
8
|
+
* (backend `verbDeclaresReadOnly`, `execute-capability.ts`). A lookup reads
|
|
9
|
+
* LinkedIn or a conversation and writes nothing, in the pod or outside it, so
|
|
10
|
+
* it declares itself read-only. A verb that sends, invites, posts, opens a
|
|
11
|
+
* chat, makes a hosted-auth link or captures people into the pod writes, so it
|
|
12
|
+
* never does.
|
|
13
|
+
*
|
|
14
|
+
* Widening `readOnly` on an approved skill makes the pod ask for its approval
|
|
15
|
+
* again (backend `SKILL_METADATA_READ_ONLY`): the person approves each lookup
|
|
16
|
+
* once, then it runs on its own.
|
|
17
|
+
*
|
|
18
|
+
* NOT covered: the pod's gate itself (backend tests), and the catalog
|
|
19
|
+
* validator's refusal of a declarative write that says read-only
|
|
20
|
+
* (`scripts/catalog-lib.test.ts`).
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { describe, expect, it } from "vitest";
|
|
25
|
+
|
|
26
|
+
const capability = JSON.parse(
|
|
27
|
+
readFileSync(join(import.meta.dirname, "..", "capabilities", "unipile-linkedin.capability.json"), "utf8"),
|
|
28
|
+
) as { spec: { skills: Array<{ name: string; metadata?: { readOnly?: unknown } }> } };
|
|
29
|
+
|
|
30
|
+
const LOOKUPS = [
|
|
31
|
+
"unipile_list_accounts",
|
|
32
|
+
"unipile_get_account",
|
|
33
|
+
"unipile_list_conversations",
|
|
34
|
+
"unipile_list_messages",
|
|
35
|
+
"linkedin_search_people",
|
|
36
|
+
"linkedin_get_profile",
|
|
37
|
+
"linkedin_find_profile",
|
|
38
|
+
"linkedin_list_relations",
|
|
39
|
+
"linkedin_list_invitations",
|
|
40
|
+
"linkedin_list_company_pages",
|
|
41
|
+
];
|
|
42
|
+
const WRITES = [
|
|
43
|
+
"unipile_create_hosted_auth",
|
|
44
|
+
"unipile_send_message",
|
|
45
|
+
"unipile_start_chat",
|
|
46
|
+
"linkedin_capture_profiles",
|
|
47
|
+
"linkedin_send_invite",
|
|
48
|
+
"linkedin_send_first_message",
|
|
49
|
+
"unipile_publish_post",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
describe("Unipile — a lookup runs on its own, a write waits for review", () => {
|
|
53
|
+
const names = capability.spec.skills.map((s) => s.name);
|
|
54
|
+
|
|
55
|
+
it("every verb of the pack is either a lookup or a write", () => {
|
|
56
|
+
expect([...names].sort()).toEqual([...LOOKUPS, ...WRITES].sort());
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it.each(LOOKUPS)("%s declares metadata.readOnly: true", (name) => {
|
|
60
|
+
expect(capability.spec.skills.find((s) => s.name === name)?.metadata?.readOnly).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it.each(WRITES)("%s does not declare itself read-only", (name) => {
|
|
64
|
+
expect(capability.spec.skills.find((s) => s.name === name)?.metadata?.readOnly).not.toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|