clawskills-mcp 0.3.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  An MCP (Model Context Protocol) server that exposes [ClawSkills](https://github.com/Shanksg/clawskills) API integration skill docs to AI agents.
4
4
 
5
- ClawSkills currently contains 14 structured Markdown skill docs for SaaS APIs such as Salesforce, HubSpot, GitHub, Jira, Stripe, Notion, Slack, and Linear. Each skill tracks its own API version and `Last validated` date, while `clawskills-mcp` has its own npm version for the server itself.
5
+ ClawSkills currently contains 14 structured Markdown skill docs plus workflow playbooks for cross-tool automations. Each skill tracks its own API version and `Last validated` date, while `clawskills-mcp` has its own npm version for the server itself.
6
6
 
7
7
  ---
8
8
 
@@ -46,6 +46,9 @@ docker run --rm -i clawskills-mcp
46
46
  | `list_skills` | List all available skill slugs with descriptions |
47
47
  | `get_skill` | Fetch a full skill doc or a specific section |
48
48
  | `search_skills` | Full-text search across all skill docs |
49
+ | `list_playbooks` | List all available cross-tool workflow playbooks |
50
+ | `get_playbook` | Fetch a full playbook by slug |
51
+ | `search_playbooks` | Full-text search across all workflow playbooks |
49
52
 
50
53
  ### `list_skills`
51
54
 
@@ -71,17 +74,36 @@ Examples:
71
74
 
72
75
  Returns matching excerpts (±3 lines of context) grouped by skill, capped at 5 matches per skill.
73
76
 
77
+ ### `list_playbooks`
78
+
79
+ No arguments. Returns all available playbooks.
80
+
81
+ ### `get_playbook`
82
+
83
+ | Argument | Type | Required | Description |
84
+ |----------|------|----------|-------------|
85
+ | `name` | string | yes | Playbook slug: `zendesk-jira-bug-escalation`, `hubspot-asana-onboarding`, etc. Fuzzy matched. |
86
+
87
+ ### `search_playbooks`
88
+
89
+ | Argument | Type | Required | Description |
90
+ |----------|------|----------|-------------|
91
+ | `query` | string | yes | Search string, e.g. `"idempotency"`, `"rollback"`, `"closed won"` |
92
+
93
+ Returns matching excerpts (±3 lines of context) grouped by playbook, capped at 5 matches per playbook.
94
+
74
95
  ---
75
96
 
76
97
  ## Configuration
77
98
 
78
- Skills are bundled inside the npm package — no `SKILLS_DIR` configuration needed for the standard `npx` setup.
99
+ Skills and playbooks are bundled inside the npm package — no extra configuration is needed for the standard `npx` setup.
79
100
 
80
- `SKILLS_DIR` is available if you want to point the server at a custom or local skills directory (e.g. your own forks of the docs):
101
+ `SKILLS_DIR` and `PLAYBOOKS_DIR` are available if you want to point the server at custom or local content directories:
81
102
 
82
103
  | Env var | Default | Description |
83
104
  |---------|---------|-------------|
84
105
  | `SKILLS_DIR` | bundled `skills/` inside the package | Absolute path to a directory of `{slug}/skill.md` files |
106
+ | `PLAYBOOKS_DIR` | bundled `playbooks/` inside the package | Absolute path to a directory of `{slug}.md` playbook files |
85
107
 
86
108
  ```json
87
109
  {
@@ -90,7 +112,8 @@ Skills are bundled inside the npm package — no `SKILLS_DIR` configuration need
90
112
  "command": "npx",
91
113
  "args": ["-y", "clawskills-mcp"],
92
114
  "env": {
93
- "SKILLS_DIR": "/path/to/your/skills"
115
+ "SKILLS_DIR": "/path/to/your/skills",
116
+ "PLAYBOOKS_DIR": "/path/to/your/playbooks"
94
117
  }
95
118
  }
96
119
  }
package/dist/index.js CHANGED
@@ -25,6 +25,19 @@ function resolveSkillsDir() {
25
25
  }
26
26
  return candidates[0];
27
27
  }
28
+ function resolvePlaybooksDir() {
29
+ if (process.env.PLAYBOOKS_DIR)
30
+ return process.env.PLAYBOOKS_DIR;
31
+ const candidates = [
32
+ path.resolve(__dirname, "../../playbooks"),
33
+ path.resolve(__dirname, "../playbooks"),
34
+ ];
35
+ for (const candidate of candidates) {
36
+ if (fs.existsSync(candidate))
37
+ return candidate;
38
+ }
39
+ return candidates[0];
40
+ }
28
41
  function resolvePackageVersion() {
29
42
  const packageJsonPath = path.resolve(__dirname, "../package.json");
30
43
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
@@ -49,6 +62,23 @@ function loadSkills(skillsDir) {
49
62
  process.stderr.write(`[clawskills-mcp] Loaded ${skills.size} skills: ${[...skills.keys()].join(", ")}\n`);
50
63
  return skills;
51
64
  }
65
+ function loadPlaybooks(playbooksDir) {
66
+ const playbooks = new Map();
67
+ if (!fs.existsSync(playbooksDir)) {
68
+ process.stderr.write(`[clawskills-mcp] Playbooks directory not found: ${playbooksDir}\n`);
69
+ return playbooks;
70
+ }
71
+ const entries = fs.readdirSync(playbooksDir, { withFileTypes: true });
72
+ for (const entry of entries) {
73
+ if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "INDEX.md")
74
+ continue;
75
+ const slug = entry.name.replace(/\.md$/, "");
76
+ const content = fs.readFileSync(path.join(playbooksDir, entry.name), "utf-8");
77
+ playbooks.set(slug, content);
78
+ }
79
+ process.stderr.write(`[clawskills-mcp] Loaded ${playbooks.size} playbooks: ${[...playbooks.keys()].join(", ")}\n`);
80
+ return playbooks;
81
+ }
52
82
  // ---------------------------------------------------------------------------
53
83
  // Section extraction
54
84
  // ---------------------------------------------------------------------------
@@ -115,12 +145,15 @@ function findSkill(skills, name) {
115
145
  }
116
146
  return undefined;
117
147
  }
118
- function searchSkills(skills, query) {
148
+ function findPlaybook(playbooks, name) {
149
+ return findSkill(playbooks, name);
150
+ }
151
+ function searchDocuments(documents, query) {
119
152
  const results = [];
120
153
  const lowerQuery = query.toLowerCase();
121
154
  const CONTEXT_LINES = 3;
122
155
  const MAX_PER_SKILL = 5;
123
- for (const [slug, content] of skills.entries()) {
156
+ for (const [name, content] of documents.entries()) {
124
157
  const lines = content.split("\n");
125
158
  const excerpts = [];
126
159
  const usedRanges = [];
@@ -140,11 +173,17 @@ function searchSkills(skills, query) {
140
173
  break;
141
174
  }
142
175
  if (excerpts.length > 0) {
143
- results.push({ skill: slug, excerpts });
176
+ results.push({ name, excerpts });
144
177
  }
145
178
  }
146
179
  return results;
147
180
  }
181
+ function searchSkills(skills, query) {
182
+ return searchDocuments(skills, query);
183
+ }
184
+ function searchPlaybooks(playbooks, query) {
185
+ return searchDocuments(playbooks, query);
186
+ }
148
187
  // ---------------------------------------------------------------------------
149
188
  // Skill summary (first meaningful line)
150
189
  // ---------------------------------------------------------------------------
@@ -159,13 +198,15 @@ function skillSummary(content) {
159
198
  // ---------------------------------------------------------------------------
160
199
  // Exports for testing
161
200
  // ---------------------------------------------------------------------------
162
- export { resolveSkillsDir, resolvePackageVersion, loadSkills, extractSection, findSkill, searchSkills, skillSummary };
201
+ export { resolveSkillsDir, resolvePlaybooksDir, resolvePackageVersion, loadSkills, loadPlaybooks, extractSection, findSkill, findPlaybook, searchSkills, searchPlaybooks, skillSummary, };
163
202
  // ---------------------------------------------------------------------------
164
203
  // MCP Server setup
165
204
  // ---------------------------------------------------------------------------
166
205
  const SKILLS_DIR = resolveSkillsDir();
206
+ const PLAYBOOKS_DIR = resolvePlaybooksDir();
167
207
  const PACKAGE_VERSION = resolvePackageVersion();
168
208
  const skills = loadSkills(SKILLS_DIR);
209
+ const playbooks = loadPlaybooks(PLAYBOOKS_DIR);
169
210
  const server = new Server({ name: "clawskills-mcp", version: PACKAGE_VERSION }, { capabilities: { tools: {} } });
170
211
  // List tools
171
212
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -201,11 +242,40 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
201
242
  required: ["query"],
202
243
  },
203
244
  },
245
+ {
246
+ name: "list_playbooks",
247
+ description: "List all available ClawSkills workflow playbooks.",
248
+ inputSchema: { type: "object", properties: {}, required: [] },
249
+ },
250
+ {
251
+ name: "get_playbook",
252
+ description: "Retrieve a ClawSkills workflow playbook by name (slug).",
253
+ inputSchema: {
254
+ type: "object",
255
+ properties: {
256
+ name: { type: "string", description: "Playbook slug, e.g. 'zendesk-jira-bug-escalation'" },
257
+ },
258
+ required: ["name"],
259
+ },
260
+ },
261
+ {
262
+ name: "search_playbooks",
263
+ description: "Search across workflow playbooks for a query string and return matching excerpts with context.",
264
+ inputSchema: {
265
+ type: "object",
266
+ properties: {
267
+ query: { type: "string", description: "Search query, e.g. 'idempotency', 'rollback', 'customer impact'" },
268
+ },
269
+ required: ["query"],
270
+ },
271
+ },
204
272
  ],
205
273
  }));
206
274
  // Call tools
207
275
  const GetSkillSchema = z.object({ name: z.string(), section: z.string().optional() });
276
+ const GetPlaybookSchema = z.object({ name: z.string() });
208
277
  const SearchSkillsSchema = z.object({ query: z.string() });
278
+ const SearchPlaybooksSchema = z.object({ query: z.string() });
209
279
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
210
280
  const { name, arguments: args } = request.params;
211
281
  if (name === "list_skills") {
@@ -218,6 +288,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
218
288
  });
219
289
  return { content: [{ type: "text", text: `Available skills (${skills.size}):\n\n${lines.join("\n")}` }] };
220
290
  }
291
+ if (name === "list_playbooks") {
292
+ if (playbooks.size === 0) {
293
+ return { content: [{ type: "text", text: "No playbooks loaded. Check PLAYBOOKS_DIR." }] };
294
+ }
295
+ const lines = [...playbooks.entries()].map(([slug, content]) => `• **${slug}** — ${skillSummary(content)}`);
296
+ return { content: [{ type: "text", text: `Available playbooks (${playbooks.size}):\n\n${lines.join("\n")}` }] };
297
+ }
221
298
  if (name === "get_skill") {
222
299
  const parsed = GetSkillSchema.safeParse(args);
223
300
  if (!parsed.success) {
@@ -256,13 +333,46 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
256
333
  return { content: [{ type: "text", text: `No results found for "${query}".` }] };
257
334
  }
258
335
  const output = results
259
- .map(({ skill, excerpts }) => {
336
+ .map(({ name: skill, excerpts }) => {
260
337
  const blocks = excerpts.map((e, i) => `--- match ${i + 1} ---\n${e}`).join("\n\n");
261
338
  return `### ${skill}\n\n${blocks}`;
262
339
  })
