@synap-core/workspace-templates 0.23.0 → 0.24.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.
Files changed (42) hide show
  1. package/README.md +50 -24
  2. package/capabilities/stripe.capability.json +1163 -87
  3. package/capabilities/unipile-linkedin.capability.json +30 -7
  4. package/dist/{define-CPS0n8BV.d.ts → define-DqoItRiR.d.ts} +40 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +159 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/wire.d.ts +1 -1
  9. package/package.json +3 -3
  10. package/skills/app-factory-design-review/SKILL.md +8 -2
  11. package/skills/design-critique/SKILL.md +3 -0
  12. package/src/catalog-safety.test.ts +1 -1
  13. package/src/content-os.template.test.ts +27 -0
  14. package/src/content-os.yaml +43 -0
  15. package/src/crm-messages-cadence.template.test.ts +83 -1
  16. package/src/crm.yaml +111 -4
  17. package/src/define.ts +16 -0
  18. package/src/reserved-profile-slugs.test.ts +8 -10
  19. package/src/retired-goal-kinds.test.ts +6 -12
  20. package/src/stripe-capability.test.ts +192 -0
  21. package/src/template-documents.test.ts +158 -0
  22. package/src/templates.ts +92 -4
  23. package/src/types.ts +32 -1
  24. package/src/unipile-linkedin-find-profile.test.ts +183 -0
  25. package/src/validate.ts +81 -0
  26. package/views/bookmark-views-pack.view.json +39 -0
  27. package/views/event-views-pack.view.json +45 -0
  28. package/views/task-views-pack.view.json +53 -0
  29. package/packs/bookmark-views-pack.json +0 -33
  30. package/packs/crm-pack.json +0 -76
  31. package/packs/customer-profile.json +0 -28
  32. package/packs/deep-research.playbook.json +0 -30
  33. package/packs/event-views-pack.json +0 -33
  34. package/packs/habit-profile.json +0 -27
  35. package/packs/hr-pack.json +0 -54
  36. package/packs/invoice-profile.json +0 -28
  37. package/packs/knowledge-base-pack.json +0 -56
  38. package/packs/meeting-profile.json +0 -29
  39. package/packs/product-profile.json +0 -30
  40. package/packs/project-management-pack.json +0 -49
  41. package/packs/recipe-profile.json +0 -29
  42. package/packs/task-views-pack.json +0 -39
