@synap-core/workspace-templates 0.26.0 → 0.28.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synap-core/workspace-templates",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "type": "module",
5
5
  "description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
6
6
  "repository": {
@@ -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
- humanOnlyStatuses: [drafted, sent, opened]
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
- expect(verb.metadata).toEqual({ verbType: "read" });
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
+ });