263
340
  .join("\n\n---\n\n");
264
341
  return { content: [{ type: "text", text: `Search results for "${query}":\n\n${output}` }] };
265
342
  }
343
+ if (name === "get_playbook") {
344
+ const parsed = GetPlaybookSchema.safeParse(args);
345
+ if (!parsed.success) {
346
+ return { content: [{ type: "text", text: `Invalid arguments: ${parsed.error.message}` }], isError: true };
347
+ }
348
+ const slug = findPlaybook(playbooks, parsed.data.name);
349
+ if (!slug) {
350
+ const available = [...playbooks.keys()].join(", ");
351
+ return {
352
+ content: [{ type: "text", text: `Playbook "${parsed.data.name}" not found. Available playbooks: ${available}` }],
353
+ isError: true,
354
+ };
355
+ }
356
+ return { content: [{ type: "text", text: playbooks.get(slug) }] };
357
+ }
358
+ if (name === "search_playbooks") {
359
+ const parsed = SearchPlaybooksSchema.safeParse(args);
360
+ if (!parsed.success) {
361
+ return { content: [{ type: "text", text: `Invalid arguments: ${parsed.error.message}` }], isError: true };
362
+ }
363
+ const { query } = parsed.data;
364
+ const results = searchPlaybooks(playbooks, query);
365
+ if (results.length === 0) {
366
+ return { content: [{ type: "text", text: `No playbook results found for "${query}".` }] };
367
+ }
368
+ const output = results
369
+ .map(({ name: playbook, excerpts }) => {
370
+ const blocks = excerpts.map((e, i) => `--- match ${i + 1} ---\n${e}`).join("\n\n");
371
+ return `### ${playbook}\n\n${blocks}`;
372
+ })
373
+ .join("\n\n---\n\n");
374
+ return { content: [{ type: "text", text: `Playbook search results for "${query}":\n\n${output}` }] };
375
+ }
266
376
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
267
377
  });