@@ -14,7 +14,7 @@
14
14
  "spec": {
15
15
  "key": "unipile-linkedin",
16
16
  "name": "Unipile — Messaging & LinkedIn",
17
- "description": "Unified messaging + LinkedIn outreach via Unipile API v2 (API-key connector). Stores the Unipile v2 API key in the vault and registers one HTTP tool against the SINGLE global v2 host https://api.unipile.com/v2 (no per-tenant DSN — v2 removed it), authenticating with the X-API-KEY header. Skills: (a) provider-agnostic messaging — list accounts, list conversations, list messages, send into an existing thread, start a chat with one person on any account (unipile_start_chat) (works for LinkedIn/WhatsApp/Telegram/Instagram); (b) LinkedIn-specific — search people (read + persist variants), get a profile, send a connection request (v2 relation-request), start a NEW conversation (cold outreach via the CLASSIC_PRIMARY inbox), list relations + sent invitations, list the company pages an account administers (linkedin_list_company_pages), publish a post now (unipile_publish_post, the publish_post intent, one action per account from unipile_list_accounts). v2 notes baked in: account_id is a PATH segment (/{account_id}/...), send endpoints carry a /send suffix, LinkedIn cold-outreach uses /inboxes/CLASSIC_PRIMARY/chats/send with users_ids, invitations are /users/me/relation-requests, search is split per resource, and list envelopes are { data, total_count, next_cursor }. PRECONDITION: a Unipile v2 account (dashboardv2.unipile.com — v1 keys do NOT authenticate) with at least one connected provider account. Provider-agnostic skills take an explicit accountId (call unipile_list_accounts first). A pod app reaches unipile_send_message, unipile_start_chat, linkedin_send_first_message (send_message; the last one by the person's LinkedIn profile, 1st-degree connections only), linkedin_send_invite (request_connection, by their profile) and unipile_publish_post (publish_post) through their inputMaps: one action per account unipile_list_accounts lists, the recipient resolved by the pod ($to). LinkedIn skills take an accountId too: no skill code carries an install-time value, so the pod can update every install to a new version of this template. Skills call the tool by NAME via callProvider('unipile_linkedin', ...); the dispatcher injects the vaulted X-API-KEY server-side. key/tool name kept as unipile-linkedin/unipile_linkedin for backward compat with existing installs.",
17
+ "description": "Unified messaging + LinkedIn outreach via Unipile API v2 (API-key connector). Stores the Unipile v2 API key in the vault and registers one HTTP tool against the SINGLE global v2 host https://api.unipile.com/v2 (no per-tenant DSN — v2 removed it), authenticating with the X-API-KEY header. Skills: (a) provider-agnostic messaging — list accounts, list conversations, list messages, send into an existing thread, start a chat with one person on any account (unipile_start_chat) (works for LinkedIn/WhatsApp/Telegram/Instagram); (b) LinkedIn-specific — search people (read + persist variants, paged by cursor), find one known person's profile on a strict name and company match (linkedin_find_profile), get a profile, send a connection request (v2 relation-request), start a NEW conversation (cold outreach via the CLASSIC_PRIMARY inbox), list relations + sent invitations, list the company pages an account administers (linkedin_list_company_pages), publish a post now (unipile_publish_post, the publish_post intent, one action per account from unipile_list_accounts). v2 notes baked in: account_id is a PATH segment (/{account_id}/...), send endpoints carry a /send suffix, LinkedIn cold-outreach uses /inboxes/CLASSIC_PRIMARY/chats/send with users_ids, invitations are /users/me/relation-requests, search is split per resource, and list envelopes are { data, total_count, next_cursor }. PRECONDITION: a Unipile v2 account (dashboardv2.unipile.com — v1 keys do NOT authenticate) with at least one connected provider account. Provider-agnostic skills take an explicit accountId (call unipile_list_accounts first). A pod app reaches unipile_send_message, unipile_start_chat, linkedin_send_first_message (send_message; the last one by the person's LinkedIn profile, 1st-degree connections only), linkedin_send_invite (request_connection, by their profile) and unipile_publish_post (publish_post) through their inputMaps: one action per account unipile_list_accounts lists, the recipient resolved by the pod ($to). LinkedIn skills take an accountId too: no skill code carries an install-time value, so the pod can update every install to a new version of this template. Skills call the tool by NAME via callProvider('unipile_linkedin', ...); the dispatcher injects the vaulted X-API-KEY server-side. key/tool name kept as unipile-linkedin/unipile_linkedin for backward compat with existing installs.",
18
18
  "params": [
19
19
  {
20
20
  "name": "unipileApiKey",
@@ -224,7 +224,7 @@
224
224
  "intent": "capture_into_pod",
225
225
  "kind": "code",
226
226
  "scope": "pod",
227
- "description": "Searches LinkedIn people via Unipile and PROPOSES each match as a person entity (proposal-gated). Use linkedin_search_people to read without persisting. Args: { keywords: string, accountId: string (from unipile_list_accounts), limit?: number, profileSlug?: string (default person) }.",
227
+ "description": "Searches LinkedIn people via Unipile and PROPOSES each match as a person entity (proposal-gated). Use linkedin_search_people to read without persisting. Args: { keywords: string, accountId: string (from unipile_list_accounts), limit?: number, profileSlug?: string (default person), cursor?: string (the cursor a previous page returned) }. Returns { proposed, keywords, cursor } (cursor null on the last page).",
228
228
  "requires": [
229
229
  "unipile_linkedin"
230
230
  ],
@@ -234,9 +234,10 @@
234
234
  "keywords": "string",
235
235
  "accountId": "string",
236
236
  "limit": "number?",
237
- "profileSlug": "string?"
237
+ "profileSlug": "string?",
238
+ "cursor": "string?"
238
239
  },
239
- "code": "// LinkedIn people search via Unipile, proposed as person entities. See\n// linkedin_search_people for the v2 endpoint shape; this variant PERSISTS each\n// match (so its name carries no read token -> classified `write` -> proposed).\nconst accountId = String(args?.accountId ?? '').trim();\nconst keywords = String(args?.keywords ?? '').trim();\nconst limit = Number(args?.limit ?? 10);\nif (!accountId) throw new Error('linkedin_capture_profiles: accountId is required — call unipile_list_accounts first to find yours, then pass it as accountId.');\nif (!keywords) throw new Error('linkedin_capture_profiles: keywords is required');\n\nconst qs = new URLSearchParams({ offset: '0', limit: String(limit) });\nconst res = await callProvider('unipile_linkedin', 'POST', `/${encodeURIComponent(accountId)}/linkedin/search/people?${qs.toString()}`, { keywords });\nconst payload = res?.body ?? res;\nconst items = Array.isArray(payload?.data) ? payload.data : (Array.isArray(payload) ? payload : []);\n\nconst profileSlug = String(args?.profileSlug ?? '').trim() || 'person';\nlet proposed = 0;\nfor (const p of items.slice(0, limit)) {\n // v2 PeopleSearchResult: display_name, id (provider-native), public_identifier,\n // headline, location, profile_url.\n const name = String(p?.display_name ?? [p?.first_name, p?.last_name].filter(Boolean).join(' ') ?? 'LinkedIn profile').trim() || 'LinkedIn profile';\n await propose.entity({\n profileSlug,\n title: name,\n properties: {\n headline: p?.headline ?? null,\n location: p?.location ?? null,\n linkedinProviderId: p?.id ?? null,\n publicIdentifier: p?.public_identifier ?? null,\n profileUrl: p?.profile_url ?? null,\n provenance: 'connector', provider: 'unipile'\n },\n reasoning: `LinkedIn profile matched search \"${keywords}\" via Unipile.`\n });\n proposed += 1;\n}\nreturn { proposed, keywords };\n"
240
+ "code": "// LinkedIn people search via Unipile, proposed as person entities. See\n// linkedin_search_people for the v2 endpoint shape; this variant PERSISTS each\n// match (so its name carries no read token -> classified `write` -> proposed).\n// Pages by `cursor` like linkedin_search_people: `limit` caps one page.\nconst accountId = String(args?.accountId ?? '').trim();\nconst keywords = String(args?.keywords ?? '').trim();\nconst limit = Number(args?.limit ?? 10);\nconst cursor = String(args?.cursor ?? '').trim();\nif (!accountId) throw new Error('linkedin_capture_profiles: accountId is required — call unipile_list_accounts first to find yours, then pass it as accountId.');\nif (!keywords) throw new Error('linkedin_capture_profiles: keywords is required');\n\nconst qs = cursor ? `?${new URLSearchParams({ cursor }).toString()}` : '';\nconst res = await callProvider('unipile_linkedin', 'POST', `/${encodeURIComponent(accountId)}/linkedin/search/people${qs}`, { keywords });\nconst payload = res?.body ?? res;\nconst items = Array.isArray(payload?.data) ? payload.data : (Array.isArray(payload) ? payload : []);\n\nconst profileSlug = String(args?.profileSlug ?? '').trim() || 'person';\nlet proposed = 0;\nfor (const p of items.slice(0, limit)) {\n // v2 PeopleSearchResult: display_name, id (provider-native), public_identifier,\n // headline, location, profile_url.\n const name = String(p?.display_name ?? [p?.first_name, p?.last_name].filter(Boolean).join(' ') ?? 'LinkedIn profile').trim() || 'LinkedIn profile';\n await propose.entity({\n profileSlug,\n title: name,\n properties: {\n headline: p?.headline ?? null,\n location: p?.location ?? null,\n linkedinProviderId: p?.id ?? null,\n publicIdentifier: p?.public_identifier ?? null,\n profileUrl: p?.profile_url ?? null,\n provenance: 'connector', provider: 'unipile'\n },\n reasoning: `LinkedIn profile matched search \"${keywords}\" via Unipile.`\n });\n proposed += 1;\n}\nconst next = typeof payload?.next_cursor === 'string' && payload.next_cursor ? payload.next_cursor : null;\nreturn { proposed, keywords, cursor: next };\n"
240
241
  },
241
242
  {
242
243
  "name": "linkedin_send_invite",
@@ -274,7 +275,7 @@
274
275
  "intent": "find_people",
275
276
  "kind": "code",
276
277
  "scope": "pod",
277
- "description": "Searches LinkedIn people via Unipile and RETURNS the matches inline. Pure read -> runs INSTANTLY (no approval). Use to qualify/enrich a lead in-flow; use linkedin_capture_profiles to persist. Args: { keywords: string, accountId: string (from unipile_list_accounts), limit?: number }.",
278
+ "description": "Searches LinkedIn people via Unipile and RETURNS the matches inline. Pure read -> runs INSTANTLY (no approval). Use to qualify/enrich a lead in-flow; use linkedin_capture_profiles to persist, and linkedin_find_profile to find ONE known person's profile. Args: { keywords: string, accountId: string (from unipile_list_accounts), limit?: number, cursor?: string (the cursor a previous page returned) }. Returns { count, keywords, results, cursor } (cursor null on the last page).",
278
279
  "requires": [
279
280
  "unipile_linkedin"
280
281
  ],
@@ -283,9 +284,31 @@
283
284
  "parameters": {
284
285
  "keywords": "string",
285
286
  "accountId": "string",
286
- "limit": "number?"
287
+ "limit": "number?",
288
+ "cursor": "string?"
289
+ },
290
+ "code": "// Pure-read LinkedIn people search — returns raw v2 rows, writes NOTHING (keeps\n// it classified `read` -> governance `auto`, so an agent enriches a lead inline).\n// v2: search is split per resource (/linkedin/search/people); account_id is a\n// path segment; filters go in the JSON body; pages by `cursor` (the query\n// param, from the reply's next_cursor): the endpoint takes no offset or limit,\n// so `limit` caps the rows returned from one page.\nconst accountId = String(args?.accountId ?? '').trim();\nconst keywords = String(args?.keywords ?? '').trim();\nconst limit = Number(args?.limit ?? 10);\nconst cursor = String(args?.cursor ?? '').trim();\nif (!accountId) throw new Error('linkedin_search_people: accountId is required — call unipile_list_accounts first to find yours.');\nif (!keywords) throw new Error('linkedin_search_people: keywords is required');\n\nconst qs = cursor ? `?${new URLSearchParams({ cursor }).toString()}` : '';\nconst res = await callProvider('unipile_linkedin', 'POST', `/${encodeURIComponent(accountId)}/linkedin/search/people${qs}`, { keywords });\nconst payload = res?.body ?? res;\n// v2 envelope: { data: [...], total_count, next_cursor }.\nconst items = Array.isArray(payload?.data) ? payload.data : (Array.isArray(payload) ? payload : []);\nconst results = items.slice(0, limit);\nconst next = typeof payload?.next_cursor === 'string' && payload.next_cursor ? payload.next_cursor : null;\nreturn { count: results.length, keywords, results, cursor: next };\n"
291
+ },
292
+ {
293
+ "name": "linkedin_find_profile",
294
+ "intent": "enrich_entity",
295
+ "kind": "code",
296
+ "scope": "pod",
297
+ "description": "Finds ONE person's LinkedIn profile by their full name and company, on a STRICT match only: the result's name equals the full name (case- and accent-insensitive), the company is in its headline, and exactly one result is both. Pure read -> runs instantly; it writes nothing. A match returns { matched: true, profile, properties: { linkedinUrl, linkedinProviderId } }: propose those properties on the person for a person to approve, never write them directly. No match, several, or more results than it read returns { matched: false, reason: none | several | more_results, candidates } with up to 3 candidates for a person to choose from. Args: { fullName: string, company: string, accountId: string (from unipile_list_accounts) }.",
298
+ "requires": [
299
+ "unipile_linkedin"
300
+ ],
301
+ "executionMode": "sync",
302
+ "timeoutSeconds": 60,
303
+ "metadata": {
304
+ "verbType": "read"
305
+ },
306
+ "parameters": {
307
+ "fullName": "string",
308
+ "company": "string",
309
+ "accountId": "string"
287
310
  },
288
- "code": "// Pure-read LinkedIn people search — returns raw v2 rows, writes NOTHING (keeps\n// it classified `read` -> governance `auto`, so an agent enriches a lead inline).\n// v2: search is split per resource (/linkedin/search/people); account_id is a\n// path segment; offset+limit are query params; filters go in the JSON body.\nconst accountId = String(args?.accountId ?? '').trim();\nconst keywords = String(args?.keywords ?? '').trim();\nconst limit = Number(args?.limit ?? 10);\nif (!accountId) throw new Error('linkedin_search_people: accountId is required — call unipile_list_accounts first to find yours.');\nif (!keywords) throw new Error('linkedin_search_people: keywords is required');\n\nconst qs = new URLSearchParams({ offset: '0', limit: String(limit) });\nconst res = await callProvider('unipile_linkedin', 'POST', `/${encodeURIComponent(accountId)}/linkedin/search/people?${qs.toString()}`, { keywords });\nconst payload = res?.body ?? res;\n// v2 envelope: { data: [...], total_count, next_cursor }.\nconst items = Array.isArray(payload?.data) ? payload.data : (Array.isArray(payload) ? payload : []);\nconst results = items.slice(0, limit);\nreturn { count: results.length, keywords, results };\n"
311
+ "code": "// Find ONE person's LinkedIn profile by their full name and company: a STRICT\n// match or none (CRM wave 2, S4b). A pure read: it writes nothing, so it runs at\n// once. Writing the match onto the person (linkedinUrl, linkedinProviderId) is\n// the caller's write, proposed for a person to approve (founder decision 7).\n// STRICT: the result's name equals the full name (case- and accent-insensitive,\n// spaces collapsed), the company is in its headline as whole words (a v2\n// PeopleSearchResult has no company field), and exactly one result is both.\n// None, several, or a third page left unread (so one more could exist): no\n// match, with up to 3 candidates (the closest first) for a person to choose.\n// v2: POST /{account_id}/linkedin/search/people, paged by `cursor` (the reply's\n// next_cursor); the company also narrows the search (advanced_keywords).\nconst accountId = String(args?.accountId ?? '').trim();\nconst fullName = String(args?.fullName ?? '').trim();\nconst company = String(args?.company ?? '').trim();\nif (!accountId) throw new Error('linkedin_find_profile: accountId is required — call unipile_list_accounts first to find yours.');\nif (!fullName) throw new Error('linkedin_find_profile: fullName (the person\\'s first and last name) is required');\nif (!company) throw new Error('linkedin_find_profile: company is required: without it a name alone is never a strict match');\n\nconst fold = (s) => String(s ?? '').normalize('NFD').replace(/\\p{M}+/gu, '').toLowerCase();\nconst nameKey = (s) => fold(s).replace(/\\s+/g, ' ').trim();\nconst words = (s) => fold(s).split(/[^\\p{L}\\p{N}]+/u).filter(Boolean);\nconst hasWords = (text, part) => {\n if (!part.length) return false;\n for (let i = 0; i + part.length <= text.length; i++) {\n if (part.every((w, j) => text[i + j] === w)) return true;\n }\n return false;\n};\nconst wanted = nameKey(fullName);\nconst companyWords = words(company);\nconst nameMatches = (p) => nameKey(p?.display_name) === wanted;\nconst companyInHeadline = (p) => hasWords(words(p?.headline), companyWords);\n\nconst MAX_PAGES = 2;\nconst account = encodeURIComponent(accountId);\nconst body = { keywords: fullName, advanced_keywords: { company } };\nconst byId = new Map();\nlet cursor = null;\nlet pages = 0;\ndo {\n const qs = cursor ? `?${new URLSearchParams({ cursor }).toString()}` : '';\n const res = await callProvider('unipile_linkedin', 'POST', `/${account}/linkedin/search/people${qs}`, body);\n if (res?.proposed === true) {\n return { matched: false, reason: 'proposed', proposed: true, proposalId: res?.proposalId ?? null, candidates: [] };\n }\n if (res?.status && (res.status < 200 || res.status >= 300)) throw new Error(`linkedin_find_profile: the LinkedIn search failed (status ${res.status}).`);\n const payload = res?.body ?? res;\n const items = Array.isArray(payload?.data) ? payload.data : [];\n for (const p of items) {\n const key = String(p?.id ?? p?.public_identifier ?? `row-${byId.size}`);\n if (!byId.has(key)) byId.set(key, p);\n }\n pages += 1;\n const next = typeof payload?.next_cursor === 'string' && payload.next_cursor ? payload.next_cursor : null;\n cursor = next && next !== cursor ? next : null;\n} while (cursor && pages < MAX_PAGES);\nconst moreUnread = Boolean(cursor);\n\nconst view = (p) => {\n const publicIdentifier = p?.public_identifier ?? null;\n return {\n name: String(p?.display_name ?? '').trim() || null,\n headline: p?.headline ?? null,\n location: p?.location ?? null,\n providerId: p?.id ?? null,\n publicIdentifier,\n profileUrl: p?.profile_url ?? (publicIdentifier ? `https://www.linkedin.com/in/${publicIdentifier}` : null),\n networkDistance: p?.network_distance ?? null,\n nameMatches: nameMatches(p),\n companyInHeadline: companyInHeadline(p),\n };\n};\nconst rows = [...byId.values()].map(view);\nconst strict = rows.filter((r) => r.nameMatches && r.companyInHeadline);\nconst searched = { fullName, company, results: rows.length, pages, moreUnread };\n\nif (strict.length === 1 && !moreUnread) {\n const profile = strict[0];\n return {\n matched: true,\n profile,\n properties: { linkedinUrl: profile.profileUrl, linkedinProviderId: profile.providerId },\n candidates: [],\n searched,\n };\n}\nconst rank = (r) => (r.nameMatches ? 2 : 0) + (r.companyInHeadline ? 1 : 0);\nconst candidates = rows\n .map((r, i) => ({ r, i }))\n .sort((a, b) => rank(b.r) - rank(a.r) || a.i - b.i)\n .slice(0, 3)\n .map(({ r }) => r);\nconst reason = strict.length > 1 ? 'several' : strict.length === 1 ? 'more_results' : 'none';\nreturn { matched: false, reason, candidates, searched };\n"
289
312
  },
290
313
  {
291
314
  "name": "linkedin_get_profile",
@@ -215,6 +215,25 @@ interface TemplateView {
215
215
  description?: string;
216
216
  groupBy?: string;
217
217
  }
218
+ /**
219
+ * A document a template carries. Mirrors `WorkspaceDocumentInput`
220
+ * (synap-backend `@synap-core/types/workspaces/definition`) VERBATIM; the pod
221
+ * installs it through `utils/template-documents.ts` (the one create door,
222
+ * system-authored, owned by the installer, identified in the space by `slug`).
223
+ */
224
+ interface TemplateDocument {
225
+ /** Stable id inside the template, kebab-case. Never rename it: the space knows the document by it. */
226
+ slug: string;
227
+ /** The document's name (its one title). */
228
+ title: string;
229
+ /** Markdown body; may carry `synap-cell` / `synap-section` embeds. */
230
+ content: string;
231
+ /**
232
+ * What its embeds read (a living document's scope): `space` = the space it
233
+ * is installed in, `pod` = the whole pod. Absent = no scope.
234
+ */
235
+ scope?: "space" | "pod";
236
+ }
218
237
  interface TemplateEntityLink {
219
238
  sourceProfileSlug: string;
220
239
  targetProfileSlug: string;
@@ -297,10 +316,12 @@ interface TemplateSidebarSurface {
297
316
  meta?: Record<string, unknown>;
298
317
  }
299
318
  interface TemplateSidebarItem {
300
- kind: "app" | "view" | "profile" | "external" | "cell";
319
+ kind: "app" | "view" | "profile" | "external" | "cell" | "document";
301
320
  appId?: string;
302
321
  viewName?: string;
303
322
  viewId?: string;
323
+ /** kind "document": the `documents[].slug` it opens (the pod stores its id beside it). */
324
+ documentSlug?: string;
304
325
  profileSlug?: string;
305
326
  url?: string;
306
327
  cellKey?: string;
@@ -887,6 +908,14 @@ interface WorkspaceYaml {
887
908
  profiles: TemplateProfile[];
888
909
  /** Curated views — only the ones that add value */
889
910
  views?: TemplateView[];
911
+ /**
912
+ * Documents the space carries (plan 9ad4b2fc §5 lane 12, P4: a pinned set of
913
+ * documents hosts a space package). Installed by the pod with the views:
914
+ * created once per `slug` in the space, never rewritten by an update. A
915
+ * sidebar item `{kind: "document", documentSlug}` opens one. See
916
+ * {@link TemplateDocument}.
917
+ */
918
+ documents?: TemplateDocument[];
890
919
  /** Schema-level links between entity types */
891
920
  entityLinks?: TemplateEntityLink[];
892
921
  /**
@@ -1107,6 +1136,8 @@ interface WorkspaceDefinitionInput {
1107
1136
  proposalId?: string;
1108
1137
  profiles: WorkspaceYaml['profiles'];
1109
1138
  views: NonNullable<WorkspaceYaml['views']>;
1139
+ /** Template documents — created by the pod with the views (`utils/template-documents.ts`). */
1140
+ documents?: WorkspaceYaml['documents'];
1110
1141
  entityLinks: NonNullable<WorkspaceYaml['entityLinks']>;
1111
1142
  seedEntities: NonNullable<WorkspaceYaml['seedEntities']>;
1112
1143
  suggestedRelations?: WorkspaceYaml['suggestedRelations'];
@@ -1263,6 +1294,14 @@ interface PackageDefinitionOutput {
1263
1294
  defaultSources?: WorkspaceYaml['workspace']['defaultSources'];
1264
1295
  profiles: WorkspaceYaml['profiles'];
1265
1296
  views: NonNullable<WorkspaceYaml['views']>;
1297
+ /**
1298
+ * Template documents — created by the pod with the views
1299
+ * (`utils/template-documents.ts`); the Hub `PackageApplySchema` slots them.
1300
+ * The CP publish door does NOT yet (it strips the field and refuses a
1301
+ * `kind: "document"` sidebar item), so no official template may declare
1302
+ * documents until it does.
1303
+ */
1304
+ documents?: WorkspaceYaml['documents'];
1266
1305
  suggestedEntities: NonNullable<WorkspaceYaml['seedEntities']>;
1267
1306
  suggestedRelations?: WorkspaceYaml['suggestedRelations'];
1268
1307
  displayTemplates?: WorkspaceYaml['displayTemplates'];
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { W as WorkspaceYaml, T as TemplateDependency, a as TemplateLifecycleOption, b as TemplateLifecycleOptionWaits } from './define-CPS0n8BV.js';
2
- export { B as BentoViewBlockWire, c as BentoWidgetBlockWire, d as BuiltSpacePackage, e as BuiltSpacePackageCell, I as INTENT_LABEL_MAX, P as PACKAGE_DEPENDENCY_KINDS, f as PackageAutomationWire, g as PackageDefinitionOutput, h as PackageDependencyKind, S as START_EXAMPLE_RANGE, i as START_STARTERS_RANGE, j as START_VIEWS_MAX, k as SpacePackageCellWire, l as SplitBentoBlocks, m as TemplateActionPlacement, n as TemplateAppKind, o as TemplateAppRef, p as TemplateAutomation, q as TemplateBentoBlock, r as TemplateDefaultSource, s as TemplateDisplayTemplate, t as TemplateEntityLink, u as TemplateExtendsRef, v as TemplateIntegration, w as TemplateIntent, x as TemplateLayoutConfig, y as TemplateMeta, z as TemplateOnboarding, A as TemplateOnboardingCollectTarget, C as TemplatePlaybook, D as TemplatePlaybookParam, E as TemplatePrimarySurface, F as TemplateProfile, G as TemplateProfileEntityBentoTemplates, H as TemplateProperty, J as TemplateSeedEntity, K as TemplateSidebarItem, L as TemplateSidebarSurface, M as TemplateSourceRole, N as TemplateSpacePackage, O as TemplateSpacePackageCell, Q as TemplateStart, R as TemplateSuggestedRelation, U as TemplateView, V as ToDefinitionResult, X as WorkspaceDefinitionInput, Y as WorkspaceJsonValue, Z as WorkspacePrimarySurface, _ as WorkspacePrimarySurfaceViewReference, $ as normalizeTemplateLayout, a0 as spacePackageCells, a1 as splitBentoBlocks, a2 as toAutomationWire, a3 as toPackageDefinition, a4 as toWorkspaceDefinition } from './define-CPS0n8BV.js';
1
+ import { W as WorkspaceYaml, T as TemplateDependency, a as TemplateLifecycleOption, b as TemplateLifecycleOptionWaits } from './define-DqoItRiR.js';
2
+ export { B as BentoViewBlockWire, c as BentoWidgetBlockWire, d as BuiltSpacePackage, e as BuiltSpacePackageCell, I as INTENT_LABEL_MAX, P as PACKAGE_DEPENDENCY_KINDS, f as PackageAutomationWire, g as PackageDefinitionOutput, h as PackageDependencyKind, S as START_EXAMPLE_RANGE, i as START_STARTERS_RANGE, j as START_VIEWS_MAX, k as SpacePackageCellWire, l as SplitBentoBlocks, m as TemplateActionPlacement, n as TemplateAppKind, o as TemplateAppRef, p as TemplateAutomation, q as TemplateBentoBlock, r as TemplateDefaultSource, s as TemplateDisplayTemplate, t as TemplateEntityLink, u as TemplateExtendsRef, v as TemplateIntegration, w as TemplateIntent, x as TemplateLayoutConfig, y as TemplateMeta, z as TemplateOnboarding, A as TemplateOnboardingCollectTarget, C as TemplatePlaybook, D as TemplatePlaybookParam, E as TemplatePrimarySurface, F as TemplateProfile, G as TemplateProfileEntityBentoTemplates, H as TemplateProperty, J as TemplateSeedEntity, K as TemplateSidebarItem, L as TemplateSidebarSurface, M as TemplateSourceRole, N as TemplateSpacePackage, O as TemplateSpacePackageCell, Q as TemplateStart, R as TemplateSuggestedRelation, U as TemplateView, V as ToDefinitionResult, X as WorkspaceDefinitionInput, Y as WorkspaceJsonValue, Z as WorkspacePrimarySurface, _ as WorkspacePrimarySurfaceViewReference, $ as normalizeTemplateLayout, a0 as spacePackageCells, a1 as splitBentoBlocks, a2 as toAutomationWire, a3 as toPackageDefinition, a4 as toWorkspaceDefinition } from './define-DqoItRiR.js';
3
3
  import '@synap-core/types/presentation';
4
4
  import '@synap-core/types/space-brief';
5
5
  import '@synap-core/types/capability-intents';
package/dist/index.js CHANGED
@@ -11144,6 +11144,31 @@ var DATA = {
11144
11144
  "executor": "is-agent",
11145
11145
  "status": "active"
11146
11146
  },
11147
+ {
11148
+ "name": "Write the caption",
11149
+ "description": "Draft the caption of one post from its brief and its script, as a draft in its script document that the person edits and saves.",
11150
+ "goalTemplate": `Draft the caption of this post ({post}). Read the post: its hook (post-hook), its call to action (post-cta), its pillar, its journey stage, its platform (post-platform) and its format. Read its script, the post's own document: its slides (the S1, S2 lines or the slide table) and any caption already written under "## Caption". Write ONE caption in the brand voice (the pod's brand kit) that says what the slides leave out: a first line that stands on its own before the platform cuts it (Instagram shows about 125 characters before "more", LinkedIn about 210 before "see more"), then two or three short lines, and the call to action last. No hashtag wall, no emoji run, no claim the script does not make, and no line that starts with "##". Put it in the script document as its "## Caption draft" section: replace that section if it is there, else add it at the end, and change no other line (synap_update_document with the revision you read). Never write or change the "## Caption" section on your own: it is the person's. Then ask the person to approve your draft: ONE confirm ask on this session (owner the person), its prompt the caption itself, "Use this caption" recommended. On yes, put the draft in the "## Caption" section (replace its words, or add it) and remove the "## Caption draft" section, nothing else. On no, leave the draft where it is (they may edit it and save it on the post's page in Content Studio, which also makes it the caption). Never move the post's status. Close this session once the person answered.`,
11151
+ "subjectProfile": {
11152
+ "profileSlug": "post"
11153
+ },
11154
+ "expectedOutputs": [
11155
+ {
11156
+ "kind": "document",
11157
+ "label": "Caption draft",
11158
+ "relationToSubject": "none"
11159
+ }
11160
+ ],
11161
+ "params": [
11162
+ {
11163
+ "name": "post",
11164
+ "type": "entity",
11165
+ "label": "Post",
11166
+ "required": true
11167
+ }
11168
+ ],
11169
+ "executor": "is-agent",
11170
+ "status": "active"
11171
+ },
11147
11172
  {
11148
11173
  "name": "Repurpose",
11149
11174
  "description": "Turn a published post into new posts for other formats and platforms, each linked to the original.",
@@ -13599,7 +13624,7 @@ var DATA = {
13599
13624
  {
13600
13625
  "name": "Draft a first message",
13601
13626
  "description": "Draft one personal first message for a lead, citing its buying signal or a past exchange, in the person's language, as a proposed message in their channel for a person to approve; the agent never sends.",
13602
- "goalTemplate": `Draft the first message to the lead {lead} on {channel}. READ: the lead's score fields (icpScore, buyingSignal, buyingSignalSource, qualificationNotes) as they are, never rescoring them; its channels, past messages and notes in the pod; and the outreach campaign given ({campaign}), if any: its value proposition and call to action are the angle. WHO: a person lead is written to directly; for a company lead, write to the person who decides there (from the pod, else its public decider), and when no decider is known ask the person who to write to. DRAFT: one first message on channel {channel}, in the campaign's angle when one is given. DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. Its message is personal: it cites the buying signal and what it showed, or a past exchange, never a generic template; short; in the person's language, French by default, and in French it says vous. It offers one useful next step, never pressure. Draft one message only: a person reviews it in the channel, edits it if needed and approves it. NEXT ACTION: set the lead's nextAction to "Send the first message to <name> on LinkedIn" ("Send the first message to <name> by email" on email), naming the person written to, and its nextActionAt to today. Never move the lead's stage while drafting. SENT: once the message is sent, follow the cadence, so "Follow up on leads in outreach" drafts the next one. CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead's lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead's next action: after the first message, nextAction "Follow up with <name> if no reply" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction "Follow up with <name> a last time if no reply" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction "No reply after two follow-ups: nurture or drop <name>" and nextActionAt the sent day + 7 days, and when that date passes with no reply, "Follow up on leads in outreach" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and "Follow up on leads in outreach" reads it and proposes the next step.`,
13627
+ "goalTemplate": `Draft the first message to the lead {lead} on {channel}. READ: the lead's score fields (icpScore, buyingSignal, buyingSignalSource, qualificationNotes) as they are, never rescoring them; its channels, past messages and notes in the pod; and the outreach campaign given ({campaign}), if any: its value proposition and call to action are the angle. WHO: a person lead is written to directly; for a company lead, write to the person who decides there (from the pod, else its public decider), and when no decider is known ask the person who to write to. DRAFT: one first message on channel {channel}, in the campaign's angle when one is given. DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. A LinkedIn message is drafted whatever their reach: the pod holds a draft to someone who is not a connection yet, shows it with the next step and lets it go out once they connect, so never hold a draft back for want of a connection. Draft it, then, in this order: (1) a person with no linkedinUrl: find their profile with linkedin_find_profile (their full name and company); on a match, propose its linkedinUrl and linkedinProviderId on the person for a person to approve; on none or several, ask the person to choose among its candidates (the process "Find their LinkedIn" does this for one lead). (2) Read their linkedinConnection, which only the pod writes (never set it or linkedinInvitedAt): connected, nothing more; none or not set, ask to connect with linkedin_send_invite (it refuses someone already connected or already invited: then nothing more); pending, the draft waits for them; ignored, draft by email instead when they have an email address. While the draft waits for them to connect, set the lead's nextAction to "Wait for <name> to accept the LinkedIn request" and its nextActionAt 7 days later. An email message needs none of this. Its message is personal: it cites the buying signal and what it showed, or a past exchange, never a generic template; short; in the person's language, French by default, and in French it says vous. It offers one useful next step, never pressure. Draft one message only: a person reviews it in the channel, edits it if needed and approves it. NEXT ACTION: set the lead's nextAction to "Send the first message to <name> on LinkedIn" ("Send the first message to <name> by email" on email), naming the person written to, and its nextActionAt to today. Never move the lead's stage while drafting. SENT: once the message is sent, follow the cadence, so "Follow up on leads in outreach" drafts the next one. CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead's lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead's next action: after the first message, nextAction "Follow up with <name> if no reply" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction "Follow up with <name> a last time if no reply" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction "No reply after two follow-ups: nurture or drop <name>" and nextActionAt the sent day + 7 days, and when that date passes with no reply, "Follow up on leads in outreach" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and "Follow up on leads in outreach" reads it and proposes the next step.`,
13603
13628
  "subjectProfile": {
13604
13629
  "profileSlug": "lead"
13605
13630
  },
@@ -13653,7 +13678,7 @@ var DATA = {
13653
13678
  "criteria": [
13654
13679
  {
13655
13680
  "key": "first-message-one-draft",
13656
- "statement": "One proposed message, filed through messaging.send to the person written to (for a company, its decider), waits for review in that person's channel, holds the message text, and follows the campaign's angle when one was given.",
13681
+ "statement": "One proposed message, filed through messaging.send to the person written to (for a company, its decider), waits for review in that person's channel, holds the message text, and follows the campaign's angle when one was given, whatever the person's LinkedIn reach; on LinkedIn to someone not yet a connection, the pod holds it, and their profile is found and the connection asked for or awaited, in the DRAFT RULES order.",
13657
13682
  "check": {
13658
13683
  "kind": "judge"
13659
13684
  }
@@ -13702,10 +13727,73 @@ var DATA = {
13702
13727
  }
13703
13728
  ]
13704
13729
  },
13730
+ {
13731
+ "name": "Find their LinkedIn",
13732
+ "description": "Find one lead's LinkedIn profile by their full name and company and propose its URL and member id on the person for a person to approve, or offer up to 3 candidates to choose from; nothing is sent.",
13733
+ "goalTemplate": "Find the LinkedIn profile of the lead {lead}. WHO: a person lead is looked up themselves; for a company lead, the person who decides there, from the pod; when no decider is known, ask the person who it is. When that person already has a linkedinUrl, say so and stop. FIND: run linkedin_find_profile with their full name, their company and the LinkedIn account unipile_list_accounts lists (when several are connected, ask the person which one reads). On a match, propose its linkedinUrl and linkedinProviderId on the person through the entity door, for a person to approve; never write them directly, and never set linkedinConnection or linkedinInvitedAt, which only the pod writes. On none, several or more results than it read, ask the person to choose among its candidates (at most 3, each with its name, headline and profile URL) or to give the URL themselves, then propose the one chosen the same way. Never send a message or a connection request, never draft one, and never move the lead's stage.",
13734
+ "subjectProfile": {
13735
+ "profileSlug": "lead"
13736
+ },
13737
+ "activators": [
13738
+ {
13739
+ "on": "manual",
13740
+ "mode": "propose"
13741
+ }
13742
+ ],
13743
+ "params": [
13744
+ {
13745
+ "name": "lead",
13746
+ "type": "entity",
13747
+ "label": "Lead",
13748
+ "required": true
13749
+ }
13750
+ ],
13751
+ "executor": "is-agent",
13752
+ "status": "active",
13753
+ "requiredIntents": [
13754
+ "enrich_entity"
13755
+ ],
13756
+ "stages": [
13757
+ {
13758
+ "key": "find",
13759
+ "name": "Find the profile",
13760
+ "category": "started",
13761
+ "goal": "Run linkedin_find_profile for the lead (a company lead's decider) with their full name and company; on a match propose linkedinUrl and linkedinProviderId on the person, otherwise ask the person to choose among up to 3 candidates or give the URL.",
13762
+ "criteria": [
13763
+ {
13764
+ "key": "find-linkedin-proposed",
13765
+ "statement": "The person's linkedinUrl and linkedinProviderId are proposed on them for a person to approve (a strict match, or the candidate the person chose), or the person was asked to choose among at most 3 candidates; nothing was written directly.",
13766
+ "check": {
13767
+ "kind": "judge"
13768
+ }
13769
+ },
13770
+ {
13771
+ "key": "find-linkedin-reader-only",
13772
+ "statement": "No message or connection request was sent or drafted, linkedinConnection and linkedinInvitedAt were not set, and the lead's stage was not moved.",
13773
+ "check": {
13774
+ "kind": "judge"
13775
+ }
13776
+ }
13777
+ ]
13778
+ },
13779
+ {
13780
+ "key": "confirm",
13781
+ "name": "Person confirms",
13782
+ "category": "completed",
13783
+ "goal": "The person approves the proposed profile, or picks a candidate or gives the URL, and the pod stores linkedinUrl and linkedinProviderId on the person; the lead's next step is then to ask to connect.",
13784
+ "gate": {
13785
+ "kind": "human"
13786
+ },
13787
+ "onFail": {
13788
+ "toStage": "find"
13789
+ }
13790
+ }
13791
+ ]
13792
+ },
13705
13793
  {
13706
13794
  "name": "Follow up on leads in outreach",
13707
13795
  "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.",
13708
- "goalTemplate": `Follow up on the leads in outreach. For each, read its channels (synap_get_channel, mode person: for a company lead, the channels of the person who decides there) and its notes. A REPLY is an inbound message after our last outbound one in a channel. With a reply, draft nothing: summarise it for a person; when they show interest, propose the next step (prepare the free estimate, "Prepare a pre-sale estimate", or book a meeting) and propose moving the lead to engaged; when they decline, propose nurturing or disqualified with their reason (in disqualificationReason). Unless you propose dropping them, write the next step on the lead as nextAction, one imperative line, and nextActionAt, the date they named or today. Never open a deal and never answer the reply yourself; a person does. With no reply, and only when the lead's next action date is today or past and no proposed message to it is still waiting, draft the next message, to the lead (for a company, the person who decides there) and in the angle of the lead's outreach campaign: follow its value proposition and call to action, short and in the lead's language, offering the next useful step, never pressure. For a lead with no campaign, ask the person which one. Draft at most two follow-ups per lead: a lead already sent two follow-ups gets no draft, never a third follow-up; when its last date passes in silence, propose moving it to nurturing through the status door, with its expected status. DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. For each draft, set the lead's \`nextAction\` to send it (e.g. "Send the drafted message to Ana on LinkedIn") and \`nextActionAt\` to today. The person reviews each proposed message in its channel, edits it if needed and approves it; the agent never sends. CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead's lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead's next action: after the first message, nextAction "Follow up with <name> if no reply" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction "Follow up with <name> a last time if no reply" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction "No reply after two follow-ups: nurture or drop <name>" and nextActionAt the sent day + 7 days, and when that date passes with no reply, "Follow up on leads in outreach" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and "Follow up on leads in outreach" reads it and proposes the next step.`,
13796
+ "goalTemplate": `Follow up on the leads in outreach. For each, read its channels (synap_get_channel, mode person: for a company lead, the channels of the person who decides there) and its notes. A REPLY is an inbound message after our last outbound one in a channel. With a reply, draft nothing: summarise it for a person; when they show interest, propose the next step (prepare the free estimate, "Prepare a pre-sale estimate", or book a meeting) and propose moving the lead to engaged; when they decline, propose nurturing or disqualified with their reason (in disqualificationReason). Unless you propose dropping them, write the next step on the lead as nextAction, one imperative line, and nextActionAt, the date they named or today. Never open a deal and never answer the reply yourself; a person does. With no reply, and only when the lead's next action date is today or past and no proposed message to it is still waiting, draft the next message, to the lead (for a company, the person who decides there) and in the angle of the lead's outreach campaign: follow its value proposition and call to action, short and in the lead's language, offering the next useful step, never pressure. For a lead with no campaign, ask the person which one. Draft at most two follow-ups per lead: a lead already sent two follow-ups gets no draft, never a third follow-up; when its last date passes in silence, propose moving it to nurturing through the status door, with its expected status. DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. A LinkedIn message is drafted whatever their reach: the pod holds a draft to someone who is not a connection yet, shows it with the next step and lets it go out once they connect, so never hold a draft back for want of a connection. Draft it, then, in this order: (1) a person with no linkedinUrl: find their profile with linkedin_find_profile (their full name and company); on a match, propose its linkedinUrl and linkedinProviderId on the person for a person to approve; on none or several, ask the person to choose among its candidates (the process "Find their LinkedIn" does this for one lead). (2) Read their linkedinConnection, which only the pod writes (never set it or linkedinInvitedAt): connected, nothing more; none or not set, ask to connect with linkedin_send_invite (it refuses someone already connected or already invited: then nothing more); pending, the draft waits for them; ignored, draft by email instead when they have an email address. While the draft waits for them to connect, set the lead's nextAction to "Wait for <name> to accept the LinkedIn request" and its nextActionAt 7 days later. An email message needs none of this. For each draft, set the lead's \`nextAction\` to send it (e.g. "Send the drafted message to Ana on LinkedIn") and \`nextActionAt\` to today. The person reviews each proposed message in its channel, edits it if needed and approves it; the agent never sends. CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead's lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead's next action: after the first message, nextAction "Follow up with <name> if no reply" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction "Follow up with <name> a last time if no reply" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction "No reply after two follow-ups: nurture or drop <name>" and nextActionAt the sent day + 7 days, and when that date passes with no reply, "Follow up on leads in outreach" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and "Follow up on leads in outreach" reads it and proposes the next step.`,
13709
13797
  "subjectProfile": {
13710
13798
  "profileSlug": "lead",
13711
13799
  "statusProperty": "leadStage"
@@ -13792,7 +13880,7 @@ var DATA = {
13792
13880
  {
13793
13881
  "name": "Personalized Outreach",
13794
13882
  "description": "Run one outreach campaign \u2014 write the angle and its offer, pick the people, draft one personal first message each as a proposed message in their channel; a person approves the list, then approves each message, which the pod sends.",
13795
- "goalTemplate": `Run this outreach campaign ({campaign}) on {channel}; each stage moves its status. If its type is not outreach, stop and say so. PREPARE: write the target audience, the value proposition and the call to action. Pick the people to reach from the pod (and public sources when the pod has too few), resolving each one before creating anything, link each as a member of the campaign with the lead role (lead source cold-outreach when it has none), and for each draft one first message to that person in this campaign's angle, specific to them \u2014 their company, role and any past message \u2014 never a generic template. DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. START: a person approves the audience, the offer and the list, then reviews each proposed message in its channel and approves it; nothing goes out before they approve it, and the agent never sends. Once a message is sent, follow the cadence, so "Follow up on leads in outreach" drafts the next one. CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead's lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead's next action: after the first message, nextAction "Follow up with <name> if no reply" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction "Follow up with <name> a last time if no reply" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction "No reply after two follow-ups: nurture or drop <name>" and nextActionAt the sent day + 7 days, and when that date passes with no reply, "Follow up on leads in outreach" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and "Follow up on leads in outreach" reads it and proposes the next step. FINISH: when every person written to has replied or their follow-ups ended in silence, record in the campaign notes how many messages were sent (the outbound messages in their channels), how many got a reply and how many led to a booked meeting. Pausing or cancelling is a person's call.`,
13883
+ "goalTemplate": `Run this outreach campaign ({campaign}) on {channel}; each stage moves its status. If its type is not outreach, stop and say so. PREPARE: write the target audience, the value proposition and the call to action. Pick the people to reach from the pod (and public sources when the pod has too few), resolving each one before creating anything, link each as a member of the campaign with the lead role (lead source cold-outreach when it has none), and for each draft one first message to that person in this campaign's angle, specific to them \u2014 their company, role and any past message \u2014 never a generic template. DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. A LinkedIn message is drafted whatever their reach: the pod holds a draft to someone who is not a connection yet, shows it with the next step and lets it go out once they connect, so never hold a draft back for want of a connection. Draft it, then, in this order: (1) a person with no linkedinUrl: find their profile with linkedin_find_profile (their full name and company); on a match, propose its linkedinUrl and linkedinProviderId on the person for a person to approve; on none or several, ask the person to choose among its candidates (the process "Find their LinkedIn" does this for one lead). (2) Read their linkedinConnection, which only the pod writes (never set it or linkedinInvitedAt): connected, nothing more; none or not set, ask to connect with linkedin_send_invite (it refuses someone already connected or already invited: then nothing more); pending, the draft waits for them; ignored, draft by email instead when they have an email address. While the draft waits for them to connect, set the lead's nextAction to "Wait for <name> to accept the LinkedIn request" and its nextActionAt 7 days later. An email message needs none of this. START: a person approves the audience, the offer and the list, then reviews each proposed message in its channel and approves it; nothing goes out before they approve it, and the agent never sends. Once a message is sent, follow the cadence, so "Follow up on leads in outreach" drafts the next one. CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead's lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead's next action: after the first message, nextAction "Follow up with <name> if no reply" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction "Follow up with <name> a last time if no reply" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction "No reply after two follow-ups: nurture or drop <name>" and nextActionAt the sent day + 7 days, and when that date passes with no reply, "Follow up on leads in outreach" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and "Follow up on leads in outreach" reads it and proposes the next step. FINISH: when every person written to has replied or their follow-ups ended in silence, record in the campaign notes how many messages were sent (the outbound messages in their channels), how many got a reply and how many led to a booked meeting. Pausing or cancelling is a person's call.`,
13796
13884
  "subjectProfile": {
13797
13885
  "profileSlug": "campaign",
13798
13886
  "statusProperty": "campaign-status"
@@ -33443,6 +33531,19 @@ function collectBaseProfileSlugs(data, resolveDep, seen = /* @__PURE__ */ new Se
33443
33531
  }
33444
33532
  return out;
33445
33533
  }
33534
+ var TEMPLATE_DOCUMENT_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
33535
+ function composeBaseDocumentSlugs(data, resolveDep, seen = /* @__PURE__ */ new Set()) {
33536
+ const out = /* @__PURE__ */ new Set();
33537
+ for (const dep of data.dependencies || []) {
33538
+ if (dep.relation !== "compose" || seen.has(dep.slug)) continue;
33539
+ seen.add(dep.slug);
33540
+ const base = resolveDep(dep.slug);
33541
+ if (!base) continue;
33542
+ for (const d of base.documents || []) if (d?.slug) out.add(d.slug);
33543
+ for (const s of composeBaseDocumentSlugs(base, resolveDep, seen)) out.add(s);
33544
+ }
33545
+ return out;
33546
+ }
33446
33547
  function collectProfileSlugRefs(node, path, out) {
33447
33548
  if (node == null || typeof node !== "object") return;
33448
33549
  if (Array.isArray(node)) {
@@ -34625,6 +34726,56 @@ function validateTemplate(authored, authoredOpts = {}) {
34625
34726
  );
34626
34727
  }
34627
34728
  }
34729
+ const documentSlugs = /* @__PURE__ */ new Set();
34730
+ for (const [i, d] of (tpl.documents || []).entries()) {
34731
+ const where = `documents[${i}]`;
34732
+ if (typeof d?.slug !== "string" || !TEMPLATE_DOCUMENT_SLUG_RE.test(d.slug)) {
34733
+ add(
34734
+ "document-slug-invalid",
34735
+ `${where}.slug`,
34736
+ `${where}.slug "${d?.slug ?? ""}" must be kebab-case (a-z, 0-9, -): the space knows the document by it`
34737
+ );
34738
+ continue;
34739
+ }
34740
+ if (documentSlugs.has(d.slug))
34741
+ add(
34742
+ "document-slug-duplicate",
34743
+ `${where}.slug`,
34744
+ `document slug "${d.slug}" is declared twice`
34745
+ );
34746
+ documentSlugs.add(d.slug);
34747
+ if (typeof d.title !== "string" || !d.title.trim())
34748
+ add(
34749
+ "document-title-required",
34750
+ `${where}.title`,
34751
+ `document "${d.slug}" needs a title (its name)`
34752
+ );
34753
+ if (typeof d.content !== "string")
34754
+ add(
34755
+ "document-content-required",
34756
+ `${where}.content`,
34757
+ `document "${d.slug}" needs a markdown content (it may be empty)`
34758
+ );
34759
+ if (d.scope !== void 0 && d.scope !== "space" && d.scope !== "pod")
34760
+ add(
34761
+ "document-scope-unknown",
34762
+ `${where}.scope`,
34763
+ `document "${d.slug}" scope "${String(d.scope)}" is not one of: space, pod`
34764
+ );
34765
+ }
34766
+ const reachableDocumentSlugs = /* @__PURE__ */ new Set([
34767
+ ...documentSlugs,
34768
+ ...composeBaseDocumentSlugs(tpl, resolveDepFrom(opts))
34769
+ ]);
34770
+ for (const [i, si] of (tpl.layout?.sidebarItems || []).entries()) {
34771
+ if (si.kind !== "document") continue;
34772
+ if (!si.documentSlug || !reachableDocumentSlugs.has(si.documentSlug))
34773
+ add(
34774
+ "sidebar-document-unknown",
34775
+ `layout.sidebarItems[${i}]`,
34776
+ `layout.sidebarItems ("${si.label ?? si.documentSlug ?? "document"}") names document "${si.documentSlug ?? ""}", which this template (or a base it composes onto) does not declare in documents[]`
34777
+ );
34778
+ }
34628
34779
  for (const dt of tpl.displayTemplates || []) {
34629
34780
  if (dt.entityType && !known(dt.entityType)) {
34630
34781
  add(
@@ -34932,6 +35083,9 @@ function toWorkspaceDefinition(slugOrYaml, nameOverride) {
34932
35083
  proposalId,
34933
35084
  profiles: tpl.profiles,
34934
35085
  views: tpl.views ?? [],
35086
+ // Absent when the template has none, so a template without documents
35087
+ // compiles to the same definition (and content hash) as before.
35088
+ ...tpl.documents?.length ? { documents: tpl.documents } : {},
34935
35089
  entityLinks: tpl.entityLinks ?? [],
34936
35090
  seedEntities: tpl.seedEntities ?? [],
34937
35091
  suggestedRelations: tpl.suggestedRelations,
@@ -35017,6 +35171,7 @@ function toPackageDefinition(slugOrYaml) {
35017
35171
  defaultSources: ws.defaultSources,
35018
35172
  profiles: tpl.profiles,
35019
35173
  views: tpl.views ?? [],
35174
+ ...tpl.documents?.length ? { documents: tpl.documents } : {},
35020
35175
  suggestedEntities: tpl.seedEntities ?? [],
35021
35176
  suggestedRelations: tpl.suggestedRelations,
35022
35177
  displayTemplates: tpl.displayTemplates,