@synap-core/workspace-templates 0.11.0 → 0.23.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 (249) hide show
  1. package/README.md +483 -0
  2. package/automations/bookmark-enrichment.automation.json +321 -0
  3. package/automations/github-pr-events.automation.json +505 -0
  4. package/automations/mail-feed.automation.json +86 -0
  5. package/automations/ship-a-change-checks-green.automation.json +106 -0
  6. package/automations/ship-a-change-checks-red.automation.json +106 -0
  7. package/automations/ship-a-change-deployed.automation.json +56 -0
  8. package/automations/ship-a-change-pr-merged.automation.json +110 -0
  9. package/automations/ship-a-change-pr-opened.automation.json +104 -0
  10. package/automations/url-bookmark-capture.automation.json +132 -0
  11. package/capabilities/agency-skills.capability.json +157 -0
  12. package/capabilities/apify.capability.json +265 -0
  13. package/capabilities/apollo.capability.json +120 -0
  14. package/capabilities/arch-backend.capability.json +97 -0
  15. package/capabilities/arch-client-intelligence.capability.json +401 -0
  16. package/capabilities/backblaze-b2.capability.json +205 -0
  17. package/capabilities/cal-com.capability.json +266 -0
  18. package/capabilities/canva.capability.json +274 -0
  19. package/capabilities/claude-code-routines.capability.json +172 -0
  20. package/capabilities/claude-managed-agents.capability.json +215 -0
  21. package/capabilities/client-comms-report.capability.json +246 -0
  22. package/capabilities/content-idea-to-production.capability.json +29 -0
  23. package/capabilities/datagouv.capability.json +315 -0
  24. package/capabilities/discord-bot.capability.json +157 -0
  25. package/capabilities/dropbox.capability.json +281 -0
  26. package/capabilities/exa.capability.json +109 -0
  27. package/capabilities/fal-ai.capability.json +83 -0
  28. package/capabilities/fal-media-lab.capability.json +158 -0
  29. package/capabilities/fireflies.capability.json +191 -0
  30. package/capabilities/freellmapi.capability.json +114 -0
  31. package/capabilities/generic-apikey.capability.json +92 -0
  32. package/capabilities/github-merge.capability.json +99 -0
  33. package/capabilities/github-read.capability.json +325 -0
  34. package/capabilities/governance-tighten-recommender.capability.json +81 -0
  35. package/capabilities/jev.capability.json +226 -0
  36. package/capabilities/lead-conversion-enrollment.capability.json +246 -0
  37. package/capabilities/mail-feed.capability.json +277 -0
  38. package/capabilities/mailgun.capability.json +52 -0
  39. package/capabilities/nango-google.capability.json +639 -0
  40. package/capabilities/notion.capability.json +417 -0
  41. package/capabilities/omniroute.capability.json +137 -0
  42. package/capabilities/posthog-analytics.capability.json +258 -0
  43. package/capabilities/postiz.capability.json +467 -0
  44. package/capabilities/proactive-digest.capability.json +190 -0
  45. package/capabilities/proton.capability.json +288 -0
  46. package/capabilities/replicate-rvm.capability.json +97 -0
  47. package/capabilities/research-methods.capability.json +57 -0
  48. package/capabilities/shopify.capability.json +395 -0
  49. package/capabilities/slack.capability.json +369 -0
  50. package/capabilities/stripe.capability.json +420 -0
  51. package/capabilities/telegram-bridge.capability.json +56 -0
  52. package/capabilities/treg.capability.json +102 -0
  53. package/capabilities/unipile-linkedin.capability.json +471 -0
  54. package/capabilities/vercel.capability.json +424 -0
  55. package/dist/define-CPS0n8BV.d.ts +1331 -0
  56. package/dist/index.d.ts +189 -900
  57. package/dist/index.js +24654 -21639
  58. package/dist/index.js.map +1 -1
  59. package/dist/wire.d.ts +222 -0
  60. package/dist/wire.js +77 -0
  61. package/dist/wire.js.map +1 -0
  62. package/package.json +41 -27
  63. package/packs/bookmark-views-pack.json +33 -0
  64. package/packs/crm-pack.json +76 -0
  65. package/packs/customer-profile.json +28 -0
  66. package/packs/deep-research.playbook.json +30 -0
  67. package/packs/event-views-pack.json +33 -0
  68. package/packs/habit-profile.json +27 -0
  69. package/packs/hr-pack.json +54 -0
  70. package/packs/invoice-profile.json +28 -0
  71. package/packs/knowledge-base-pack.json +56 -0
  72. package/packs/meeting-profile.json +29 -0
  73. package/packs/product-profile.json +30 -0
  74. package/packs/project-management-pack.json +49 -0
  75. package/packs/recipe-profile.json +29 -0
  76. package/packs/task-views-pack.json +39 -0
  77. package/retired.yaml +136 -0
  78. package/skills/app-factory-build-slice/SKILL.md +70 -0
  79. package/skills/app-factory-build-slice.catalog.json +17 -0
  80. package/skills/app-factory-design-canvas/SKILL.md +96 -0
  81. package/skills/app-factory-design-canvas.catalog.json +17 -0
  82. package/skills/app-factory-design-review/SKILL.md +99 -0
  83. package/skills/app-factory-design-review.catalog.json +18 -0
  84. package/skills/app-factory-research/SKILL.md +123 -0
  85. package/skills/app-factory-research.catalog.json +17 -0
  86. package/skills/bookmark-enrichment/SKILL.md +40 -0
  87. package/skills/carousel-format/carousel-format.skill.json +18 -0
  88. package/skills/collage-broll/collage-broll.skill.json +20 -0
  89. package/skills/content-taste/content-taste.skill.json +18 -0
  90. package/skills/creative-director/creative-director.skill.json +18 -0
  91. package/skills/design-critique/SKILL.md +135 -0
  92. package/skills/design-critique.catalog.json +12 -0
  93. package/skills/designing-with-real-components/SKILL.md +140 -0
  94. package/skills/designing-with-real-components.catalog.json +12 -0
  95. package/skills/edit-loop/edit-loop.skill.json +25 -0
  96. package/skills/facecam-edit/facecam-edit.skill.json +24 -0
  97. package/skills/mapping-the-server/SKILL.md +54 -0
  98. package/skills/math-explainer-manimgx/math-explainer-manimgx.skill.json +20 -0
  99. package/skills/motion-quality-gates/motion-quality-gates.skill.json +20 -0
  100. package/skills/open-design-edit/open-design-edit.skill.json +24 -0
  101. package/skills/reading-client-progress/SKILL.md +65 -0
  102. package/skills/routing-to-channels/SKILL.md +54 -0
  103. package/skills/stellar-scf-grants-advisor/ASSOCIATE-CHEATSHEET.md +133 -0
  104. package/skills/stellar-scf-grants-advisor/ASSOCIATE-ONBOARDING-GUIDE.md +761 -0
  105. package/skills/stellar-scf-grants-advisor/CLIENT-FAQ.md +181 -0
  106. package/skills/stellar-scf-grants-advisor/PARTNER-HANDOVER.md +130 -0
  107. package/skills/stellar-scf-grants-advisor/SKILL.md +102 -0
  108. package/skills/stellar-scf-grants-advisor/examples/README.md +43 -0
  109. package/skills/stellar-scf-grants-advisor/examples/chat-summary-templates.md +158 -0
  110. package/skills/stellar-scf-grants-advisor/examples/corpus-comparable-rows.md +136 -0
  111. package/skills/stellar-scf-grants-advisor/examples/q10-team-rewrites.md +109 -0
  112. package/skills/stellar-scf-grants-advisor/examples/q2-description-rewrites.md +88 -0
  113. package/skills/stellar-scf-grants-advisor/examples/q6-integration-rewrites.md +107 -0
  114. package/skills/stellar-scf-grants-advisor/examples/tranche-structures.md +176 -0
  115. package/skills/stellar-scf-grants-advisor/reference/01-methodology.md +96 -0
  116. package/skills/stellar-scf-grants-advisor/reference/02-scoring-framework.md +199 -0
  117. package/skills/stellar-scf-grants-advisor/reference/03-rejection-patterns.md +241 -0
  118. package/skills/stellar-scf-grants-advisor/reference/04-winner-patterns.md +120 -0
  119. package/skills/stellar-scf-grants-advisor/reference/05-comeback-patterns.md +92 -0
  120. package/skills/stellar-scf-grants-advisor/reference/06-budget-benchmarks.md +125 -0
  121. package/skills/stellar-scf-grants-advisor/reference/07-integration-list.md +147 -0
  122. package/skills/stellar-scf-grants-advisor/reference/08-abstract-fields.md +258 -0
  123. package/skills/stellar-scf-grants-advisor/reference/09-build-fields.md +259 -0
  124. package/skills/stellar-scf-grants-advisor/reference/10-corpus-winners.md +135 -0
  125. package/skills/stellar-scf-grants-advisor/reference/11-corpus-rejects.md +267 -0
  126. package/skills/stellar-scf-grants-advisor/reference/12-client-communication.md +131 -0
  127. package/skills/stellar-scf-grants-advisor/reference/13-excel-output-spec.md +199 -0
  128. package/skills/stellar-scf-grants-advisor/reference/14-worked-examples.md +160 -0
  129. package/skills/stellar-scf-grants-advisor/templates/abstract-review-builder.py +253 -0
  130. package/skills/stellar-scf-grants-advisor/templates/build-review-builder.py +213 -0
  131. package/skills/video-direction/video-direction.skill.json +20 -0
  132. package/skills/video-style-vocabulary/video-style-vocabulary.skill.json +20 -0
  133. package/skills/video-toolbox/video-toolbox.skill.json +20 -0
  134. package/src/__fixtures__/lifecycle-presets.flat-before.json +205 -0
  135. package/src/agency-os.yaml +122 -0
  136. package/src/agent-os-skill-slugs.test.ts +65 -0
  137. package/src/apify-social-reader.test.ts +317 -0
  138. package/src/base.template.test.ts +25 -19
  139. package/src/base.yaml +95 -39
  140. package/src/blockchain-ecosystem.yaml +1 -0
  141. package/src/brand-library.yaml +525 -14
  142. package/src/builder-dev-fold.template.test.ts +113 -0
  143. package/src/builder.yaml +798 -92
  144. package/src/business-model-catalog-description.test.ts +47 -0
  145. package/src/business-model-grp.template.test.ts +214 -0
  146. package/src/business-model.yaml +598 -0
  147. package/src/catalog-safety.test.ts +198 -0
  148. package/src/catalog.template-items.test.ts +75 -0
  149. package/src/catalog.ts +85 -2
  150. package/src/civic-france.yaml +102 -0
  151. package/src/concept-guard.test.ts +28 -8
  152. package/src/concept-guard.ts +2 -2
  153. package/src/content-creation.template.test.ts +52 -0
  154. package/src/content-creation.yaml +192 -0
  155. package/src/content-os.edit-loop.template.test.ts +210 -0
  156. package/src/content-os.publish-due-posts.template.test.ts +261 -0
  157. package/src/content-os.template.test.ts +322 -0
  158. package/src/content-os.video-takes.template.test.ts +338 -0
  159. package/src/content-os.work-a-take.template.test.ts +125 -0
  160. package/src/content-os.yaml +1830 -39
  161. package/src/content-studio-retired.template.test.ts +82 -0
  162. package/src/corpus-flow-validity.test.ts +4 -5
  163. package/src/crm-absorbs-outreach.template.test.ts +117 -0
  164. package/src/crm-build-plan.template.test.ts +128 -0
  165. package/src/crm-lists.template.test.ts +96 -0
  166. package/src/crm-messages-cadence.template.test.ts +164 -0
  167. package/src/crm-one-send-door.template.test.ts +65 -0
  168. package/src/crm.yaml +1456 -181
  169. package/src/decisions-wave.template.test.ts +186 -0
  170. package/src/define.ts +130 -17
  171. package/src/design-to-implementation.template.test.ts +94 -0
  172. package/src/dist-freshness.test.ts +97 -0
  173. package/src/domain-template-type.test.ts +75 -0
  174. package/src/ecosystem.yaml +27 -66
  175. package/src/enterprise-os.yaml +10 -134
  176. package/src/facecam-edit.audio-studio.test.ts +188 -0
  177. package/src/fal-generate-image.test.ts +200 -0
  178. package/src/fal-media-lab-pod-files.test.ts +180 -0
  179. package/src/finance.yaml +254 -1
  180. package/src/foundation.yaml +130 -28
  181. package/src/frame-host-modules.d.mts +6 -0
  182. package/src/frame-host-modules.mjs +58 -0
  183. package/src/generate.mjs +9 -3
  184. package/src/github-merge.test.ts +210 -0
  185. package/src/grants.yaml +57 -17
  186. package/src/hr.yaml +39 -6
  187. package/src/index.ts +36 -1
  188. package/src/intent.test.ts +161 -0
  189. package/src/internal-runbook.yaml +19 -1
  190. package/src/jev.test.ts +356 -0
  191. package/src/legal.yaml +33 -1
  192. package/src/life-os.yaml +65 -240
  193. package/src/lifecycle-coverage.test.ts +124 -0
  194. package/src/lifecycle-declarations.test.ts +252 -0
  195. package/src/lifecycle-presets.test.ts +468 -0
  196. package/src/lifecycle-presets.ts +494 -0
  197. package/src/lifecycle-stuck.test.ts +46 -0
  198. package/src/lifecycle-waits.parity.test.ts +319 -0
  199. package/src/marketing.yaml +108 -4
  200. package/src/meetings-knowledge.yaml +18 -0
  201. package/src/networking.yaml +23 -2
  202. package/src/operations.yaml +304 -4
  203. package/src/pipeline-views.template.test.ts +88 -0
  204. package/src/playbook-goal-placeholders.test.ts +57 -0
  205. package/src/postiz-contract.test.ts +134 -0
  206. package/src/presentation-declarations.test.ts +174 -0
  207. package/src/process-contract.validate.test.ts +352 -0
  208. package/src/project-management.yaml +39 -740
  209. package/src/publish-body.d.mts +29 -0
  210. package/src/publish-body.mjs +74 -0
  211. package/src/publish-body.test.ts +74 -0
  212. package/src/publish-official-lib.test.ts +176 -0
  213. package/src/replicate-rvm.test.ts +504 -0
  214. package/src/research-base.yaml +119 -20
  215. package/src/research-decision.yaml +60 -3
  216. package/src/reserved-profile-slugs.test.ts +83 -0
  217. package/src/retired-goal-kinds.test.ts +58 -0
  218. package/src/role-reference-and-step-domain.test.ts +192 -0
  219. package/src/seed-obligation.test.ts +176 -0
  220. package/src/send-message-maps.test.ts +227 -0
  221. package/src/shared-role-ssot.test.ts +42 -1
  222. package/src/ship-a-change.template.test.ts +177 -0
  223. package/src/skills.test.ts +170 -0
  224. package/src/social.yaml +85 -27
  225. package/src/space-packages.freshness.test.ts +97 -0
  226. package/src/space-packages.generated.ts +40 -0
  227. package/src/space-packages.test.ts +125 -0
  228. package/src/space-packages.ts +73 -0
  229. package/src/start.test.ts +188 -0
  230. package/src/task-intents.test.ts +293 -0
  231. package/src/templates.ts +21639 -21522
  232. package/src/types.ts +443 -60
  233. package/src/unipile-publish-post.test.ts +162 -0
  234. package/src/validate-flow.drift.test.ts +2 -3
  235. package/src/validate-flow.ts +32 -2
  236. package/src/validate-onboarding.test.ts +59 -0
  237. package/src/validate.test.ts +156 -3
  238. package/src/validate.ts +1384 -7
  239. package/src/wire-core.ts +231 -0
  240. package/src/wire.test.ts +159 -0
  241. package/src/wire.ts +107 -0
  242. package/src/__fixtures__/report-automation-flow.json +0 -393
  243. package/src/agent-fleet.yaml +0 -1645
  244. package/src/business-developer.template.test.ts +0 -351
  245. package/src/business-developer.yaml +0 -860
  246. package/src/content-studio.yaml +0 -361
  247. package/src/dev-dashboard.yaml +0 -990
  248. package/src/outreach-comms.yaml +0 -486
  249. package/src/the-arch.yaml +0 -201