268
378
  // ---------------------------------------------------------------------------
@@ -3,9 +3,10 @@ import * as fs from "fs";
3
3
  import * as os from "os";
4
4
  import * as path from "path";
5
5
  import { fileURLToPath } from "url";
6
- import { resolveSkillsDir, resolvePackageVersion, loadSkills, extractSection, findSkill, searchSkills, skillSummary } from "./index.js";
6
+ import { resolveSkillsDir, resolvePlaybooksDir, resolvePackageVersion, loadSkills, loadPlaybooks, extractSection, findSkill, findPlaybook, searchSkills, searchPlaybooks, skillSummary, } from "./index.js";
7
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
8
  const REAL_SKILLS_DIR = path.resolve(__dirname, "../../skills");
9
+ const REAL_PLAYBOOKS_DIR = path.resolve(__dirname, "../../playbooks");
9
10
  // ---------------------------------------------------------------------------
10
11
  // Helpers
11
12
  // ---------------------------------------------------------------------------
@@ -18,6 +19,13 @@ function makeTempSkillsDir(skills) {
18
19
  }
19
20
  return dir;
20
21
  }
22
+ function makeTempPlaybooksDir(playbooks) {
23
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawskills-playbooks-test-"));
24
+ for (const [slug, content] of Object.entries(playbooks)) {
25
+ fs.writeFileSync(path.join(dir, `${slug}.md`), content, "utf-8");
26
+ }
27
+ return dir;
28
+ }
21
29
  const SAMPLE_CONTENT = `# GitHub Skill
22
30
 
23
31
  A skill doc for GitHub API.
@@ -44,11 +52,17 @@ Scopes: repo, read:user, etc.
44
52
  POST /repos/{owner}/{repo}/issues
45
53
  `;
46
54
  let tempDir;
55
+ let tempPlaybooksDir;
47
56
  beforeAll(() => {
48
57
  tempDir = makeTempSkillsDir({ github: SAMPLE_CONTENT, salesforce: "# Salesforce\nCRM platform.\n## Overview\nSalesforce is a CRM." });
58
+ tempPlaybooksDir = makeTempPlaybooksDir({
59
+ "zendesk-jira": "# Zendesk -> Jira\n\nEscalate bugs from support into engineering.\n\n## Idempotency\n\nUse zendesk:{ticket_id}.",
60
+ "hubspot-asana": "# HubSpot -> Asana\n\nCreate onboarding work from closed won deals.",
61
+ });
49
62
  });
50
63
  afterAll(() => {
51
64
  fs.rmSync(tempDir, { recursive: true, force: true });
65
+ fs.rmSync(tempPlaybooksDir, { recursive: true, force: true });
52
66
  });
53
67
  // ---------------------------------------------------------------------------
54
68
  // loadSkills
@@ -86,6 +100,12 @@ describe("resolveSkillsDir", () => {
86
100
  expect(loadSkills(resolveSkillsDir()).has("stripe")).toBe(true);
87
101
  });
88
102
  });
103
+ describe("resolvePlaybooksDir", () => {
104
+ it("prefers the canonical repo-root playbooks directory", () => {
105
+ expect(path.basename(resolvePlaybooksDir())).toBe("playbooks");
106
+ expect(loadPlaybooks(resolvePlaybooksDir()).has("zendesk-jira-bug-escalation")).toBe(true);
107
+ });
108
+ });
89
109
  describe("resolvePackageVersion", () => {
90
110
  it("matches package.json", () => {
91
111
  const packageJsonPath = path.resolve(__dirname, "../package.json");
@@ -94,6 +114,20 @@ describe("resolvePackageVersion", () => {
94
114
  });
95
115
  });
96
116
  // ---------------------------------------------------------------------------
117
+ // loadPlaybooks
118
+ // ---------------------------------------------------------------------------
119
+ describe("loadPlaybooks", () => {
120
+ it("loads markdown files from a directory", () => {
121
+ const playbooks = loadPlaybooks(tempPlaybooksDir);
122
+ expect(playbooks.size).toBe(2);
123
+ expect(playbooks.has("zendesk-jira")).toBe(true);
124
+ });
125
+ it("returns empty map for missing directory", () => {
126
+ const playbooks = loadPlaybooks("/nonexistent/playbooks/path");
127
+ expect(playbooks.size).toBe(0);
128
+ });
129
+ });
130
+ // ---------------------------------------------------------------------------
97
131
  // findSkill
98
132
  // ---------------------------------------------------------------------------
99
133
  describe("findSkill", () => {
@@ -119,6 +153,18 @@ describe("findSkill", () => {
119
153
  expect(findSkill(skills, "zzznomatch")).toBeUndefined();
120
154
  });
121
155
  });
156
+ describe("findPlaybook", () => {
157
+ let playbooks;
158
+ beforeAll(() => {
159
+ playbooks = loadPlaybooks(tempPlaybooksDir);
160
+ });
161
+ it("returns key for exact match", () => {
162
+ expect(findPlaybook(playbooks, "zendesk-jira")).toBe("zendesk-jira");
163
+ });
164
+ it("matches normalized input", () => {
165
+ expect(findPlaybook(playbooks, "zendesk jira")).toBe("zendesk-jira");
166
+ });
167
+ });
122
168
  // ---------------------------------------------------------------------------
123
169
  // extractSection
124
170
  // ---------------------------------------------------------------------------
@@ -153,7 +199,7 @@ describe("searchSkills", () => {
153
199
  it("returns results for a matching query", () => {
154
200
  const results = searchSkills(skills, "Personal Access Token");
155
201
  expect(results.length).toBeGreaterThan(0);
156
- expect(results[0].skill).toBe("github");
202
+ expect(results[0].name).toBe("github");
157
203
  expect(results[0].excerpts.length).toBeGreaterThan(0);
158
204
  });
159
205
  it("is case-insensitive", () => {
@@ -172,6 +218,17 @@ describe("searchSkills", () => {
172
218
  expect(results[0].excerpts.length).toBeLessThanOrEqual(5);
173
219
  });
174
220
  });
221
+ describe("searchPlaybooks", () => {
222
+ let playbooks;
223
+ beforeAll(() => {
224
+ playbooks = loadPlaybooks(tempPlaybooksDir);
225
+ });
226
+ it("returns results for a matching query", () => {
227
+ const results = searchPlaybooks(playbooks, "idempotency");
228
+ expect(results.length).toBeGreaterThan(0);
229
+ expect(results[0].name).toBe("zendesk-jira");
230
+ });
231
+ });
175
232
  // ---------------------------------------------------------------------------
176
233
  // skillSummary
177
234
  // ---------------------------------------------------------------------------
@@ -234,3 +291,25 @@ describe("real skills", () => {
234
291
  }
235
292
  });
236
293
  });
294
+ describe("real playbooks", () => {
295
+ let playbooks;
296
+ beforeAll(() => {
297
+ playbooks = loadPlaybooks(REAL_PLAYBOOKS_DIR);
298
+ });
299
+ it("loads all expected playbooks", () => {
300
+ const knownPlaybooks = [
301
+ "hubspot-asana-onboarding",
302
+ "salesforce-hubspot-lead-sync",
303
+ "zendesk-jira-bug-escalation",
304
+ ];
305
+ for (const slug of knownPlaybooks) {
306
+ expect(playbooks.has(slug), `Missing playbook: ${slug}`).toBe(true);
307
+ }
308
+ });
309
+ it("every playbook has a non-empty summary line", () => {
310
+ for (const [slug, content] of playbooks.entries()) {
311
+ const summary = skillSummary(content);
312
+ expect(summary, `${slug}: empty summary`).not.toBe("");
313
+ }
314
+ });
315
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawskills-mcp",
3
- "version": "0.3.6",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server exposing ClawSkills API integration skill docs to AI agents",
5
5
  "license": "MIT",
6
6
  "author": "ClawSkills Contributors",
@@ -30,12 +30,14 @@
30
30
  },
31
31
  "files": [
32
32
  "dist/",
33
- "skills/"
33
+ "skills/",
34
+ "playbooks/"
34
35
  ],
35
36
  "scripts": {
36
37
  "sync:skills": "node scripts/sync-skills.mjs",
37
38
  "check:skills": "node scripts/check-skills.mjs",
38
39
  "check:freshness": "node scripts/check-skills.mjs --freshness",
40
+ "check:docs": "node scripts/check-docs.mjs",
39
41
  "prepack": "npm run sync:skills",
40
42
  "build": "npm run sync:skills && tsc",
41
43
  "start": "node dist/index.js",
@@ -0,0 +1,32 @@
1
+ # Playbooks Index
2
+
3
+ Workflow playbooks are end-to-end guides for multi-system automations. They complement the per-tool `skill.md` files by defining the orchestration layer: trigger, source of truth, field mapping, idempotency, failure policy, and operational checks.
4
+
5
+ ## Available playbooks
6
+
7
+ | Playbook | Systems | Primary use case |
8
+ |----------|---------|------------------|
9
+ | [Zendesk Ticket -> Jira Bug escalation](./zendesk-jira-bug-escalation.md) | Zendesk, Jira | Escalate support-confirmed defects into engineering workflow |
10
+ | [HubSpot Deal Won -> Asana onboarding kickoff](./hubspot-asana-onboarding.md) | HubSpot, Asana | Start implementation or onboarding work when a deal closes |
11
+ | [Salesforce Lead -> HubSpot Contact sync](./salesforce-hubspot-lead-sync.md) | Salesforce, HubSpot | Mirror new/updated leads into marketing automation without duplicates |
12
+
13
+ ## Playbook template
14
+
15
+ Each playbook should define:
16
+ - Business trigger
17
+ - Systems involved
18
+ - Source of truth
19
+ - Sequence of operations
20
+ - Field mapping
21
+ - Idempotency and dedup keys
22
+ - Retry / partial-failure policy
23
+ - Reconciliation / rollback guidance
24
+ - Observability checks
25
+
26
+ ## Next candidates
27
+
28
+ - Slack incident -> Jira issue
29
+ - GitHub PR -> Slack notification
30
+ - Zendesk -> Salesforce case sync
31
+ - Stripe payment failed -> HubSpot task
32
+ - Notion request -> Asana task
@@ -0,0 +1,76 @@
1
+ # HubSpot Deal Won -> Asana Onboarding Kickoff
2
+
3
+ ## Goal
4
+
5
+ Create onboarding or implementation work in Asana as soon as a HubSpot Deal is marked closed won.
6
+
7
+ ## Systems involved
8
+
9
+ - HubSpot: sales pipeline, deal state, customer/account context
10
+ - Asana: delivery execution, onboarding tasks, project coordination
11
+
12
+ ## Trigger
13
+
14
+ - HubSpot webhook `deal.propertyChange` where `dealstage` moves into the closed-won stage
15
+
16
+ ## Source of truth
17
+
18
+ - Commercial state and customer/account metadata: HubSpot
19
+ - Delivery execution and task/project state: Asana
20
+ - Workflow linkage: integration database or HubSpot custom property storing the Asana `gid`
21
+
22
+ ## Sequence
23
+
24
+ 1. Receive the HubSpot webhook and verify it corresponds to the target pipeline/stage.
25
+ 2. Fetch the Deal, associated Company, and primary Contact.
26
+ 3. Check whether an Asana task/project already exists for the Deal ID.
27
+ 4. Create the onboarding project or kickoff task in Asana.
28
+ 5. Populate notes and custom fields with customer, owner, ARR, plan, and target launch details.
29
+ 6. Persist the Asana `gid` back to HubSpot or your integration store.
30
+ 7. Notify the implementation owner or CSM if required.
31
+
32
+ ## Field mapping
33
+
34
+ | HubSpot | Asana |
35
+ |---------|-------|
36
+ | `dealId` | external mapping / custom field |
37
+ | `dealname` | task/project name |
38
+ | associated company name | notes / title suffix |
39
+ | deal owner | assignee |
40
+ | close date | kickoff anchor or due date |
41
+ | ARR / amount | custom field |
42
+ | onboarding tier | custom field / template choice |
43
+
44
+ ## Idempotency
45
+
46
+ - Dedup key: HubSpot Deal ID
47
+ - Webhook delivery is at-least-once, so check your mapping store before creation
48
+ - If using project templates, ensure only one project is generated per Deal ID
49
+
50
+ ## Retry and partial failure policy
51
+
52
+ - Retry transient Asana failures with bounded backoff
53
+ - If Asana create succeeds but write-back to HubSpot fails, recover by searching the mapping store on the next run
54
+ - Validation errors should move the record into a manual review queue with the Deal ID and failing field
55
+
56
+ ## Reconciliation
57
+
58
+ - Daily job: compare all closed-won deals in the last 30 days against Asana kickoff artifacts
59
+ - Backfill missing Asana `gid` values into HubSpot
60
+ - Flag onboarding projects with no matching active deal
61
+
62
+ ## Rollback guidance
63
+
64
+ - If a Deal reopens or is marked closed-lost shortly after creation, archive or cancel the Asana project/task rather than deleting it
65
+ - Preserve the linkage so repeated stage churn does not create duplicates
66
+
67
+ ## Observability
68
+
69
+ - Count created onboarding projects/tasks per day
70
+ - Alert on webhook processing failures and duplicate prevention hits
71
+ - Track time from closed won to Asana kickoff creation
72
+
73
+ ## Related skills
74
+
75
+ - [`skills/hubspot/skill.md`](../skills/hubspot/skill.md)
76
+ - [`skills/asana/skill.md`](../skills/asana/skill.md)
@@ -0,0 +1,78 @@
1
+ # Salesforce Lead -> HubSpot Contact Sync
2
+
3
+ ## Goal
4
+
5
+ Mirror new or updated Salesforce Leads into HubSpot Contacts so marketing and lifecycle workflows stay current without creating duplicates.
6
+
7
+ ## Systems involved
8
+
9
+ - Salesforce: CRM of record for lead capture and sales qualification
10
+ - HubSpot: marketing automation, nurture, lifecycle segmentation
11
+
12
+ ## Trigger
13
+
14
+ - Scheduled incremental sync using Salesforce `LastModifiedDate`
15
+ - Optional near-real-time trigger from Salesforce CDC or platform events
16
+
17
+ ## Source of truth
18
+
19
+ - Lead identity and sales-owned fields: Salesforce
20
+ - Marketing engagement and automation state: HubSpot
21
+ - Identity dedup anchor: email, with explicit mapping store as a secondary safeguard
22
+
23
+ ## Sequence
24
+
25
+ 1. Query Salesforce Leads modified since the last sync watermark, with a small overlap window.
26
+ 2. Skip converted Leads and Leads with no email.
27
+ 3. Search HubSpot Contacts by email.
28
+ 4. If a Contact exists, update it with mapped fields.
29
+ 5. If no Contact exists, create one and persist the mapping.
30
+ 6. Record sync outcome per Lead so failed rows can be retried independently.
31
+ 7. Advance the watermark only after all rows are processed or quarantined.
32
+
33
+ ## Field mapping
34
+
35
+ | Salesforce Lead | HubSpot Contact |
36
+ |-----------------|-----------------|
37
+ | `Lead.Id` | custom property `salesforce_lead_id` or integration DB |
38
+ | `Email` | `email` |
39
+ | `FirstName` | `firstname` |
40
+ | `LastName` | `lastname` |
41
+ | `Company` | `company` |
42
+ | `Phone` | `phone` |
43
+ | `LeadSource` | custom source property |
44
+ | `Status` | derived `lifecyclestage` mapping |
45
+
46
+ ## Idempotency
47
+
48
+ - Primary dedup key: normalized email
49
+ - Secondary correlation key: Salesforce Lead ID
50
+ - Do not use HubSpot batch create blindly for unknown contacts; search first
51
+
52
+ ## Retry and partial failure policy
53
+
54
+ - Retry transient HubSpot rate-limit and availability errors per shard
55
+ - Quarantine `400` validation failures with Lead ID, email, and failing field names
56
+ - If one row fails, continue the rest of the batch and surface a remediation report
57
+
58
+ ## Reconciliation
59
+
60
+ - Daily compare: Salesforce Leads updated in the last 24 hours vs HubSpot Contacts carrying `salesforce_lead_id`
61
+ - Repair records where the ID mapping is missing but email matches
62
+ - Flag email collisions where multiple Salesforce Leads compete for one HubSpot Contact
63
+
64
+ ## Rollback guidance
65
+
66
+ - Avoid destructive deletes across systems
67
+ - If a bad sync overwrites HubSpot fields, restore from HubSpot history or your sync audit log and re-run the corrected mapping
68
+
69
+ ## Observability
70
+
71
+ - Track processed, created, updated, quarantined, and retried rows per run
72
+ - Alert on spikes in duplicate resolution or validation failures
73
+ - Monitor watermark lag so the sync does not silently fall behind
74
+
75
+ ## Related skills
76
+
77
+ - [`skills/salesforce/skill.md`](../skills/salesforce/skill.md)
78
+ - [`skills/hubspot/skill.md`](../skills/hubspot/skill.md)
@@ -0,0 +1,79 @@
1
+ # Zendesk Ticket -> Jira Bug Escalation
2
+
3
+ ## Goal
4
+
5
+ Escalate a Zendesk support ticket into Jira when support determines the issue is a product defect that engineering must track and resolve.
6
+
7
+ ## Systems involved
8
+
9
+ - Zendesk: intake, customer communication, support triage
10
+ - Jira: engineering backlog, bug lifecycle, fix tracking
11
+
12
+ ## Trigger
13
+
14
+ Any of:
15
+ - Zendesk ticket gets tag `engineering`
16
+ - Custom field `issue_type` changes to `bug`
17
+ - Ticket priority becomes `urgent` and product area is set
18
+
19
+ ## Source of truth
20
+
21
+ - Customer communication and ticket state: Zendesk
22
+ - Engineering execution state: Jira
23
+ - Escalation linkage: integration database or mirrored external ID field
24
+
25
+ ## Sequence
26
+
27
+ 1. Receive the Zendesk webhook or consume the ticket via Incremental Export.
28
+ 2. Build a deterministic correlation key: `zendesk:{ticket_id}`.
29
+ 3. Search Jira for an existing issue with that correlation key in a custom field or label.
30
+ 4. If no issue exists, create a Jira Bug with summary, reproduction notes, impact, and Zendesk URL.
31
+ 5. If an issue already exists, update priority, labels, assignee, and current reproduction context.
32
+ 6. Write the Jira issue key back to Zendesk as an internal note or custom field.
33
+ 7. Optionally mirror selected Jira status changes back to Zendesk after state mapping is finalized.
34
+
35
+ ## Field mapping
36
+
37
+ | Zendesk | Jira |
38
+ |---------|------|
39
+ | `ticket.id` | custom external ID / label |
40
+ | `subject` | `summary` |
41
+ | latest public comment | `description` |
42
+ | `priority` | `priority` |
43
+ | `tags` | `labels` |
44
+ | requester org / account | custom customer field or component |
45
+ | product area custom field | component / label |
46
+
47
+ ## Idempotency
48
+
49
+ - Dedup key: `zendesk:{ticket_id}`
50
+ - Before create, always search Jira by dedup key
51
+ - Zendesk webhooks are at-least-once delivery, so the create path must be safe to replay
52
+
53
+ ## Retry and partial failure policy
54
+
55
+ - Retry transient Jira failures (`429`, `503`) with bounded exponential backoff
56
+ - Do not retry `400` field validation errors blindly; quarantine and surface them
57
+ - If Jira create succeeds but Zendesk write-back fails, reconcile by searching Jira with the dedup key on the next run
58
+
59
+ ## Reconciliation
60
+
61
+ - Daily job: compare Zendesk tickets tagged `engineering` against Jira bugs with the dedup key
62
+ - Repair missing backlinks in Zendesk
63
+ - Flag Jira issues missing an active Zendesk ticket counterpart
64
+
65
+ ## Rollback guidance
66
+
67
+ - If a Jira issue was created in error, transition it to a closed/cancelled state instead of deleting it
68
+ - Remove the escalation tag or set a custom field in Zendesk to prevent automatic recreation
69
+
70
+ ## Observability
71
+
72
+ - Count escalations per day
73
+ - Alert on create failures, write-back failures, or rising duplicate-detection rates
74
+ - Track mean time from Zendesk escalation to Jira issue creation
75
+
76
+ ## Related skills
77
+
78
+ - [`skills/zendesk/skill.md`](../skills/zendesk/skill.md)
79
+ - [`skills/jira/skill.md`](../skills/jira/skill.md)
package/skills/INDEX.md CHANGED
@@ -3,6 +3,7 @@
3
3
  A curated library of integration skill documents for the most common SaaS platforms used in go-to-market and operations workflows. Each skill doc teaches an agent or automation builder how to reliably use that platform's API for high-value, production-grade workflows.
4
4
 
5
5
  → See [ROADMAP.md](./ROADMAP.md) for the prioritized build plan, phased milestones, and governance model.
6
+ → Cross-tool workflow guides now live in [`../playbooks/INDEX.md`](../playbooks/INDEX.md).
6
7
 
7
8
  ---
8
9
 
package/skills/ROADMAP.md CHANGED
@@ -12,14 +12,14 @@ This skill set enables end-to-end automation and integration across the most com
12
12
 
13
13
  ---
14
14
 
15
- ## Current state (as of 2026-03-10)
15
+ ## Current state (as of 2026-03-12)
16
16
 
17
17
  ### What's shipped
18
18
 
19
19
  | Layer | Status |
20
20
  |-------|--------|
21
21
  | **14 skill docs** — Monday.com, Salesforce, Jira, Dynamics 365, HubSpot, ServiceNow, Zendesk, Asana, GitHub, Figma, Slack, Stripe, Notion, Linear | ✅ Live |
22
- | **MCP server** — `clawskills-mcp` v0.3.4 on npm; `list_skills`, `get_skill`, `search_skills` | ✅ Live |
22
+ | **MCP server** — `clawskills-mcp` v0.4.0 on npm; `list_skills`, `get_skill`, `search_skills` | ✅ Live |
23
23
  | **CI pipeline** — build + test on every push/PR via `ci.yml` | ✅ Live |
24
24
  | **Release automation** — `release.yml` workflow_dispatch → bump, tag, npm publish via OIDC | ✅ Live |
25
25
  | **Test suite** — Vitest unit tests + real-skills validation (required sections, ≥5 KB, all 14 tools) | ✅ Live |
@@ -83,7 +83,7 @@ The repo now needs tighter alignment between the canonical `skills/` directory,
83
83
  | Channel | Status | Next action |
84
84
  |---------|--------|-------------|
85
85
  | GitHub (public) | Ready to flip | Make repo public |
86
- | npm (`clawskills-mcp`) | ✅ Live — v0.3.4 | Maintain via `release.yml` |
86
+ | npm (`clawskills-mcp`) | ✅ Live — v0.4.0 | Maintain via `release.yml` |
87
87
  | MCP registry listings | Not submitted | Submit to glama.ai, mcp.so, smithery.ai after public launch |
88
88
  | Docs website | Planned | Mintlify — fastest path to SEO-friendly browsable docs |
89
89
  | Lane A vs B | TBD | Revisit after first traction signals from public launch |
@@ -108,6 +108,43 @@ Each recipe lives as a `## Cross-tool recipe` appendix section in the relevant p
108
108
 
109
109
  ---
110
110
 
111
+ ### Priority 5 — Workflow playbooks and retrieval UX
112
+
113
+ The next product layer is workflow-first guidance, not only tool-by-tool references. This means shipping opinionated playbooks that show how multiple systems fit together operationally, then exposing them cleanly through MCP.
114
+
115
+ **Backlog:**
116
+ - Add a `playbooks/` directory with high-value cross-tool workflows
117
+ - Ship 3 flagship playbooks first:
118
+ - Zendesk Ticket -> Jira Bug escalation
119
+ - HubSpot Deal Won -> Asana onboarding kickoff
120
+ - Salesforce Lead -> HubSpot Contact sync
121
+ - Expand to additional high-frequency workflows:
122
+ - Slack incident -> Jira issue
123
+ - GitHub PR -> Slack notification
124
+ - Zendesk -> Salesforce case sync
125
+ - Stripe payment failed -> HubSpot task
126
+ - Notion request -> Asana task
127
+ - Standardize each playbook format:
128
+ - business trigger
129
+ - systems involved
130
+ - source-of-truth decision
131
+ - field mapping
132
+ - idempotency strategy
133
+ - retry and partial-failure policy
134
+ - reconciliation and rollback guidance
135
+ - observability / alerting checks
136
+ - Expose playbooks through MCP after the doc layer stabilizes:
137
+ - `list_playbooks`
138
+ - `get_playbook`
139
+ - search/ranking that prefers recipes and playbooks over generic mentions
140
+
141
+ **Acceptance criteria:**
142
+ - [ ] At least 3 production-grade playbooks checked into the repo
143
+ - [ ] README and INDEX reference the playbooks layer
144
+ - [ ] MCP server plan defined for playbook retrieval and discovery
145
+
146
+ ---
147
+
111
148
  ## Prioritization framework
112
149
 
113
150
  Each skill/recipe is scored on five dimensions (1–5 each):
@@ -685,6 +685,32 @@ curl -X POST \
685
685
 
686
686
  ---
687
687
 
688
+ ### Cross-tool recipe: HubSpot Deal Won -> Asana project kickoff
689
+
690
+ **Goal:** Start onboarding or implementation work in Asana when a HubSpot Deal reaches `closedwon`.
691
+
692
+ **Flow:**
693
+ 1. Receive a HubSpot webhook for `deal.propertyChange`.
694
+ 2. Confirm the new `dealstage` maps to closed-won in the target pipeline.
695
+ 3. Fetch the Deal, associated Company, and primary Contact from HubSpot.
696
+ 4. Create the Asana task or project in the correct team or project template.
697
+ 5. Store the Asana `task_gid` or `project_gid` back in HubSpot or in your integration DB.
698
+
699
+ **Suggested Asana fields:**
700
+
701
+ | Asana field | Source |
702
+ |---|---|
703
+ | `name` | `Onboard {dealname}` |
704
+ | `notes` | Deal URL, customer, ARR, owner, launch context |
705
+ | `assignee` | mapped CSM or implementation owner |
706
+ | `due_on` | kickoff or target launch date |
707
+ | `custom_fields` | plan, ARR, onboarding tier, deal ID |
708
+
709
+ **Operational guardrails:**
710
+ - Prevent duplicates by checking your mapping store before creating a new Asana record.
711
+ - If you use a project template, create the project once and then create the kickoff task inside it.
712
+ - HubSpot webhooks are at-least-once delivery; your creation path must be idempotent.
713
+
688
714
  ## Query patterns & filtering
689
715
 
690
716
  ### Task search
@@ -390,6 +390,67 @@ GET https://api.hubapi.com/crm/v3/imports/{importId}
390
390
  # importStatus: STARTED → PROCESSING → DONE
391
391
  ```
392
392
 
393
+ **When to use Imports vs batch endpoints:**
394
+ - Use `batch/create` or `batch/update` for API-driven writes where you already have structured JSON and need immediate responses.
395
+ - Use Imports for backfills, CSV-based migrations, or cases where users export flat files from another system.
396
+
397
+ **Partial failure handling:**
398
+ - Imports can finish with row-level failures even when the job completes successfully.
399
+ - Always inspect the import summary and error rows before marking the source batch as complete.
400
+ - Treat duplicate email collisions as data-quality events, not transient API failures.
401
+
402
+ **Batch write guardrails:**
403
+ - Max 100 inputs per batch request.
404
+ - For large syncs, shard into batches of 100 and apply a short concurrency limit so you do not burst into 429s.
405
+ - Search first when deduplicating by email; batch create is not idempotent.
406
+
407
+ ---
408
+
409
+ ### Cross-tool recipe: Salesforce Lead -> HubSpot Contact sync
410
+
411
+ **Goal:** Mirror new or updated Salesforce Leads into HubSpot Contacts for marketing and lifecycle automation.
412
+
413
+ **HubSpot-side pattern:**
414
+ 1. Search Contacts by `email`.
415
+ 2. If found, `PATCH /crm/v3/objects/contacts/{id}` with mapped fields.
416
+ 3. If not found, `POST /crm/v3/objects/contacts`.
417
+ 4. Optionally stamp a custom property like `salesforce_lead_id` so future reconciliations do not depend solely on email.
418
+
419
+ **Recommended property mapping:**
420
+
421
+ | HubSpot Contact | Source from Salesforce |
422
+ |---|---|
423
+ | `email` | `Lead.Email` |
424
+ | `firstname` | `Lead.FirstName` |
425
+ | `lastname` | `Lead.LastName` |
426
+ | `company` | `Lead.Company` |
427
+ | `phone` | `Lead.Phone` |
428
+ | `lifecyclestage` | derived mapping from lead status |
429
+
430
+ **Failure policy:**
431
+ - `400` validation errors: quarantine the record with the source Lead ID and failing field names.
432
+ - `409` or duplicate conflicts: re-run a search by email, then update the winning Contact instead of creating a second record.
433
+ - `429`: respect `Retry-After` and retry the shard, not the full sync job.
434
+
435
+ ---
436
+
437
+ ### Cross-tool recipe: HubSpot Deal Won -> Asana project kickoff
438
+
439
+ **Goal:** When a Deal enters `closedwon`, create onboarding work in Asana.
440
+
441
+ **Flow:**
442
+ 1. Subscribe to `deal.propertyChange` for `dealstage`.
443
+ 2. Filter for the pipeline stage representing "closed won".
444
+ 3. Fetch the Deal plus associated Company and primary Contact.
445
+ 4. Create the Asana task or project with the customer name, ARR, owner, and launch date.
446
+ 5. Store the resulting Asana `gid` in HubSpot or in your integration DB.
447
+
448
+ **Minimum payload to send to Asana:**
449
+ - `name`: `Onboard {dealname}`
450
+ - `notes`: include the HubSpot deal URL, company, close date, and owner
451
+ - `due_on`: derived from kickoff SLA or launch date
452
+ - `custom_fields`: ARR, segment, onboarding tier, source deal ID
453
+
393
454
  ---
394
455
 
395
456
  ## Query patterns & filtering
@@ -462,6 +462,36 @@ The `expand=transitions.fields` parameter returns the fields required for each t
462
462
 
463
463
  ---
464
464
 
465
+ ### Cross-tool recipe: Zendesk Ticket -> Jira Bug escalation
466
+
467
+ **Goal:** Create or update a Jira Bug when a Zendesk ticket meets escalation criteria.
468
+
469
+ **Escalation trigger examples:**
470
+ - Zendesk ticket priority becomes `urgent`
471
+ - Tag includes `engineering`
472
+ - Custom field indicates product defect
473
+
474
+ **Flow:**
475
+ 1. Receive the Zendesk webhook or poll Incremental Export.
476
+ 2. Build a deterministic external key, for example `zendesk:{ticket_id}`.
477
+ 3. Search Jira for an existing issue with that external key in a custom field or label.
478
+ 4. If found, update severity, assignee, and status. If not found, create a Bug with the Zendesk URL in the description.
479
+ 5. Post the Jira issue key back to Zendesk as an internal note or custom field.
480
+
481
+ **Minimum field mapping:**
482
+
483
+ | Zendesk | Jira |
484
+ |---|---|
485
+ | `subject` | `summary` |
486
+ | `description` / latest public comment | `description` (ADF) |
487
+ | `priority` | `priority` |
488
+ | `ticket id` | custom external ID field |
489
+ | `tags` | `labels` |
490
+
491
+ **Status sync recommendation:**
492
+ - One-way sync into Jira is safer initially.
493
+ - Add reverse Jira -> Zendesk status updates only after you define an explicit state map and conflict rules.
494
+
465
495
  ## Query patterns & filtering
466
496
 
467
497
  ### JQL incremental sync
@@ -378,10 +378,28 @@ curl -s "$BASE/services/data/v66.0/jobs/ingest/$JOB_ID" \
378
378
  # Step 5 — retrieve failed records
379
379
  curl -s "$BASE/services/data/v66.0/jobs/ingest/$JOB_ID/failedResults" \
380
380
  -H "Authorization: Bearer $TOKEN"
381
+
382
+ # Step 6 — retrieve successful and unprocessed records for reconciliation
383
+ curl -s "$BASE/services/data/v66.0/jobs/ingest/$JOB_ID/successfulResults" \
384
+ -H "Authorization: Bearer $TOKEN"
385
+
386
+ curl -s "$BASE/services/data/v66.0/jobs/ingest/$JOB_ID/unprocessedRecords" \
387
+ -H "Authorization: Bearer $TOKEN"
381
388
  ```
382
389
 
383
390
  **When to use Bulk API v2:** >1,000 records. For <1,000 records, REST is simpler and faster.
384
391
 
392
+ **CSV and job design tips:**
393
+ - Keep one object type per job. Do not mix Leads and Contacts in the same ingest file.
394
+ - Include only columns you intend to write. Extra CSV columns are treated as field references and can fail the row.
395
+ - Shard very large backfills into multiple jobs so a single failure does not block the full dataset.
396
+ - Reconcile with all three result streams: `successfulResults`, `failedResults`, and `unprocessedRecords`.
397
+
398
+ **Partial-failure policy:**
399
+ - Retry only `unprocessedRecords` automatically.
400
+ - Send `failedResults` to a remediation queue with the Salesforce error message and original source key.
401
+ - Do not advance the upstream source cursor until every row is either successful or quarantined.
402
+
385
403
  ---
386
404
 
387
405
  ### Recipe 8: Register a Change Data Capture (CDC) subscription
@@ -410,6 +428,33 @@ def handler(event):
410
428
 
411
429
  ---
412
430
 
431
+ ### Cross-tool recipe: Salesforce Lead -> HubSpot Contact sync
432
+
433
+ **Goal:** Create or update a HubSpot Contact whenever a Salesforce Lead is created or updated, deduplicating by email.
434
+
435
+ **Flow:**
436
+ 1. Query Salesforce Leads modified since the last sync boundary using `LastModifiedDate`.
437
+ 2. Skip records with blank `Email` or `IsConverted = true`.
438
+ 3. In HubSpot, search by `email` first. If found, `PATCH` the Contact. If not, create it.
439
+ 4. Store the mapping `Lead.Id -> HubSpot contact id` in your integration DB, but treat `email` as the primary dedup key.
440
+ 5. Write a sync marker back to Salesforce only after the HubSpot write succeeds.
441
+
442
+ **Field mapping (minimum viable):**
443
+
444
+ | Salesforce Lead | HubSpot Contact |
445
+ |---|---|
446
+ | `Email` | `email` |
447
+ | `FirstName` | `firstname` |
448
+ | `LastName` | `lastname` |
449
+ | `Company` | `company` |
450
+ | `Phone` | `phone` |
451
+ | `LeadSource` | `lead_source` or custom property |
452
+
453
+ **Operational guardrails:**
454
+ - Prefer a watermark sync (`LastModifiedDate >= cursor`) plus a small overlap window to avoid missed updates.
455
+ - Do not use Bulk API v2 for near-real-time sync unless you are processing a large backfill.
456
+ - If HubSpot rejects one record, quarantine that record and continue the rest of the sync batch.
457
+
413
458
  ## Query patterns & filtering
414
459
 
415
460
  ### SOQL reference patterns
@@ -493,6 +493,11 @@ curl -u "agent@example.com/token:TOKEN" \
493
493
 
494
494
  **Validation**: Poll job status to `completed`. Spot-check 5 random ticket IDs from the batch to confirm `assignee_id` updated correctly.
495
495
 
496
+ **Operational notes:**
497
+ - Use `update_many` for homogeneous changes like tags, assignee, or status across a known set of ticket IDs.
498
+ - Keep request batches at 100 IDs max and persist the returned `job_status_id`.
499
+ - If the job partially succeeds, re-run only the failed IDs after inspecting the job result.
500
+
496
501
  ---
497
502
 
498
503
  ### Recipe 8: Manage agent permissions and group membership
@@ -542,6 +547,39 @@ curl -u "admin@example.com/token:TOKEN" \
542
547
 
543
548
  ---
544
549
 
550
+ ### Cross-tool recipe: Zendesk Ticket -> Jira Bug escalation
551
+
552
+ **Goal**: Escalate a Zendesk ticket into Jira when support confirms the issue is a product defect.
553
+
554
+ **Trigger examples**:
555
+ - Agent adds tag `engineering`
556
+ - Ticket priority changes to `urgent`
557
+ - Custom field `issue_type` becomes `bug`
558
+
559
+ **Flow**:
560
+ 1. Capture the Zendesk event via webhook or Incremental Export.
561
+ 2. Build a stable external key such as `zendesk:{ticket_id}`.
562
+ 3. Search Jira for an existing issue using that key in a custom field or label.
563
+ 4. If no issue exists, create a Jira Bug with the Zendesk URL, customer impact, and reproduction details.
564
+ 5. Write the Jira issue key back to Zendesk as an internal note or custom field so agents can track engineering progress.
565
+
566
+ **Minimum field mapping**:
567
+
568
+ | Zendesk ticket field | Jira field |
569
+ |---|---|
570
+ | `subject` | `summary` |
571
+ | latest public comment | `description` |
572
+ | `priority` | `priority` |
573
+ | `tags` | `labels` |
574
+ | `id` | external ID field / label |
575
+
576
+ **Operational guardrails**:
577
+ - Make the create path idempotent; webhooks can be retried.
578
+ - Keep status sync one-way at first. Bi-directional sync needs an explicit state map and conflict resolution policy.
579
+ - Post only sanitized customer context into Jira if Zendesk comments may include PII.
580
+
581
+ ---
582
+
545
583
  ## Query patterns & filtering
546
584
 
547
585
  ### Search API
@@ -602,6 +640,13 @@ GET /api/v2/incremental/tickets.json?start_time=1708300800
602
640
  - Also available for users and organizations: `/api/v2/incremental/users.json`, `/api/v2/incremental/organizations.json`.
603
641
  - Events include `deleted` tickets — check `status` field.
604
642
 
643
+ **Production sync pattern:**
644
+ 1. Start with `start_time` at least 5 minutes in the past.
645
+ 2. Continue following the returned window until you have drained the stream for the current run.
646
+ 3. Persist the returned `end_time`, not your request timestamp.
647
+ 4. Re-run with a small overlap window to protect against delayed indexing and consumer crashes.
648
+ 5. Deduplicate by ticket `id` and latest `updated_at` in your sink.
649
+
605
650
  ### Sorting
606
651
 
607
652
  Search API: `&sort_by=created_at&sort_order=asc` (or `desc`).
@@ -648,6 +693,13 @@ def zendesk_request(method, url, **kwargs):
648
693
  raise Exception("Exceeded retry budget")
649
694
  ```
650
695
 
696
+ ### Bulk update and export guardrails
697
+
698
+ - `update_many` is efficient for operational changes across up to 100 tickets, but it is not a substitute for a full sync.
699
+ - Poll the returned job status before assuming the bulk update succeeded.
700
+ - For analytics or warehouse sync, use Incremental Export rather than paginating Search or list endpoints.
701
+ - Keep writes and reads separate: use `update_many` for operational changes, Incremental Export for system-of-record ingestion.
702
+
651
703
  ### Retry vs fail-fast
652
704
 
653
705
  | HTTP Status | Action |