@@ -0,0 +1,157 @@
1
+ {
2
+ "apiVersion": "synap/v1",
3
+ "kind": "capability",
4
+ "meta": {
5
+ "slug": "agency-skills",
6
+ "name": "Agency Skills — Outreach & Client Work",
7
+ "description": "Writing skills for agency work: draft outreach and replies, qualify replies, summarize threads, pull action items and build client briefs. Includes Lead Outreach and Client Onboarding playbooks. Outbound messages always wait for your approval.",
8
+ "tags": [
9
+ "capability"
10
+ ],
11
+ "isPublic": true,
12
+ "syncByDefault": true
13
+ },
14
+ "spec": {
15
+ "key": "agency-skills",
16
+ "name": "Agency — AI Know-How",
17
+ "description": "The agency's AI know-how bundle: six instruction skills (prompt/doc skills injected into the agent's turn — no sandboxed code, no connector calls) that teach the agent how to draft outreach, draft replies, qualify replies, summarize threads, extract action items, and assemble a client brief. Plus two process playbooks (lead-outreach, client-onboarding) that sequence those skills + the connector tools into the §2 agency flows. Instruction skills are pure know-how: when the agent runs in a client's thread it gets that client as the subject (contextEntityId), and these skills shape WHAT it writes. Their `requires` lists name the connector tools the skill's output feeds into (e.g. draft-outreach feeds the LinkedIn send) — the tools come from the sibling capabilities (unipile-linkedin, nango-google, telegram-bridge); apply those to wire the links. Outbound actions remain proposal-gated and reversible.",
18
+ "params": [],
19
+ "vault": [],
20
+ "tools": [],
21
+ "skills": [
22
+ {
23
+ "name": "draft-outreach",
24
+ "kind": "instruction",
25
+ "scope": "pod",
26
+ "description": "Write a personalized, non-salesy first outreach message to a lead, grounded in their LinkedIn profile and our agency's pitch. Output feeds the LinkedIn send (Unipile), which stays proposal-gated.",
27
+ "requires": [
28
+ "unipile_linkedin"
29
+ ],
30
+ "code": "# Draft Outreach\n\nYou are writing the FIRST message to a lead we just connected with on LinkedIn. The goal is to start a genuine conversation, not to sell. A good first message earns a reply; a salesy one gets ignored.\n\n## Inputs you are given\n- The lead's LinkedIn profile (name, headline, current company, role, recent activity, location).\n- Our agency's pitch / positioning (what we do, who we help, the outcome we deliver).\n- Any prior context about why this lead was sourced (the list they came from, the angle).\n\n## How to write it\n1. **Open with them, not us.** Reference something specific and true from their profile — their role, a company milestone, a post they shared, a shared interest. Generic openers (\"I came across your profile\") are a tell; be specific enough that it could only have been written for this person.\n2. **One sentence of relevance.** Connect what they do to what we do, lightly. Show you understand their world.\n3. **A low-friction invitation.** Ask one easy, open question OR offer something small and useful (an idea, a resource). Never pitch the engagement in message one. No calendar links, no \"hop on a call\" yet.\n4. **Be brief.** 3–5 sentences. LinkedIn rewards short. If it reads like a template, rewrite it.\n5. **Match their register.** Mirror the lead's seniority and tone — peer-to-peer for founders/execs, warm and concrete for operators.\n\n## Hard rules\n- No flattery that isn't earned, no buzzwords (\"synergy\", \"circle back\", \"game-changer\").\n- No more than one ask. No links unless they're genuinely helpful.\n- Use the lead's first name once, naturally.\n- Never invent facts about the lead — only use what's in the profile.\n\n## Output\nReturn ONLY the message text, ready to review and send. No subject line (LinkedIn DMs have none), no preamble, no explanation. The operator reviews and edits before it sends."
31
+ },
32
+ {
33
+ "name": "draft-reply",
34
+ "kind": "instruction",
35
+ "scope": "pod",
36
+ "description": "Draft a proposed reply to a client's mail or Telegram thread, in the client's voice and with full client context. Output feeds the Gmail/Telegram send, which stays proposal-gated and operator-reviewed.",
37
+ "requires": [
38
+ "gmail",
39
+ "telegram"
40
+ ],
41
+ "code": "# Draft Reply\n\nYou are drafting a reply on behalf of the operator, inside a specific client's communication thread (mail or Telegram). The operator will review and edit before it sends — your job is to get them 90% of the way with a reply they can approve in one read.\n\n## Inputs you are given\n- The full thread (most recent message last) — mail or Telegram.\n- The client's context: who they are, the engagement, recent activity, known preferences (e.g. \"prefers async\", \"likes bullet points\", tone).\n- Any open commitments or deadlines relevant to this thread.\n\n## How to write it\n1. **Answer what was actually asked.** Identify the concrete asks/questions in the latest message and address each. Don't dodge, don't pad.\n2. **Match the client's voice and channel.** Mail can be a touch more formal; Telegram is shorter and warmer. Mirror how this client and operator have written earlier in the thread.\n3. **Be specific and committed.** If we owe something, say what and by when. If we need info, ask precisely. Avoid vague reassurance (\"we'll look into it\").\n4. **Acknowledge, then move forward.** One line of acknowledgement if warranted, then the substance. No long apologies.\n5. **Surface, don't hide, uncertainty.** If a question can't be answered without info the operator holds, draft the reply with a clearly-marked placeholder like [confirm timeline with operator] rather than guessing.\n\n## Hard rules\n- Never commit to a date, price, or scope that isn't supported by the thread or client context — flag it for the operator instead.\n- Don't invent facts about the engagement.\n- Keep it tight: clients value brevity. Cut anything that doesn't serve the reply.\n\n## Output\nReturn ONLY the reply body, ready to review and send. For mail, you MAY include a one-line subject ONLY if starting a new subject; otherwise omit it. No commentary about your reasoning — just the draft."
42
+ },
43
+ {
44
+ "name": "qualify-reply",
45
+ "kind": "instruction",
46
+ "scope": "pod",
47
+ "description": "Classify a lead's or client's reply (interested / not-interested / needs-info) and state the single recommended next step. Pure classification — no tools, no sends.",
48
+ "code": "# Qualify Reply\n\nYou are triaging an inbound reply from a lead or client so the operator (and the automation) knows what to do next. Be decisive and honest — a wrong \"interested\" wastes outreach, a wrong \"not\" loses a deal.\n\n## Inputs you are given\n- The reply text.\n- The thread it replied to (what we asked / sent).\n- Light context on the lead/client and where they are in the flow.\n\n## Classify into exactly ONE label\n- **interested** — they signal openness: a question back, a yes, a request to talk, curiosity about what we do. Lukewarm-but-engaged counts as interested.\n- **not-interested** — a clear no, \"not now\", \"please remove me\", silence-with-decline, or an out-of-office that closes the door. Respect it.\n- **needs-info** — they're not saying yes or no; they need something from us first (pricing, a clarification, proof, a different contact). The ball is in our court.\n\n## How to decide\n- Read intent, not just politeness. \"Thanks, this is interesting, but...\" is usually not-interested or needs-info depending on the \"but\".\n- When genuinely ambiguous, prefer **needs-info** over guessing interested/not — it routes to a human-shaped next step instead of a premature send.\n- Detect explicit opt-outs and ALWAYS classify them not-interested, regardless of tone.\n\n## Output\nReturn a compact result with exactly these fields:\n- `label`: one of interested | not-interested | needs-info\n- `confidence`: low | medium | high\n- `reason`: one sentence citing the signal in their reply.\n- `next_step`: one concrete recommended action (e.g. \"send the personalized first message\", \"draft a reply answering their pricing question\", \"mark not-interested and stop outreach\", \"promote to a deal and start onboarding\").\n\nDo not take the action — only recommend it. Every send remains proposal-gated and operator-reviewed."
49
+ },
50
+ {
51
+ "name": "summarize-thread",
52
+ "kind": "instruction",
53
+ "scope": "pod",
54
+ "description": "Condense a mail or Telegram thread into the key points plus the open questions. Pure know-how — no tools.",
55
+ "code": "# Summarize Thread\n\nYou are condensing a communication thread (mail or Telegram) so the operator can grasp it in seconds and pick up where it left off. Accuracy and brevity both matter — a summary that hides an open question is worse than none.\n\n## Inputs you are given\n- The full thread, oldest to newest.\n- Light client/lead context (who the parties are).\n\n## What to produce\n1. **TL;DR** — one or two sentences: what this thread is about and where it stands right now.\n2. **Key points** — 3–6 bullets of the substance: decisions made, commitments given (by us and by them), facts established, dates/numbers that matter. Attribute who said what when it's load-bearing.\n3. **Open questions / awaiting** — the things still unresolved: what we're waiting on from them, what they're waiting on from us, any unanswered question. Be explicit about whose court the ball is in.\n4. **Recommended next step** — one line, only if obvious from the thread.\n\n## Rules\n- Only state what's in the thread. Never infer commitments that weren't made.\n- Preserve specifics — exact dates, amounts, names — don't blur them into \"soon\" or \"some\".\n- If the thread is short, keep the summary shorter than the thread; don't pad.\n\n## Output\nReturn the four sections above as plain markdown. No preamble."
56
+ },
57
+ {
58
+ "name": "extract-action-items",
59
+ "kind": "instruction",
60
+ "scope": "pod",
61
+ "description": "Turn a thread (mail/Telegram) into a list of concrete, assignable tasks. Pure know-how — no tools.",
62
+ "code": "# Extract Action Items\n\nYou are turning a communication thread into a clean list of concrete tasks the team can act on. A good action item is unambiguous about WHAT and WHO; a vague one (\"follow up\") is useless.\n\n## Inputs you are given\n- The full thread (mail or Telegram).\n- Client/lead context and who's on our side.\n\n## How to extract\n1. Read the whole thread, then pull out every commitment, request, and unresolved decision that requires someone to DO something.\n2. Phrase each as an imperative starting with a verb: \"Send the revised proposal\", \"Confirm the launch date with the client\", \"Draft the onboarding doc\".\n3. Assign an owner where the thread makes it clear (us vs the client vs a named person). If unclear, mark owner as `unassigned`.\n4. Capture a due date / timeframe ONLY if the thread states or strongly implies one — never invent deadlines.\n5. Note a one-clause source so the operator can trace it back (\"client asked for pricing in last message\").\n\n## Rules\n- One task per item — split compound asks (\"send the deck and book a call\") into two.\n- Don't manufacture tasks the thread doesn't support. If there are no action items, say so plainly.\n- Keep client-side and our-side tasks distinct so the operator sees what we owe vs what we're waiting on.\n\n## Output\nReturn a list where each item has: `task` (imperative), `owner` (us | client | named person | unassigned), `due` (date/timeframe or null), `source` (one clause). These will be proposed as task entities linked to the client — every write stays proposal-gated."
63
+ },
64
+ {
65
+ "name": "client-brief",
66
+ "kind": "instruction",
67
+ "scope": "pod",
68
+ "description": "Assemble a client's context — who they are, the engagement, recent activity — at the start of an agent turn so the agent acts with full grounding. Pure know-how — no tools.",
69
+ "code": "# Client Brief\n\nYou are assembling a fast, accurate briefing on a client at the START of a turn, so everything you do next (drafting a reply, planning work, answering the operator) is grounded in who this client actually is. Think of it as reading the file before the meeting.\n\n## Inputs you are given\n- The client entity (name, company, the engagement, status).\n- Recent activity: latest messages across their channels, recent tasks/deals, recent notes.\n- Any stored preferences or facts about how they like to work.\n\n## What to assemble\n1. **Who they are** — client/company name, the relationship (deal stage or active engagement), and the one-line of what we do for them.\n2. **Where things stand** — the current state of the engagement: what's in flight, what's blocked, what's recently shipped or decided.\n3. **Recent activity** — the last few meaningful touchpoints (a mail, a Telegram exchange, a meeting), newest first, each one clause.\n4. **Open items** — what we owe them and what we're waiting on (pull from open tasks / unresolved threads).\n5. **How to work with them** — known preferences: tone, channel, cadence, sensitivities (e.g. \"prefers async\", \"wants weekly updates\", \"don't loop in X\").\n\n## Rules\n- Only state what the pod actually knows — never fabricate engagement details or preferences.\n- Lead with what's most decision-relevant for the turn ahead; cut stale or irrelevant history.\n- Keep it scannable — the agent reads this in one pass before acting.\n\n## Output\nReturn the five sections as compact markdown. This is the agent's grounding for the turn; it is not shown to the client."
70
+ }
71
+ ],
72
+ "provides": [],
73
+ "playbooks": [
74
+ {
75
+ "name": "Lead Outreach",
76
+ "description": "Flow 1 — Lead acquisition via LinkedIn outreach from an imported list. The agent works the list one lead at a time, each step proposal-gated and operator-reviewed.",
77
+ "goalTemplate": "Work a list of imported leads into qualified conversations via LinkedIn. For each lead: (1) find their LinkedIn profile via Unipile profile search; (2) send a connection request (proposal-gated); (3) wait for acceptance (delay / scheduled re-check); (4) on acceptance, use the draft-outreach skill to write a personalized, non-salesy first message from the lead's profile + our agency pitch, and propose it for the operator to review and send; (5) on reply, use qualify-reply to classify interest and recommend the next step — if interested, promote the lead to a deal/client and trigger the client-onboarding flow. Every outbound action stays proposal-gated and reversible.",
78
+ "params": [
79
+ {
80
+ "name": "leadSource",
81
+ "label": "Lead source",
82
+ "type": "text",
83
+ "required": false,
84
+ "description": "Where this batch of leads came from (e.g. the monthly CSV name or the campaign angle) — used to ground the outreach."
85
+ }
86
+ ],
87
+ "inputStrategy": {
88
+ "kind": "entity-list",
89
+ "description": "Bound to a list of lead entities (e.g. imported from a monthly CSV); the agent iterates one lead per cycle."
90
+ },
91
+ "channelSpec": {
92
+ "kind": "session",
93
+ "description": "Runs in the lead-outreach session; outreach drafts and qualification post into the session for review."
94
+ },
95
+ "expectedOutputs": [
96
+ {
97
+ "kind": "entity",
98
+ "profileSlug": "person",
99
+ "description": "LinkedIn profiles matched and enriched as person entities."
100
+ },
101
+ {
102
+ "kind": "note",
103
+ "description": "Connection requests and sent first messages recorded for the activity timeline."
104
+ },
105
+ {
106
+ "kind": "deal",
107
+ "description": "Interested leads promoted to deals for the onboarding flow."
108
+ }
109
+ ],
110
+ "schedule": null,
111
+ "executor": "is-agent",
112
+ "status": "draft"
113
+ },
114
+ {
115
+ "name": "Client Onboarding",
116
+ "description": "Flow 3 — One command (a won deal, or /link-client) sets up the whole client context. Idempotent: re-running ensures the pieces exist without duplicating them. Every creation is proposal-gated.",
117
+ "goalTemplate": "Onboard a client into a complete working context. Steps: (1) ensure the client ENTITY exists (create it from the deal/name if absent); (2) ensure the client ROOM + threads exist — a client-comms thread for inbound mail/Telegram and a team+AI thread for work; (3) start a per-client SESSION bound to the client (instantiate with subjectId = the client entity) as the hub linking their mail/Drive/tasks/messages; (4) attach the DEFAULT automations for this client (e.g. draft-reply on inbound mail, intro message on first channel creation) — opt-in per client; (5) link the client's Google DRIVE folder (create + share if absent) via the gdrive tool; (6) link the client's MAIL (the dedicated per-engagement address); (7) PIN the client context to the channel — the Drive link and a client-brief (use the client-brief skill) so every turn starts grounded. Each step is proposal-gated and reversible; re-running is idempotent.",
118
+ "params": [
119
+ {
120
+ "name": "clientName",
121
+ "label": "Client name",
122
+ "type": "text",
123
+ "required": true,
124
+ "description": "The client to onboard (used to find-or-create the client entity)."
125
+ }
126
+ ],
127
+ "inputStrategy": {
128
+ "kind": "entity",
129
+ "description": "Bound to a single client entity (the subject); the session is instantiated with this client as subjectId."
130
+ },
131
+ "channelSpec": {
132
+ "kind": "client-room",
133
+ "description": "Sets up the client's room with a client-comms thread (inbound mail/Telegram) and a team+AI thread (work)."
134
+ },
135
+ "expectedOutputs": [
136
+ {
137
+ "kind": "entity",
138
+ "profileSlug": "client",
139
+ "description": "The client entity, ensured."
140
+ },
141
+ {
142
+ "kind": "channel",
143
+ "description": "The client room + client-comms and team+AI threads."
144
+ },
145
+ {
146
+ "kind": "note",
147
+ "description": "Pinned client context: Drive link + client-brief."
148
+ }
149
+ ],
150
+ "schedule": null,
151
+ "executor": "is-agent",
152
+ "status": "draft"
153
+ }
154
+ ]
155
+ },
156
+ "dependencies": []
157
+ }
@@ -0,0 +1,265 @@
1
+ {
2
+ "apiVersion": "synap/v1",
3
+ "kind": "capability",
4
+ "meta": {
5
+ "slug": "apify",
6
+ "name": "Apify — Web Scraping",
7
+ "description": "Find and run any Apify actor to collect data from public web pages and social posts, then bring the results into your workspace. Public data only; it cannot read private or saved posts. Needs an Apify API token.",
8
+ "tags": [
9
+ "capability"
10
+ ],
11
+ "isPublic": true,
12
+ "syncByDefault": true
13
+ },
14
+ "spec": {
15
+ "key": "apify",
16
+ "name": "Apify — Actor Platform",
17
+ "description": "Full Apify actor-platform access: discover marketplace actors, inspect any actor's real input schema, run any actor generically, plus a convenience skill for the common case (read one public social post by URL). Everything verified against Apify's public API docs (store search, actor/build metadata, sync run, dataset items), not guessed. GOTCHA: this is NOT a Unipile substitute and NOT a private-saved-posts fetcher — Apify scrapes PUBLIC pages only; there is no reliable, ToS-compliant way to read a user's own saved/bookmarked posts on Instagram or TikTok (neither platform exposes that via official API, and cookie-based scraping of private data is fragile and ToS-risky — deliberately NOT implemented). Scheduling recurring actor runs is deliberately NOT implemented here either — use Synap's own automation/playbook scheduling instead of duplicating Apify's scheduler; this capability stays focused on discovery + on-demand execution. Apify actor ids with a `/` are addressed with `~` in the URL path (owner~actor-name) per Apify's REST convention; all metadata endpoints (store, actor, build) wrap results in a top-level `data` object, but run-sync-get-dataset-items returns a bare array — handled per-skill. PRECONDITION: an Apify account with an API token (Console -> Settings -> Integrations -> Personal API tokens).",
18
+ "params": [
19
+ {
20
+ "name": "apifyToken",
21
+ "label": "Apify API token",
22
+ "type": "text",
23
+ "required": true,
24
+ "description": "The Apify API token (Console -> Settings -> Integrations -> Personal API tokens). Stored server-encrypted in the vault; never persisted in the template."
25
+ }
26
+ ],
27
+ "vault": [
28
+ {
29
+ "ref": "apifyTokenSecret",
30
+ "name": "{{name}} Apify API token",
31
+ "value": "{{apifyToken}}",
32
+ "type": "api_key",
33
+ "description": "Apify API token for the apify_api connector."
34
+ }
35
+ ],
36
+ "tools": [
37
+ {
38
+ "name": "apify_api",
39
+ "kind": "api",
40
+ "description": "Authenticated HTTP access to the Apify REST API (https://api.apify.com/v2). The applier injects the vaulted API token into the Authorization header as `Bearer <token>`; its credentialRef is remapped to the runtime vault://<id> at apply time.",
41
+ "credentialRef": "apifyTokenSecret",
42
+ "executor": "is-agent",
43
+ "config": {
44
+ "baseUrl": "https://api.apify.com/v2",
45
+ "auth": {
46
+ "in": "header",
47
+ "name": "Authorization",
48
+ "prefix": "Bearer "
49
+ }
50
+ }
51
+ }
52
+ ],
53
+ "skills": [
54
+ {
55
+ "name": "apify_search_actors",
56
+ "intent": "search_external",
57
+ "kind": "declarative",
58
+ "scope": "pod",
59
+ "description": "Searches the Apify actor marketplace by keyword/category so an agent can discover the right actor instead of guessing an id. No cost, no side effects. Args: { search?: string, category?: string, limit?: number (default 10, max 50) }.",
60
+ "requires": [
61
+ "apify_api"
62
+ ],
63
+ "executionMode": "sync",
64
+ "timeoutSeconds": 30,
65
+ "parameters": {
66
+ "search": "string?",
67
+ "category": "string?",
68
+ "limit": "number?"
69
+ },
70
+ "providerSpec": {
71
+ "tool": "apify_api",
72
+ "method": "GET",
73
+ "pathTemplate": "/store",
74
+ "query": {
75
+ "search": "{{search}}",
76
+ "category": "{{category}}",
77
+ "limit": "{{limit}}"
78
+ },
79
+ "paramMapping": {
80
+ "limit": {
81
+ "default": 10,
82
+ "clampMin": 1,
83
+ "clampMax": 50
84
+ }
85
+ },
86
+ "responseShape": {
87
+ "collectionPath": "data.items",
88
+ "collectionAs": "actors"
89
+ }
90
+ },
91
+ "metadata": {
92
+ "readOnly": true
93
+ }
94
+ },
95
+ {
96
+ "name": "apify_get_actor_schema",
97
+ "intent": "fetch_record",
98
+ "kind": "code",
99
+ "scope": "pod",
100
+ "description": "Fetches one actor's real input schema (field names, types, descriptions, required flags) so an agent knows exactly what to pass to apify_run_actor, instead of guessing. No cost, no side effects (2 metadata GETs, no actor run). Args: { actorId: string (from apify_search_actors, or owner/actor-name) }.",
101
+ "requires": [
102
+ "apify_api"
103
+ ],
104
+ "executionMode": "sync",
105
+ "timeoutSeconds": 30,
106
+ "parameters": {
107
+ "actorId": "string"
108
+ },
109
+ "code": "// Fetch an actor's real input schema via its latest tagged build.\n// Executor wraps this body as (async (args, context) => { <code> }); use args\n// directly and `return`. callProvider resolves apify_api by NAME and injects\n// the vaulted token. Apify metadata endpoints wrap results in a top-level\n// `data` object (verified against Apify's API docs).\nconst actorIdRaw = String(args?.actorId ?? '').trim();\nif (!actorIdRaw) throw new Error('apify_get_actor_schema: actorId is required');\nconst actorId = actorIdRaw.replace(/\\//g, '~');\n\nconst actorRes = await callProvider('apify_api', 'GET', `/actors/${actorId}`);\nconst actor = actorRes?.body?.data ?? actorRes?.data ?? actorRes;\nconst buildId = actor?.taggedBuilds?.latest?.buildId;\nif (!buildId) throw new Error(`apify_get_actor_schema: no latest build found for actor \"${actorIdRaw}\" — check the actorId is correct`);\n\nconst buildRes = await callProvider('apify_api', 'GET', `/actors/${actorId}/builds/${buildId}`);\nconst build = buildRes?.body?.data ?? buildRes?.data ?? buildRes;\nconst inputSchema = build?.actorDefinition?.input ?? null;\n\nreturn {\n actorId: actorIdRaw,\n name: actor?.name ?? null,\n title: actor?.title ?? null,\n description: actor?.description ?? null,\n inputSchema,\n};",
110
+ "metadata": {
111
+ "readOnly": true
112
+ }
113
+ },
114
+ {
115
+ "name": "apify_run_actor",
116
+ "intent": "run_external_job",
117
+ "kind": "code",
118
+ "scope": "pod",
119
+ "description": "Runs ANY Apify actor by id with arbitrary input and returns its results (proposal-gated — billed external run). Use apify_search_actors to find an actor and apify_get_actor_schema to see what input it takes first. Writes nothing: it returns the items, and the caller decides what to keep. Args: { actorId: string, input?: object (actor-specific, see apify_get_actor_schema) }.",
120
+ "requires": [
121
+ "apify_api"
122
+ ],
123
+ "executionMode": "async",
124
+ "timeoutSeconds": 120,
125
+ "parameters": {
126
+ "actorId": "string",
127
+ "input": "object?"
128
+ },
129
+ "code": "// Run any Apify actor synchronously and return its dataset items.\n// Executor wraps this body as (async (args, context) => { <code> }); use args\n// directly and `return`. callProvider resolves apify_api by NAME and injects\n// the vaulted token. run-sync-get-dataset-items returns a BARE array (not\n// wrapped in `data`, unlike the metadata endpoints) — verified against Apify's\n// API docs.\nconst actorIdRaw = String(args?.actorId ?? '').trim();\nif (!actorIdRaw) throw new Error('apify_run_actor: actorId is required');\nconst actorId = actorIdRaw.replace(/\\//g, '~');\nconst input = (args?.input && typeof args.input === 'object') ? args.input : {};\n\nconst res = await callProvider('apify_api', 'POST', `/actors/${actorId}/run-sync-get-dataset-items`, input);\nif (res?.proposed === true) {\n return { success: false, proposed: true, proposalId: res?.proposalId ?? null, actorId: actorIdRaw };\n}\nconst payload = res?.body ?? res;\nconst items = Array.isArray(payload) ? payload : [];\n\n// Writes nothing: the run is billed, so its result must come back whole. A\n// caller (or a specific verb like capture_social_post) decides what to keep.\nreturn { success: true, actorId: actorIdRaw, itemCount: items.length, items };"
130
+ },
131
+ {
132
+ "name": "apify_list_actor_runs",
133
+ "intent": "list_records",
134
+ "kind": "declarative",
135
+ "scope": "pod",
136
+ "description": "Lists past runs of one actor (status, dataset id, timestamps) — use with apify_get_dataset_items to pull results from a specific past run after the fact. No cost, no side effects. Args: { actorId: string, status?: string (e.g. SUCCEEDED), limit?: number (default 10) }.",
137
+ "requires": [
138
+ "apify_api"
139
+ ],
140
+ "executionMode": "sync",
141
+ "timeoutSeconds": 30,
142
+ "parameters": {
143
+ "actorId": "string",
144
+ "status": "string?",
145
+ "limit": "number?"
146
+ },
147
+ "providerSpec": {
148
+ "tool": "apify_api",
149
+ "method": "GET",
150
+ "pathTemplate": "/actors/{{actorId}}/runs",
151
+ "paramMapping": {
152
+ "actorId": {
153
+ "encode": "uri"
154
+ }
155
+ },
156
+ "query": {
157
+ "status": "{{status}}",
158
+ "limit": "{{limit}}",
159
+ "desc": "true"
160
+ },
161
+ "responseShape": {
162
+ "collectionPath": "data.items",
163
+ "collectionAs": "runs"
164
+ }
165
+ },
166
+ "metadata": {
167
+ "readOnly": true
168
+ }
169
+ },
170
+ {
171
+ "name": "apify_get_dataset_items",
172
+ "intent": "list_records",
173
+ "kind": "declarative",
174
+ "scope": "pod",
175
+ "description": "Fetches the result items of one dataset (from apify_list_actor_runs' defaultDatasetId, or any dataset id) — the way to pull results from a PAST run without re-running the actor. No cost, no side effects. Args: { datasetId: string, limit?: number (default 50) }.",
176
+ "requires": [
177
+ "apify_api"
178
+ ],
179
+ "executionMode": "sync",
180
+ "timeoutSeconds": 30,
181
+ "parameters": {
182
+ "datasetId": "string",
183
+ "limit": "number?"
184
+ },
185
+ "providerSpec": {
186
+ "tool": "apify_api",
187
+ "method": "GET",
188
+ "pathTemplate": "/datasets/{{datasetId}}/items",
189
+ "paramMapping": {
190
+ "datasetId": {
191
+ "encode": "uri"
192
+ },
193
+ "limit": {
194
+ "default": 50,
195
+ "clampMin": 1,
196
+ "clampMax": 500
197
+ }
198
+ },
199
+ "query": {
200
+ "limit": "{{limit}}",
201
+ "clean": "true"
202
+ }
203
+ },
204
+ "metadata": {
205
+ "readOnly": true
206
+ }
207
+ },
208
+ {
209
+ "name": "capture_social_post",
210
+ "intent": "read_webpage",
211
+ "kind": "code",
212
+ "scope": "pod",
213
+ "description": "Reads one public Instagram post, reel or carousel, or one TikTok video, from its link: the caption, the author, the date, every picture and the video, and the like, comment and view counts. It saves nothing by itself. Public posts only: a private or deleted post comes back as blocked. It runs on YOUR Apify account, and Apify bills each run, retries included. Instagram's and TikTok's terms forbid automated collection; you are responsible for using it. Args: { url: string, withVideo?: boolean (TikTok only: also download the video file — off unless the person picks it; Apify may bill more per item) }.",
214
+ "requires": [
215
+ "apify_api"
216
+ ],
217
+ "executionMode": "async",
218
+ "timeoutSeconds": 120,
219
+ "parameters": {
220
+ "url": "string",
221
+ "withVideo": "boolean?"
222
+ },
223
+ "urls": [
224
+ "instagram.com/p/*",
225
+ "instagram.com/reel/*",
226
+ "instagram.com/reels/*",
227
+ "instagram.com/tv/*",
228
+ "tiktok.com/@*/video/*",
229
+ "vm.tiktok.com/*",
230
+ "vt.tiktok.com/*"
231
+ ],
232
+ "code": "// A READER: one public Instagram post/reel/carousel or TikTok video by URL,\n// read through an Apify actor's synchronous run, returned in the UrlRead shape\n// (@synap-core/types/url-read). It writes nothing: the pod's read door stamps\n// `via`, normalizes the result and keeps it (the Library item is the one home\n// of a kept post).\n// Executor wraps this body as (async (args, context) => { <code> }); use args\n// directly and `return`. callProvider(tool, method, path, body?) resolves the\n// apify_api tool by NAME, injects the vaulted token as Authorization: Bearer\n// <token>, and uses the tool's config.baseUrl = https://api.apify.com/v2.\n// Apify actor ids with a '/' use '~' in the URL path (owner~actor-name).\n// The URLs accepted here are exactly the verb's declared `urls` (a catalog\n// test runs this body against them).\nconst url = String(args?.url ?? '').trim();\nif (!url) return { status: 'failed', sourceUrl: url, error: 'capture_social_post: url is required' };\n\nlet parsed = null;\ntry { parsed = new URL(url); } catch (_) { parsed = null; }\nconst scheme = parsed ? parsed.protocol.toLowerCase() : '';\nconst host = parsed ? parsed.hostname.toLowerCase().replace(/^www\\./, '') : '';\nconst path = parsed ? parsed.pathname : '';\n\n// The vendor is sent the URL as PARSED (what the checks above saw), never the\n// raw string: WHATWG drops tabs/newlines, so the two can differ.\nlet platform, actorId, body;\nif ((scheme === 'https:' || scheme === 'http:') && host === 'instagram.com' && /^\\/(p|reel|reels|tv)\\/[^/]+(\\/|$)/.test(path)) {\n platform = 'instagram';\n actorId = 'apify~instagram-scraper';\n body = { directUrls: [parsed.href], resultsType: 'posts' };\n} else if ((scheme === 'https:' || scheme === 'http:') && ((host === 'tiktok.com' && /^\\/@[^/]+\\/video\\/[^/]+(\\/|$)/.test(path)) || ((host === 'vm.tiktok.com' || host === 'vt.tiktok.com') && /^\\/[^/]+(\\/|$)/.test(path)))) {\n platform = 'tiktok';\n actorId = 'clockworks~tiktok-scraper';\n // The video file is downloaded only when the person picked \"with video\"\n // (`withVideo`): the actor bills more per item for it.\n body = { postURLs: [parsed.href], shouldDownloadVideos: args?.withVideo === true };\n} else {\n return {\n status: 'blocked',\n sourceUrl: url,\n blocked: { reason: 'unsupported', detail: 'Reads only public instagram.com/p, /reel, /reels and /tv links, and tiktok.com/@user/video, vm.tiktok.com and vt.tiktok.com links.' },\n };\n}\n\n// An Apify HTTP / auth / quota / run failure is NOT caught: it propagates like\n// every other Apify verb's, so the executor keeps its failure class (re-auth,\n// retry later) and the read door maps it to a retryable failure.\nconst res = await callProvider('apify_api', 'POST', `/actors/${actorId}/run-sync-get-dataset-items`, body);\nif (res?.proposed === true) {\n return { status: 'failed', sourceUrl: url, error: 'The Apify run is waiting for approval; nothing was read yet.', proposed: true, proposalId: res?.proposalId ?? null };\n}\nif (typeof res?.status === 'number' && res.status >= 400) {\n throw new Error(`capture_social_post: the Apify run answered HTTP ${res.status}`);\n}\nconst payload = res?.body ?? res;\nconst items = Array.isArray(payload) ? payload : [];\nconst item = items[0];\n\n// Not there, or not public. The actors return an empty dataset, or one item\n// carrying `error` / `errorDescription`, for a private, deleted or missing post.\n// \"Missing\" is checked first: the Instagram actor's \"Empty or private data\"\n// cannot tell the two apart, and a deleted post is the likelier reading.\nconst MISSING = /not.?found|no.?items|empty|deleted|unavailable|does not exist|404|removed/i;\nconst PRIVATE = /private|restricted|login/i;\nif (!item || typeof item !== 'object') {\n return { status: 'blocked', sourceUrl: url, blocked: { reason: 'not_found', detail: 'Apify returned nothing for this link: the post may be deleted, private or region-locked.' } };\n}\nconst itemError = [item.error, item.errorDescription, item.errorMessage].filter((x) => typeof x === 'string' && x).join(': ');\nif (itemError) {\n if (MISSING.test(itemError)) return { status: 'blocked', sourceUrl: url, blocked: { reason: 'not_found', detail: itemError } };\n if (PRIVATE.test(itemError)) return { status: 'blocked', sourceUrl: url, blocked: { reason: 'private', detail: itemError } };\n // Any other actor error is a run failure, not a fact about the post.\n throw new Error(`capture_social_post: Apify could not read this post: ${itemError}`);\n}\n\nconst isUrl = (u) => typeof u === 'string' && /^https?:\\/\\//i.test(u);\nconst num = (n) => (typeof n === 'number' && Number.isFinite(n) ? n : undefined);\nconst firstLine = (s) => (typeof s === 'string' ? s.split('\\n').map((l) => l.trim()).find(Boolean) : undefined);\nconst media = [];\nconst seen = new Set();\nconst add = (m) => {\n if (!isUrl(m.url) || seen.has(m.kind + ' ' + m.url)) return;\n seen.add(m.kind + ' ' + m.url);\n const entry = { url: m.url, kind: m.kind, order: media.length };\n if (m.role) entry.role = m.role;\n if (num(m.width)) entry.width = m.width;\n if (num(m.height)) entry.height = m.height;\n if (num(m.durationSec)) entry.durationSec = m.durationSec;\n media.push(entry);\n};\n\nlet read;\nif (platform === 'instagram') {\n // apify~instagram-scraper's dataset: caption, ownerUsername, ownerFullName,\n // timestamp, url, type (Image | Video | Sidecar), displayUrl (always an\n // image, even for a reel), videoUrl, videoDuration, dimensionsWidth/Height,\n // images (every image of a carousel) and childPosts (each carousel slide,\n // with its own displayUrl / videoUrl), likesCount, commentsCount,\n // videoViewCount / videoPlayCount.\n const children = Array.isArray(item.childPosts) ? item.childPosts.filter((c) => c && typeof c === 'object') : [];\n const slideImages = children.length ? children.map((c) => c.displayUrl) : Array.isArray(item.images) ? item.images : [];\n // The cover: the post's own still. In a carousel it is the first slide, so it\n // is listed once, as that slide.\n if (!slideImages.includes(item.displayUrl)) {\n add({ url: item.displayUrl, kind: 'image', role: 'cover', width: item.dimensionsWidth, height: item.dimensionsHeight });\n }\n add({ url: item.videoUrl, kind: 'video', width: item.dimensionsWidth, height: item.dimensionsHeight, durationSec: item.videoDuration });\n if (children.length) {\n for (const c of children) {\n add({ url: c.displayUrl, kind: 'image', role: 'slide', width: c.dimensionsWidth, height: c.dimensionsHeight });\n add({ url: c.videoUrl, kind: 'video', role: 'slide', width: c.dimensionsWidth, height: c.dimensionsHeight, durationSec: c.videoDuration });\n }\n } else {\n for (const u of slideImages) add({ url: u, kind: 'image', role: 'slide' });\n }\n const author = item.ownerUsername || item.ownerFullName || undefined;\n read = {\n canonicalUrl: isUrl(item.url) ? item.url : undefined,\n title: firstLine(item.caption)?.slice(0, 120),\n author,\n authorUrl: item.ownerUsername ? `https://www.instagram.com/${item.ownerUsername}/` : undefined,\n text: typeof item.caption === 'string' && item.caption.trim() ? item.caption : undefined,\n publishedAt: typeof item.timestamp === 'string' ? item.timestamp : undefined,\n fields: { platform, likes: num(item.likesCount), comments: num(item.commentsCount), views: num(item.videoViewCount) ?? num(item.videoPlayCount) },\n };\n} else {\n // clockworks~tiktok-scraper's dataset: text, createTimeISO, webVideoUrl (the\n // post page), authorMeta { name, profileUrl }, videoMeta { coverUrl,\n // downloadAddr, width, height, duration }, mediaUrls (stored video files,\n // when the actor downloads them), diggCount, commentCount, playCount.\n const meta = item.videoMeta && typeof item.videoMeta === 'object' ? item.videoMeta : {};\n add({ url: meta.coverUrl ?? meta.originalCoverUrl, kind: 'image', role: 'cover', width: meta.width, height: meta.height });\n const videoFile = (Array.isArray(item.mediaUrls) ? item.mediaUrls.find(isUrl) : undefined) ?? meta.downloadAddr;\n add({ url: videoFile, kind: 'video', width: meta.width, height: meta.height, durationSec: meta.duration });\n const am = item.authorMeta && typeof item.authorMeta === 'object' ? item.authorMeta : {};\n read = {\n canonicalUrl: isUrl(item.webVideoUrl) ? item.webVideoUrl : undefined,\n title: firstLine(item.text)?.slice(0, 120),\n author: am.name || am.nickName || undefined,\n authorUrl: isUrl(am.profileUrl) ? am.profileUrl : am.name ? `https://www.tiktok.com/@${am.name}` : undefined,\n text: typeof item.text === 'string' && item.text.trim() ? item.text : undefined,\n publishedAt: typeof item.createTimeISO === 'string' ? item.createTimeISO : undefined,\n fields: { platform, likes: num(item.diggCount), comments: num(item.commentCount), views: num(item.playCount) },\n };\n}\nfor (const k of Object.keys(read.fields)) if (read.fields[k] === undefined) delete read.fields[k];\nfor (const k of Object.keys(read)) if (read[k] === undefined) delete read[k];\nreturn { status: 'ok', sourceUrl: url, ...read, media: media.slice(0, 12) };",
233
+ "metadata": {
234
+ "readOnly": true
235
+ }
236
+ },
237
+ {
238
+ "name": "apify_linkedin_profile",
239
+ "intent": "enrich_entity",
240
+ "kind": "code",
241
+ "scope": "pod",
242
+ "category": "enrichment",
243
+ "description": "Enriches ONE person from a PUBLIC LinkedIn profile URL via the Apify actor curious_coder/linkedin-profile-scraper, and returns normalized fields { jobTitle, city, country, bio, company, email, phone, website } where the actor provides them (billed external run). Also returns scrapedName (the profile's full name) SEPARATELY at the top level — NOT mapped into properties, since the name is the entity title, not a person property. Read-only for the pod — returns data; the caller decides whether to write it (mirrors the CRM's useEnrich enrichLinkedIn). Args: { url: string (a linkedin.com/in/ profile URL) }.",
244
+ "requires": [
245
+ "apify_api"
246
+ ],
247
+ "executionMode": "async",
248
+ "timeoutSeconds": 120,
249
+ "parameters": {
250
+ "url": "string"
251
+ },
252
+ "code": "// Enrich a person from a public LinkedIn profile URL via an Apify actor's\n// synchronous run. Executor wraps this body as (async (args, context) => { <code> });\n// use args directly and `return`. callProvider(tool, method, path, body?) resolves\n// the apify_api tool by NAME, injects the vaulted token as Authorization: Bearer\n// <token>, and uses config.baseUrl = https://api.apify.com/v2. Apify actor ids\n// with a '/' use '~' in the URL path (owner~actor-name). Field map is DEFENSIVE:\n// the actor (curious_coder/linkedin-profile-scraper) output shape drifts across\n// runs/plans, so each field tries its most likely key(s) and is skipped (never\n// thrown) when absent. Confirmed actor keys: headline/jobTitle, occupation,\n// summary, geoLocationName/geoCountryName, companyName, positions[0].companyName,\n// firstName/lastName, publicIdentifier, pictureUrl. email/phone only appear when\n// the actor's findContacts option is enabled (not set here) — mapped defensively\n// in case a future run includes them.\nconst url = String(args?.url ?? '').trim();\nif (!url) throw new Error('apify_linkedin_profile: url is required (a public linkedin.com/in/ profile URL)');\nif (!/linkedin\\.com\\/in\\//i.test(url)) throw new Error('apify_linkedin_profile: url must be a linkedin.com/in/ profile URL');\n\nconst actorId = 'curious_coder~linkedin-profile-scraper';\nconst input = { profileUrls: [{ url }], maxResults: 1 };\n\nconst res = await callProvider('apify_api', 'POST', `/actors/${actorId}/run-sync-get-dataset-items`, input);\nif (res?.proposed === true) {\n return { success: false, proposed: true, proposalId: res?.proposalId ?? null, url };\n}\nconst payload = res?.body ?? res;\nconst items = Array.isArray(payload) ? payload : [];\nconst data = items[0];\nif (!data) throw new Error('apify_linkedin_profile: no data returned — the profile may be private, region-locked, or the URL malformed');\n\nconst mapped = {};\nif (data.jobTitle) mapped.jobTitle = data.jobTitle;\nelse if (data.headline) mapped.jobTitle = data.headline;\nelse if (data.occupation) mapped.jobTitle = data.occupation;\n\nif (typeof data.location === 'string' && data.location) mapped.city = data.location;\nelse if (data.location?.city) mapped.city = data.location.city;\nelse if (data.geoLocationName) mapped.city = data.geoLocationName;\n\nif (typeof data.location === 'object' && data.location?.country) mapped.country = data.location.country;\nelse if (data.geoCountryName) mapped.country = data.geoCountryName;\nelse if (data.countryCode) mapped.country = data.countryCode;\n\nif (data.summary) mapped.bio = data.summary;\nelse if (data.about) mapped.bio = data.about;\n\nconst company = data.companyName\n ?? (Array.isArray(data.experiences) ? data.experiences[0]?.company : undefined)\n ?? (Array.isArray(data.positions) ? data.positions[0]?.companyName : undefined);\nif (company) mapped.company = company;\n\nif (data.email) mapped.email = data.email;\nelse if (data.emailAddress) mapped.email = data.emailAddress;\nelse if (data.contactInfo?.email) mapped.email = data.contactInfo.email;\n\nif (data.phone) mapped.phone = data.phone;\nelse if (Array.isArray(data.phoneNumbers) && data.phoneNumbers[0]) mapped.phone = data.phoneNumbers[0];\nelse if (data.contactInfo?.phone) mapped.phone = data.contactInfo.phone;\n\nconst website = data.website\n ?? data.personalWebsite\n ?? (Array.isArray(data.contactInfo?.websites) ? data.contactInfo.websites[0] : undefined);\nif (website) mapped.website = website;\n\nconst joinedName = [data.firstName, data.lastName].filter(Boolean).join(' ').trim();\nconst scrapedName = data.fullName || joinedName || undefined;\n\nreturn { matched: Object.keys(mapped).length > 0, fields: Object.keys(mapped).length, url, scrapedName, ...mapped };"
253
+ }
254
+ ],
255
+ "provides": [
256
+ "search_external",
257
+ "fetch_record",
258
+ "run_external_job",
259
+ "list_records",
260
+ "read_webpage",
261
+ "enrich_entity"
262
+ ]
263
+ },
264
+ "dependencies": []
265
+ }
@@ -0,0 +1,120 @@
1
+ {
2
+ "apiVersion": "synap/v1",
3
+ "kind": "capability",
4
+ "meta": {
5
+ "slug": "apollo",
6
+ "name": "Apollo — People & Company Enrichment",
7
+ "description": "Look up people and companies in Apollo.io and get clean fields back: email, phone, job title, LinkedIn, industry, company size. Read-only; nothing is created unless you choose to. Needs an Apollo API key.",
8
+ "tags": [
9
+ "capability"
10
+ ],
11
+ "isPublic": true,
12
+ "syncByDefault": true
13
+ },
14
+ "spec": {
15
+ "key": "apollo",
16
+ "name": "Apollo — People & Company Enrichment",
17
+ "description": "B2B enrichment via the Apollo.io API (base https://api.apollo.io/api/v1) using API-key auth. Stores the Apollo API key in the vault, registers one HTTP API tool that authenticates with the `x-api-key` header (raw key, NO Bearer prefix — Apollo's auth scheme, verified against synap-backend's ApolloProvider), and seeds three READ-ONLY enrichment/search skills that return NORMALIZED keys ({ email, phone, jobTitle, linkedinUrl, city, country, company, industry, companySize, website, description }) so an agent (or a radar/CRM playbook) gets clean fields instead of Apollo's raw shape. apollo_enrich_person (POST people/match) is a `code` skill because Apollo nests the phone under `person.phone_numbers[0].sanitized_number` — an array flatten a declarative scalar map can't express. apollo_enrich_company (POST organizations/enrich) is a `code` skill because Apollo keys the endpoint on `domain`: a name-only company must OMIT domain (a declarative body map would emit `domain: \"\"`, which Apollo mis-matches), and the org's own name is deliberately not echoed back as a redundant `company` prop. apollo_search_people (POST mixed_people/search) is a `code` skill (not declarative) DELIBERATELY: the governance-name lint treats a declarative POST as a write, which would mislabel a `search`-named verb; a code skill POSTing to a `/search`-suffixed path is correctly recognized read-only (same reason Exa's search is code). All three are reads with no external side effect — they never create entities; the caller decides what to do with the returned data (mirrors the CRM's useEnrich, which takes the mapped result and files its own governed proposal). GOTCHA: Apollo's people-search often returns a locked placeholder email (email_not_unlocked@domain.com) rather than a real address — pass it through as-is; unlocking is a separate billed action deliberately NOT implemented here. PRECONDITION: an Apollo account with an API key (Settings -> Integrations -> API, an Apollo master API key). Pass it as `apolloApiKey`.",
18
+ "params": [
19
+ {
20
+ "name": "apolloApiKey",
21
+ "label": "Apollo API key",
22
+ "type": "text",
23
+ "required": true,
24
+ "description": "The Apollo.io API key (Settings -> Integrations -> API). Stored server-encrypted in the vault; never persisted in the template."
25
+ }
26
+ ],
27
+ "vault": [
28
+ {
29
+ "ref": "apolloApiKeySecret",
30
+ "name": "{{name}} Apollo API key",
31
+ "value": "{{apolloApiKey}}",
32
+ "type": "api_key",
33
+ "description": "Apollo.io API key for the apollo_api connector."
34
+ }
35
+ ],
36
+ "tools": [
37
+ {
38
+ "name": "apollo_api",
39
+ "kind": "api",
40
+ "description": "Authenticated HTTP access to the Apollo.io REST API (https://api.apollo.io/api/v1). The applier injects the vaulted API key into the `x-api-key` header (raw key, no prefix — Apollo's auth scheme, NOT Bearer); its credentialRef is remapped to the runtime vault://<id> at apply time.",
41
+ "credentialRef": "apolloApiKeySecret",
42
+ "executor": "is-agent",
43
+ "config": {
44
+ "baseUrl": "https://api.apollo.io/api/v1",
45
+ "auth": {
46
+ "in": "header",
47
+ "name": "x-api-key",
48
+ "prefix": ""
49
+ }
50
+ }
51
+ }
52
+ ],
53
+ "skills": [
54
+ {
55
+ "name": "apollo_enrich_person",
56
+ "intent": "enrich_entity",
57
+ "kind": "code",
58
+ "scope": "pod",
59
+ "category": "enrichment",
60
+ "description": "Enriches ONE person via Apollo's people/match and returns normalized contact fields { email, phone, jobTitle, linkedinUrl, city, country, company }. Read-only, no side effects (returns data; the caller decides whether to write it). Pass whatever you know — more signals = better match. Args: { name?: string (full name, split into first/last), firstName?: string, lastName?: string, email?: string, company?: string (organization name) }.",
61
+ "requires": [
62
+ "apollo_api"
63
+ ],
64
+ "executionMode": "sync",
65
+ "timeoutSeconds": 45,
66
+ "parameters": {
67
+ "name": "string?",
68
+ "firstName": "string?",
69
+ "lastName": "string?",
70
+ "email": "string?",
71
+ "company": "string?"
72
+ },
73
+ "code": "// Enrich a single person via Apollo people/match. Executor wraps this body as\n// (async (args, context) => { <code> }); use args directly and `return`.\n// callProvider('apollo_api','POST','/people/match',body) resolves the apollo_api\n// tool by NAME and injects the vaulted key as `x-api-key: <key>` (config.auth).\n// people/match returns { person: {...} }; Apollo nests the phone under\n// person.phone_numbers[0].sanitized_number — the array flatten this code exists\n// for (mirrors synap-app CRM useEnrich enrichPerson).\nlet firstName = String(args?.firstName ?? '').trim();\nlet lastName = String(args?.lastName ?? '').trim();\nif (!firstName && !lastName) {\n const parts = String(args?.name ?? '').trim().split(/\\s+/).filter(Boolean);\n firstName = parts[0] ?? '';\n lastName = parts.slice(1).join(' ');\n}\nconst email = String(args?.email ?? '').trim();\nconst company = String(args?.company ?? '').trim();\n\nconst payload = {};\nif (firstName) payload.first_name = firstName;\nif (lastName) payload.last_name = lastName;\nif (email) payload.email = email;\nif (company) payload.organization_name = company;\nif (Object.keys(payload).length === 0) throw new Error('apollo_enrich_person: provide at least a name/firstName, email, or company to match on');\n\nconst res = await callProvider('apollo_api', 'POST', '/people/match', payload);\nif (res?.proposed === true) {\n return { success: false, proposed: true, proposalId: res?.proposalId ?? null };\n}\nconst body = res?.body ?? res;\nconst person = (body && typeof body === 'object' && body.person) ? body.person : (body ?? {});\n\nconst mapped = {};\nif (person.email) mapped.email = person.email;\nconst phone = Array.isArray(person.phone_numbers) ? person.phone_numbers[0]?.sanitized_number : undefined;\nif (phone) mapped.phone = phone;\nif (person.title) mapped.jobTitle = person.title;\nif (person.linkedin_url) mapped.linkedinUrl = person.linkedin_url;\nif (person.city) mapped.city = person.city;\nif (person.country) mapped.country = person.country;\nconst history = Array.isArray(person.employment_history) ? person.employment_history[0]?.organization_name : undefined;\nif (history && !company) mapped.company = history;\n\nreturn { matched: Object.keys(mapped).length > 0, fields: Object.keys(mapped).length, ...mapped };"
74
+ },
75
+ {
76
+ "name": "apollo_enrich_company",
77
+ "intent": "enrich_entity",
78
+ "kind": "code",
79
+ "scope": "pod",
80
+ "category": "enrichment",
81
+ "description": "Enriches ONE company via Apollo's organizations/enrich and returns normalized firmographic fields { industry, companySize, website, description, linkedinUrl, country }. Read-only, no side effects (returns data; the caller decides whether to write it). A `code` skill (not declarative) DELIBERATELY: Apollo keys this endpoint on `domain`, so a name-only call must OMIT domain entirely — a declarative body map would send `domain: \"\"` (interpolateDeep doesn't prune empty scalars), which Apollo mis-matches. It also does NOT echo organization.name back as a `company` prop: the caller is a company entity that already owns its name as its title, so returning `company` would file a dead self-referential write (mirrors synap-app CRM useEnrich, whose company field set has no `company`). Prefer `domain` (Apollo's primary key for this endpoint); `name` is a best-effort fallback. Args: { domain?: string (e.g. acme.com — no scheme), name?: string (company name) }.",
82
+ "requires": [
83
+ "apollo_api"
84
+ ],
85
+ "executionMode": "sync",
86
+ "timeoutSeconds": 45,
87
+ "parameters": {
88
+ "domain": "string?",
89
+ "name": "string?"
90
+ },
91
+ "code": "// Enrich ONE company via Apollo organizations/enrich. Executor wraps this body\n// as (async (args, context) => { <code> }); use args directly and `return`.\n// callProvider('apollo_api','POST','/organizations/enrich',body) resolves the\n// apollo_api tool by NAME and injects the vaulted key as `x-api-key: <key>`.\n// Build the payload omitting empties: Apollo keys this endpoint on `domain`, so a\n// name-only company (no website) must NOT send `domain: \"\"` (a declarative body\n// map would, and Apollo mis-matches on it). organization.name is deliberately NOT\n// mapped back — the caller's company entity already owns its name as its title;\n// echoing `company` would file a dead self-referential prop write.\nconst domain = String(args?.domain ?? '').trim().replace(/^https?:\\/\\//, '').replace(/\\/.*$/, '');\nconst name = String(args?.name ?? '').trim();\nconst payload = {};\nif (domain) payload.domain = domain;\nif (name) payload.name = name;\nif (Object.keys(payload).length === 0) throw new Error('apollo_enrich_company: provide a domain (preferred) or a company name to match on');\n\nconst res = await callProvider('apollo_api', 'POST', '/organizations/enrich', payload);\nif (res?.proposed === true) {\n return { success: false, proposed: true, proposalId: res?.proposalId ?? null };\n}\nconst body = res?.body ?? res;\nconst org = (body && typeof body === 'object' && body.organization) ? body.organization : (body ?? {});\n\nconst mapped = {};\nif (org.industry) mapped.industry = org.industry;\nif (org.estimated_num_employees) mapped.companySize = org.estimated_num_employees;\nif (org.website_url) mapped.website = org.website_url;\nif (org.short_description) mapped.description = org.short_description;\nif (org.linkedin_url) mapped.linkedinUrl = org.linkedin_url;\nif (org.country) mapped.country = org.country;\n\nreturn { matched: Object.keys(mapped).length > 0, fields: Object.keys(mapped).length, ...mapped };"
92
+ },
93
+ {
94
+ "name": "apollo_search_people",
95
+ "intent": "find_people",
96
+ "kind": "code",
97
+ "scope": "pod",
98
+ "description": "Searches Apollo's people database (mixed_people/search) and returns a normalized list of prospects [{ name, email, jobTitle, linkedinUrl, city, country, company }]. Read-only, no side effects — a discovery primitive a lead-gen playbook composes (search, then resolve-or-create deduplicated entities). NOTE: Apollo often returns a locked placeholder email (email_not_unlocked@domain.com) in search results — passed through as-is; unlocking is a separate billed step not implemented here. Args: { query?: string (keywords), titles?: string[] (job titles, e.g. ['CEO','Head of Growth']), locations?: string[] (e.g. ['France','San Francisco']), page?: number (default 1), perPage?: number (default 10, max 100) }.",
99
+ "requires": [
100
+ "apollo_api"
101
+ ],
102
+ "executionMode": "sync",
103
+ "timeoutSeconds": 45,
104
+ "parameters": {
105
+ "query": "string?",
106
+ "titles": "array?",
107
+ "locations": "array?",
108
+ "page": "number?",
109
+ "perPage": "number?"
110
+ },
111
+ "code": "// Search people via Apollo mixed_people/search. Executor wraps this body as\n// (async (args, context) => { <code> }); use args directly and `return`.\n// callProvider('apollo_api','POST','/mixed_people/search',body) injects the\n// vaulted key as `x-api-key: <key>`. Response = { people: [...], contacts: [...] }.\n// A code skill (not declarative) on purpose: a declarative POST is classified a\n// write by the governance-name lint, which would mislabel this `search` verb;\n// a code POST to a /search path is correctly recognized read-only (Exa precedent).\n// Build the body omitting empty optionals — Apollo rejects empty enum values.\nconst pageN = Number(args?.page);\nconst page = Number.isFinite(pageN) ? Math.max(1, Math.trunc(pageN)) : 1;\nconst perN = Number(args?.perPage);\nconst perPage = Number.isFinite(perN) ? Math.max(1, Math.min(100, Math.trunc(perN))) : 10;\n\nconst body = { page, per_page: perPage };\nif (typeof args?.query === 'string' && args.query.trim()) body.q_keywords = args.query.trim();\nif (Array.isArray(args?.titles) && args.titles.length) body.person_titles = args.titles;\nif (Array.isArray(args?.locations) && args.locations.length) body.person_locations = args.locations;\n\nconst res = await callProvider('apollo_api', 'POST', '/mixed_people/search', body);\nif (res?.proposed === true) {\n return { success: false, proposed: true, proposalId: res?.proposalId ?? null };\n}\nconst payload = res?.body ?? res;\nconst rows = [\n ...(Array.isArray(payload?.people) ? payload.people : []),\n ...(Array.isArray(payload?.contacts) ? payload.contacts : []),\n];\nconst results = rows.map((p) => ({\n name: (p?.name ?? [p?.first_name, p?.last_name].filter(Boolean).join(' ')) || null,\n email: p?.email ?? null,\n jobTitle: p?.title ?? null,\n linkedinUrl: p?.linkedin_url ?? null,\n city: p?.city ?? null,\n country: p?.country ?? null,\n company: p?.organization_name ?? p?.organization?.name ?? null,\n}));\nreturn { query: (typeof args?.query === 'string' ? args.query.trim() : null), page, count: results.length, results };"
112
+ }
113
+ ],
114
+ "provides": [
115
+ "enrich_entity",
116
+ "find_people"
117
+ ]
118
+ },
119
+ "dependencies": []
120
+ }