@usecoil/skill-claude 0.1.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 ADDED
@@ -0,0 +1,23 @@
1
+ # Coil Claude Code Skill
2
+
3
+ Installable Claude Code skill package for Coil agent workflows. It verifies the
4
+ installed CLI and installs the compatible public `@usecoil/cli` package when
5
+ `coil` is not already available:
6
+
7
+ ```bash
8
+ npx --yes @usecoil/skill-claude@0.1.0 --base-url https://www.usecoil.com
9
+ coil config set-base-url https://www.usecoil.com --profile prod
10
+ printf '%s' "$COIL_API_KEY" | coil auth login --profile prod --key -
11
+ coil --profile prod agent-context --json
12
+ ```
13
+
14
+ The installer copies the `coil-api` skill and its references into
15
+ `~/.claude/skills/coil-api`. It does not modify global or project `CLAUDE.md`
16
+ files.
17
+
18
+ Supply `COIL_API_KEY` through a secret manager or hidden prompt. The installer
19
+ does not accept, print, or persist authentication secrets. Do not configure
20
+ `COIL_ORG_ID`; Coil API keys are organization-scoped.
21
+
22
+ This package is proprietary (`UNLICENSED`). It installs a Claude Code skill,
23
+ not an MCP server or general Claude.ai integration.
@@ -0,0 +1,5 @@
1
+ import crossSpawn from 'cross-spawn';
2
+
3
+ export function spawnSyncPortable(command, args, options = {}) {
4
+ return crossSpawn.sync(command, args, options);
5
+ }
@@ -0,0 +1,699 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from 'node:crypto';
4
+ import {
5
+ cpSync,
6
+ chmodSync,
7
+ existsSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ mkdtempSync,
11
+ readFileSync,
12
+ realpathSync,
13
+ renameSync,
14
+ rmSync,
15
+ statSync,
16
+ writeFileSync,
17
+ } from 'node:fs';
18
+ import { delimiter, dirname, join, resolve } from 'node:path';
19
+ import { homedir, tmpdir } from 'node:os';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { gunzipSync } from 'node:zlib';
22
+ import { spawnSyncPortable } from './commands.mjs';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const packageMetadata = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
26
+ const SUPPORTED_CLI_RANGE = packageMetadata.coilCompatibility?.cli;
27
+ const PUBLIC_CLI_VERSION = packageMetadata.coilCompatibility?.preferredVersion;
28
+ const supportedCliMatch = /^(\d+)\.(\d+)\.x$/.exec(SUPPORTED_CLI_RANGE ?? '');
29
+ const runtimeMetadata = packageMetadata.coilRuntime;
30
+
31
+ if (!runtimeMetadata
32
+ || !['codex', 'claude', 'hermes'].includes(runtimeMetadata.name)
33
+ || typeof runtimeMetadata.displayName !== 'string'
34
+ || !/^--[a-z]+-dir$/.test(runtimeMetadata.directoryOption ?? '')
35
+ || typeof runtimeMetadata.defaultDirectory !== 'string'
36
+ || typeof runtimeMetadata.skillDirectory !== 'string') {
37
+ console.error('Error: the skill package does not declare a valid runtime install contract.');
38
+ process.exit(1);
39
+ }
40
+
41
+ if (!supportedCliMatch) {
42
+ console.error('Error: the skill package does not declare a valid Coil CLI compatibility range.');
43
+ process.exit(1);
44
+ }
45
+
46
+ const isolatedNpmConfigDir = mkdtempSync(join(tmpdir(), 'coil-npm-config-'));
47
+ const isolatedNpmUserConfig = join(isolatedNpmConfigDir, 'user.npmrc');
48
+ const isolatedNpmGlobalConfig = join(isolatedNpmConfigDir, 'global.npmrc');
49
+ writeFileSync(isolatedNpmUserConfig, '');
50
+ writeFileSync(isolatedNpmGlobalConfig, '');
51
+ const cleanupDirs = [isolatedNpmConfigDir];
52
+ process.on('exit', () => {
53
+ for (const path of cleanupDirs) rmSync(path, { recursive: true, force: true });
54
+ });
55
+
56
+ const SUPPORTED_CLI_VERSION = new RegExp(
57
+ `^v?${supportedCliMatch[1]}\\.${supportedCliMatch[2]}\\.\\d+(?:-[0-9A-Za-z.-]+)?$`,
58
+ );
59
+ if (!SUPPORTED_CLI_VERSION.test(PUBLIC_CLI_VERSION ?? '')) {
60
+ console.error('Error: the skill package must declare a preferred compatible Coil CLI version.');
61
+ process.exit(1);
62
+ }
63
+ const PUBLIC_CLI_SPEC = `@usecoil/cli@${PUBLIC_CLI_VERSION}`;
64
+ const defaultNpmPrefix = process.env.npm_config_prefix || process.env.NPM_CONFIG_PREFIX || join(homedir(), '.local');
65
+ const runtimeDefaultDir = join(homedir(), runtimeMetadata.defaultDirectory);
66
+
67
+ function globalBinDir() {
68
+ return process.platform === 'win32' ? defaultNpmPrefix : join(defaultNpmPrefix, 'bin');
69
+ }
70
+
71
+ if (Number.parseInt(process.versions.node.split('.')[0], 10) < 20) {
72
+ console.error('Error: Coil setup requires Node.js 20 or newer.');
73
+ process.exit(1);
74
+ }
75
+
76
+ function parseArgs(argv) {
77
+ const args = {};
78
+ for (let i = 2; i < argv.length; i += 1) {
79
+ const arg = argv[i];
80
+ if (arg === '--profile' && argv[i + 1]) args.profile = argv[++i];
81
+ else if (arg === '--base-url' && argv[i + 1]) args.baseUrl = argv[++i];
82
+ else if (arg === runtimeMetadata.directoryOption && argv[i + 1]) args.runtimeDir = argv[++i];
83
+ else if (arg === '--cli-package' && argv[i + 1]) args.cliPackage = argv[++i];
84
+ else if (arg === '--cli-sha256' && argv[i + 1]) args.cliSha256 = argv[++i];
85
+ else if (arg === '--help' || arg === '-h') args.help = true;
86
+ else {
87
+ console.error(`Unknown option: ${arg}`);
88
+ process.exit(1);
89
+ }
90
+ }
91
+ return args;
92
+ }
93
+
94
+ function printUsage() {
95
+ console.log(`
96
+ Usage: npx ${packageMetadata.name} [options]
97
+
98
+ Options:
99
+ --profile <name> Suggested Coil auth profile name. Defaults to "coil".
100
+ --base-url <url> Coil API base URL to include in installed setup guidance.
101
+ Defaults to COIL_BASE_URL when present.
102
+ ${runtimeMetadata.directoryOption} <path>${' '.repeat(Math.max(1, 25 - runtimeMetadata.directoryOption.length))}${runtimeMetadata.displayName} config directory. Defaults to ~/${runtimeMetadata.defaultDirectory}.
103
+ --cli-package <spec> Use an exact @usecoil/cli 0.1.x version or local .tgz instead
104
+ of the public npm package when coil is absent.
105
+ --cli-sha256 <hex> Required with a local --cli-package tarball; verify SHA-256 first.
106
+ --help Show this help message.
107
+
108
+ Default install:
109
+ - Verifies a compatible Coil CLI (0.1.x), installing ${PUBLIC_CLI_SPEC} when absent
110
+ - Atomically installs the coil-api skill and API references
111
+ ${runtimeMetadata.guidanceFile
112
+ ? ` - Writes general Coil guidance to ~/${runtimeMetadata.defaultDirectory}/${runtimeMetadata.guidanceFile}`
113
+ : ` - Leaves global ${runtimeMetadata.displayName} instruction files unchanged`}
114
+
115
+ Authentication is normally done with:
116
+ coil auth login
117
+ ${agentAuthCommand('<name>')}
118
+ coil --profile <name> agent-context --json
119
+
120
+ The installer never accepts, prints, or persists authentication secrets.
121
+ `);
122
+ }
123
+
124
+ function stripFrontmatter(markdown) {
125
+ return markdown.replace(/^---[\s\S]*?---\n*/, '');
126
+ }
127
+
128
+ function shellQuote(value) {
129
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
130
+ }
131
+
132
+ function agentAuthCommand(profile) {
133
+ return process.platform === 'win32'
134
+ ? `$env:COIL_API_KEY | coil auth login --profile ${shellQuote(profile)} --key -`
135
+ : `printf '%s' "$COIL_API_KEY" | coil auth login --profile ${shellQuote(profile)} --key -`;
136
+ }
137
+
138
+ function upsertSection(existing, heading, body) {
139
+ const section = `${heading}\n\n${body.trim()}\n`;
140
+ if (!existing) return section;
141
+
142
+ const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
143
+ const pattern = new RegExp(`${escapedHeading}[\\s\\S]*?(?=\\n# |$)`);
144
+ if (pattern.test(existing)) {
145
+ return existing.replace(pattern, section.trimEnd()) + (existing.endsWith('\n') ? '' : '\n');
146
+ }
147
+ return `${existing.trimEnd()}\n\n${section}`;
148
+ }
149
+
150
+ function validateBaseUrl(value) {
151
+ if (!value) return undefined;
152
+ if (/[\u0000-\u001f\u007f]/.test(value)) throw new Error('--base-url cannot contain control characters.');
153
+ let parsed;
154
+ try {
155
+ parsed = new URL(value);
156
+ } catch {
157
+ throw new Error('--base-url must be an absolute HTTP(S) URL.');
158
+ }
159
+ if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
160
+ throw new Error('--base-url must be an absolute HTTP(S) URL.');
161
+ }
162
+ if (parsed.username || parsed.password) throw new Error('--base-url cannot contain credentials.');
163
+ if (parsed.search || parsed.hash) {
164
+ throw new Error('--base-url cannot contain a query string or fragment.');
165
+ }
166
+ const localHosts = new Set(['localhost', '127.0.0.1', '::1']);
167
+ if (parsed.protocol === 'http:' && !localHosts.has(parsed.hostname) && !parsed.hostname.endsWith('.localhost')) {
168
+ throw new Error('--base-url must use HTTPS unless it targets loopback.');
169
+ }
170
+ return value.replace(/\/$/, '');
171
+ }
172
+
173
+ function validateCliPackage(value) {
174
+ const exactPackagePattern = new RegExp(
175
+ `^@usecoil/cli@${supportedCliMatch[1]}\\.${supportedCliMatch[2]}\\.\\d+(?:-[0-9A-Za-z.-]+)?$`,
176
+ );
177
+ if (exactPackagePattern.test(value)) return value;
178
+ const candidate = resolve(value.replace(/^file:/, ''));
179
+ if (candidate.endsWith('.tgz') && existsSync(candidate) && statSync(candidate).isFile()) return candidate;
180
+ throw new Error('--cli-package must be an exact @usecoil/cli 0.1.x version or an existing local .tgz file.');
181
+ }
182
+
183
+ function validateCliSha256(value) {
184
+ if (!/^[a-f0-9]{64}$/i.test(value)) {
185
+ throw new Error('--cli-sha256 must be a 64-character hexadecimal SHA-256 digest.');
186
+ }
187
+ return value.toLowerCase();
188
+ }
189
+
190
+ function isLocalCliPackage(value) {
191
+ return value.endsWith('.tgz') && existsSync(value) && statSync(value).isFile();
192
+ }
193
+
194
+ function spawnPortable(command, args, options = {}) {
195
+ return spawnSyncPortable(command, args, options);
196
+ }
197
+
198
+ function snapshotLocalCliPackage(path, expectedSha256) {
199
+ const packageBytes = readFileSync(path);
200
+ const actualSha256 = createHash('sha256').update(packageBytes).digest('hex');
201
+ if (actualSha256 !== expectedSha256) {
202
+ throw new Error(`--cli-package SHA-256 mismatch; expected ${expectedSha256}, received ${actualSha256}.`);
203
+ }
204
+ validateLocalCliPackageMetadata(packageBytes);
205
+ const snapshotDir = mkdtempSync(join(tmpdir(), 'coil-cli-package-'));
206
+ cleanupDirs.push(snapshotDir);
207
+ chmodSync(snapshotDir, 0o700);
208
+ const snapshotPath = join(snapshotDir, 'usecoil-cli.tgz');
209
+ writeFileSync(snapshotPath, packageBytes, { flag: 'wx', mode: 0o600 });
210
+ return snapshotPath;
211
+ }
212
+
213
+ function readTarEntry(archive, targetName) {
214
+ for (let offset = 0; offset + 512 <= archive.length; ) {
215
+ const header = archive.subarray(offset, offset + 512);
216
+ if (header.every((byte) => byte === 0)) break;
217
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '');
218
+ const sizeText = header.subarray(124, 136).toString('ascii').replace(/\0.*$/, '').trim();
219
+ const size = sizeText ? Number.parseInt(sizeText, 8) : 0;
220
+ if (!Number.isSafeInteger(size) || size < 0) throw new Error('local CLI package contains an invalid tar entry size.');
221
+ const contentStart = offset + 512;
222
+ if (name === targetName) return archive.subarray(contentStart, contentStart + size).toString('utf8');
223
+ offset = contentStart + Math.ceil(size / 512) * 512;
224
+ }
225
+ return null;
226
+ }
227
+
228
+ function validateLocalCliPackageMetadata(packageBytes) {
229
+ let metadata;
230
+ try {
231
+ const packageJson = readTarEntry(gunzipSync(packageBytes), 'package/package.json');
232
+ if (!packageJson) throw new Error('package/package.json is missing.');
233
+ metadata = JSON.parse(packageJson);
234
+ } catch (error) {
235
+ throw new Error(`--cli-package is not a readable npm package: ${error.message}`);
236
+ }
237
+
238
+ const expectedBin = metadata.bin?.coil === 'dist/coil.js' || metadata.bin?.coil === './dist/coil.js';
239
+ const expectedExports = JSON.stringify(metadata.exports) === JSON.stringify({ './package.json': './package.json' });
240
+ const valid = metadata.name === '@usecoil/cli'
241
+ && SUPPORTED_CLI_VERSION.test(String(metadata.version ?? ''))
242
+ && metadata.license === 'UNLICENSED'
243
+ && metadata.engines?.node === '>=20'
244
+ && expectedBin
245
+ && expectedExports
246
+ && metadata.publishConfig?.access === 'public'
247
+ && !metadata.main
248
+ && !metadata.dependencies
249
+ && !metadata.devDependencies;
250
+ if (!valid) {
251
+ throw new Error('--cli-package metadata must be the reviewed @usecoil/cli public executable package.');
252
+ }
253
+ }
254
+
255
+ function checkCli() {
256
+ const pathExecutable = resolveCoilOnPath();
257
+ const packageCheck = pathExecutable
258
+ ? verifyInstalledCliPackage(packagePathForExecutable(pathExecutable))
259
+ : verifyInstalledCliPackage();
260
+ if (!packageCheck.ok) {
261
+ return {
262
+ ok: false,
263
+ present: Boolean(pathExecutable || globalCliPackagePath()),
264
+ version: null,
265
+ reason: pathExecutable
266
+ ? 'A coil executable is present on PATH but is not owned by the reviewed global @usecoil/cli package.'
267
+ : packageCheck.reason,
268
+ };
269
+ }
270
+ const probeHome = mkdtempSync(join(tmpdir(), 'coil-cli-probe-'));
271
+ const probeEnvironment = sanitizedCliEnvironment(probeHome);
272
+ const result = spawnPortable(process.execPath, [packageCheck.entrypoint, '--version'], {
273
+ encoding: 'utf8',
274
+ env: probeEnvironment,
275
+ });
276
+ rmSync(probeHome, { recursive: true, force: true });
277
+ if (result.error?.code === 'ENOENT') return { ok: false, present: false, version: null, reason: null };
278
+ if (result.error || result.status !== 0) {
279
+ return {
280
+ ok: false,
281
+ present: true,
282
+ version: null,
283
+ reason: 'A coil executable is present but failed its version check.',
284
+ };
285
+ }
286
+ const version = result.stdout.trim();
287
+ if (!SUPPORTED_CLI_VERSION.test(version)) return { ok: false, present: true, version, reason: null };
288
+
289
+ const discoveryHome = mkdtempSync(join(tmpdir(), 'coil-cli-discovery-'));
290
+ try {
291
+ const contextResult = spawnPortable(process.execPath, [packageCheck.entrypoint, '--json', 'agent-context'], {
292
+ encoding: 'utf8',
293
+ env: sanitizedCliEnvironment(discoveryHome),
294
+ });
295
+ const context = JSON.parse(contextResult.stdout);
296
+ const activationCommands = context.commands?.activation?.commands;
297
+ const activationStatus = Array.isArray(activationCommands)
298
+ && activationCommands.some((command) => command?.name === 'activation status');
299
+ const starterRun = Array.isArray(activationCommands)
300
+ && activationCommands.find((command) => command?.name === 'recipes run --starter-run');
301
+ const starterParams = Array.isArray(starterRun?.params) ? starterRun.params : [];
302
+ const hasSpendGate = starterParams.some((param) => (
303
+ param?.name === '--confirm-provider-spend' && param?.type === 'boolean' && param?.required === true
304
+ ));
305
+ const fetchCount = starterParams.find((param) => param?.name === '--fetch-count');
306
+ const hasStarterCap = fetchCount?.type === 'number'
307
+ && fetchCount?.required === true
308
+ && fetchCount?.minimum === 1
309
+ && fetchCount?.maximum === 25;
310
+ const compatible = contextResult.status === 0
311
+ && context.cli?.name === 'coil'
312
+ && context.cli?.version === version.replace(/^v/, '')
313
+ && activationStatus
314
+ && hasSpendGate
315
+ && hasStarterCap
316
+ && Array.isArray(context.commands?.recipes?.commands)
317
+ && context.commands.recipes.commands.some((command) => command?.name === 'recipes run');
318
+ return {
319
+ ok: Boolean(compatible),
320
+ present: true,
321
+ version,
322
+ reason: compatible ? null : 'The executable does not expose the required Coil runtime-discovery contract.',
323
+ };
324
+ } catch {
325
+ return {
326
+ ok: false,
327
+ present: true,
328
+ version,
329
+ reason: 'The executable does not return valid Coil runtime-discovery JSON.',
330
+ };
331
+ } finally {
332
+ rmSync(discoveryHome, { recursive: true, force: true });
333
+ }
334
+ }
335
+
336
+ function rollbackCliInstall() {
337
+ const rollback = spawnPortable('npm', [
338
+ 'uninstall',
339
+ '--global',
340
+ '--ignore-scripts',
341
+ '--no-audit',
342
+ '--no-fund',
343
+ '@usecoil/cli',
344
+ ], { encoding: 'utf8', env: sanitizedNpmEnvironment() });
345
+ return !rollback.error && rollback.status === 0;
346
+ }
347
+
348
+ function globalCliPackagePath() {
349
+ const rootResult = spawnPortable('npm', ['root', '--global'], { encoding: 'utf8', env: sanitizedNpmEnvironment() });
350
+ if (rootResult.error || rootResult.status !== 0) return null;
351
+ const packagePath = join(rootResult.stdout.trim(), '@usecoil', 'cli', 'package.json');
352
+ return existsSync(packagePath) ? packagePath : null;
353
+ }
354
+
355
+ function resolveCoilOnPath() {
356
+ const pathEntries = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
357
+ const names = process.platform === 'win32'
358
+ ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').map((ext) => `coil${ext.toLowerCase()}`)
359
+ : ['coil'];
360
+ for (const pathEntry of pathEntries) {
361
+ for (const name of names) {
362
+ const candidate = resolve(pathEntry, name);
363
+ try {
364
+ if (statSync(candidate).isFile()) return candidate;
365
+ } catch {
366
+ // Continue searching PATH.
367
+ }
368
+ }
369
+ }
370
+ return null;
371
+ }
372
+
373
+ function packagePathForExecutable(executable) {
374
+ if (process.platform === 'win32') {
375
+ return join(dirname(executable), 'node_modules', '@usecoil', 'cli', 'package.json');
376
+ }
377
+ try {
378
+ const entrypoint = realpathSync(executable);
379
+ return join(dirname(dirname(entrypoint)), 'package.json');
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+
385
+ function verifyInstalledCliPackage(packagePath = globalCliPackagePath()) {
386
+ if (!packagePath) return { ok: false, reason: 'Could not locate the global npm package root after installation.' };
387
+ try {
388
+ const metadata = JSON.parse(readFileSync(packagePath, 'utf8'));
389
+ const expectedBin = metadata.bin?.coil === 'dist/coil.js' || metadata.bin?.coil === './dist/coil.js';
390
+ const expectedExports = JSON.stringify(metadata.exports) === JSON.stringify({ './package.json': './package.json' });
391
+ const valid = metadata.name === '@usecoil/cli'
392
+ && SUPPORTED_CLI_VERSION.test(String(metadata.version ?? ''))
393
+ && metadata.license === 'UNLICENSED'
394
+ && metadata.engines?.node === '>=20'
395
+ && expectedBin
396
+ && expectedExports
397
+ && !metadata.main
398
+ && !metadata.dependencies
399
+ && !metadata.devDependencies;
400
+ const entrypoint = join(dirname(packagePath), 'dist', 'coil.js');
401
+ const entrypointIsFile = existsSync(entrypoint) && statSync(entrypoint).isFile();
402
+ return valid && entrypointIsFile
403
+ ? { ok: true, reason: null, entrypoint }
404
+ : { ok: false, reason: 'The installed package metadata does not match the reviewed Coil CLI contract.' };
405
+ } catch {
406
+ return { ok: false, reason: 'The installed Coil CLI package metadata could not be read.' };
407
+ }
408
+ }
409
+
410
+ function sanitizedNpmEnvironment() {
411
+ const allowed = new Set([
412
+ 'appdata',
413
+ 'comspec',
414
+ 'home',
415
+ 'homedrive',
416
+ 'homepath',
417
+ 'lang',
418
+ 'lc_all',
419
+ 'lc_ctype',
420
+ 'localappdata',
421
+ 'path',
422
+ 'pathext',
423
+ 'systemroot',
424
+ 'temp',
425
+ 'tmp',
426
+ 'tmpdir',
427
+ 'userprofile',
428
+ ]);
429
+ const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => {
430
+ const normalized = key.toLowerCase();
431
+ return allowed.has(normalized) || normalized === 'npm_config_prefix' || normalized === 'npm_config_cache';
432
+ }).concat([
433
+ ['npm_config_prefix', defaultNpmPrefix],
434
+ ['npm_config_userconfig', isolatedNpmUserConfig],
435
+ ['npm_config_globalconfig', isolatedNpmGlobalConfig],
436
+ ]));
437
+
438
+ for (const key of [
439
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
440
+ 'http_proxy', 'https_proxy', 'no_proxy',
441
+ 'npm_config_proxy', 'npm_config_https_proxy', 'npm_config_noproxy', 'npm_config_registry',
442
+ ]) {
443
+ const value = process.env[key];
444
+ if (!value) continue;
445
+ if (key.toLowerCase().includes('proxy') || key === 'npm_config_registry') {
446
+ try {
447
+ const parsed = new URL(value);
448
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) continue;
449
+ } catch {
450
+ const normalized = key.toLowerCase();
451
+ if (normalized !== 'no_proxy' && normalized !== 'npm_config_noproxy') continue;
452
+ }
453
+ }
454
+ environment[key] = value;
455
+ }
456
+ for (const key of ['npm_config_cafile', 'NODE_EXTRA_CA_CERTS']) {
457
+ if (process.env[key]) environment[key] = process.env[key];
458
+ }
459
+ return environment;
460
+ }
461
+
462
+ function sanitizedCliEnvironment(isolatedHome) {
463
+ const environment = sanitizedNpmEnvironment();
464
+ environment.HOME = isolatedHome;
465
+ environment.USERPROFILE = isolatedHome;
466
+ environment.COIL_API_KEY = '';
467
+ environment.COIL_PROFILE = '';
468
+ environment.COIL_BASE_URL = 'http://127.0.0.1:9';
469
+ environment.PATH = [globalBinDir(), environment.PATH].filter(Boolean).join(process.platform === 'win32' ? ';' : ':');
470
+ return environment;
471
+ }
472
+
473
+ function maybeInjectFailure(step) {
474
+ if (process.env.NODE_ENV === 'test' && process.env.COIL_SKILL_INSTALL_TEST_FAIL_AFTER === step) {
475
+ throw new Error(`Injected installer failure after ${step}`);
476
+ }
477
+ }
478
+
479
+ function lstatIfPresent(path) {
480
+ try {
481
+ return lstatSync(path);
482
+ } catch (error) {
483
+ if (error?.code === 'ENOENT') return null;
484
+ throw error;
485
+ }
486
+ }
487
+
488
+ const args = parseArgs(process.argv);
489
+ args.profile ??= 'coil';
490
+ args.baseUrl ??= process.env.COIL_BASE_URL;
491
+ args.runtimeDir ??= runtimeDefaultDir;
492
+
493
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(args.profile)) {
494
+ console.error('Error: --profile must be 1-64 letters, numbers, dots, underscores, or hyphens.');
495
+ process.exit(1);
496
+ }
497
+
498
+ try {
499
+ args.baseUrl = validateBaseUrl(args.baseUrl);
500
+ if (args.cliPackage) {
501
+ args.cliPackage = validateCliPackage(args.cliPackage);
502
+ if (isLocalCliPackage(args.cliPackage)) {
503
+ if (!args.cliSha256) throw new Error('--cli-sha256 is required when --cli-package points to a local .tgz file.');
504
+ args.cliSha256 = validateCliSha256(args.cliSha256);
505
+ args.cliPackage = snapshotLocalCliPackage(args.cliPackage, args.cliSha256);
506
+ } else if (args.cliSha256) {
507
+ throw new Error('--cli-sha256 is only valid with a local --cli-package .tgz file.');
508
+ }
509
+ } else if (args.cliSha256) {
510
+ throw new Error('--cli-sha256 requires --cli-package.');
511
+ }
512
+ } catch (error) {
513
+ console.error(`Error: ${error.message}`);
514
+ process.exit(1);
515
+ }
516
+
517
+ if (args.help) {
518
+ printUsage();
519
+ process.exit(0);
520
+ }
521
+
522
+ let cli = checkCli();
523
+ let installedCli = false;
524
+ if (!cli.ok && cli.present) {
525
+ const found = cli.version ? ` ${cli.version}` : '';
526
+ const detail = cli.reason ?? `Found${found}; supported versions are ${SUPPORTED_CLI_RANGE}.`;
527
+ console.error(`Error: ${detail} Refusing to replace an existing executable automatically.`);
528
+ process.exit(1);
529
+ }
530
+
531
+ if (!cli.ok) {
532
+ if (globalCliPackagePath()) {
533
+ console.error('Error: @usecoil/cli is already installed globally but its coil executable is unavailable; refusing to replace the existing package automatically.');
534
+ process.exit(1);
535
+ }
536
+ const installSpec = args.cliPackage ?? PUBLIC_CLI_SPEC;
537
+ const install = spawnPortable('npm', [
538
+ 'install',
539
+ '--global',
540
+ '--ignore-scripts',
541
+ '--no-audit',
542
+ '--no-fund',
543
+ installSpec,
544
+ ], { encoding: 'utf8', env: sanitizedNpmEnvironment() });
545
+ if (install.error || install.status !== 0) {
546
+ const source = args.cliPackage ? 'the reviewed Coil CLI package' : `public npm package ${PUBLIC_CLI_SPEC}`;
547
+ console.error(`Error: ${source} could not be installed.`);
548
+ process.exit(1);
549
+ }
550
+ installedCli = true;
551
+ const packageCheck = verifyInstalledCliPackage();
552
+ if (!packageCheck.ok) {
553
+ const rollbackFailed = !rollbackCliInstall();
554
+ console.error(`Error: ${packageCheck.reason}`);
555
+ if (rollbackFailed) console.error('Warning: automatic rollback of the newly installed Coil CLI failed; remove @usecoil/cli manually.');
556
+ process.exit(1);
557
+ }
558
+ cli = checkCli();
559
+ }
560
+
561
+ if (!cli.ok) {
562
+ const rollbackFailed = installedCli && !rollbackCliInstall();
563
+ const detail = cli.reason ?? (cli.version ? `Found ${cli.version}; supported versions are ${SUPPORTED_CLI_RANGE}.` : 'No coil executable was found.');
564
+ console.error(`Error: ${detail} Install @usecoil/cli ${PUBLIC_CLI_SPEC} from the public npm registry, or rerun with --cli-package <path-to-reviewed-tarball>.`);
565
+ if (rollbackFailed) console.error('Warning: automatic rollback of the newly installed Coil CLI failed; remove @usecoil/cli manually.');
566
+ process.exit(1);
567
+ }
568
+
569
+ const runtimeDir = args.runtimeDir;
570
+ const skillSource = join(__dirname, '..', 'skill');
571
+ const skillDest = join(runtimeDir, ...runtimeMetadata.skillDirectory.split('/'));
572
+ const referencesDest = runtimeMetadata.referencesDirectory
573
+ ? join(runtimeDir, ...runtimeMetadata.referencesDirectory.split('/'))
574
+ : null;
575
+ const guidanceDest = runtimeMetadata.guidanceFile ? join(runtimeDir, runtimeMetadata.guidanceFile) : null;
576
+
577
+ let stagingRoot;
578
+ try {
579
+ mkdirSync(runtimeDir, { recursive: true });
580
+ stagingRoot = mkdtempSync(join(runtimeDir, '.coil-install-'));
581
+ } catch (error) {
582
+ const rollbackFailed = installedCli && !rollbackCliInstall();
583
+ console.error(`Error: Coil skill installation could not create its staging directory: ${error.message}`);
584
+ if (rollbackFailed) console.error('Warning: automatic rollback of the newly installed Coil CLI failed; remove @usecoil/cli manually.');
585
+ process.exit(1);
586
+ }
587
+ const stagedRoot = join(stagingRoot, 'new');
588
+ const backupRoot = join(stagingRoot, 'backup');
589
+ const stagedSkill = join(stagedRoot, 'coil-api');
590
+ const stagedReferences = referencesDest ? join(stagedRoot, 'coil-references') : null;
591
+ const stagedGuidance = guidanceDest ? join(stagedRoot, runtimeMetadata.guidanceFile) : null;
592
+
593
+ try {
594
+ mkdirSync(stagedRoot, { recursive: true });
595
+ mkdirSync(backupRoot, { recursive: true });
596
+ cpSync(skillSource, stagedSkill, { recursive: true });
597
+ if (stagedReferences) {
598
+ mkdirSync(stagedReferences, { recursive: true });
599
+ for (const file of ['api-endpoints.md', 'api-fields.md']) {
600
+ cpSync(join(skillSource, 'references', file), join(stagedReferences, file));
601
+ }
602
+ }
603
+
604
+ if (stagedGuidance) {
605
+ const skillBody = stripFrontmatter(readFileSync(join(skillSource, 'SKILL.md'), 'utf-8'));
606
+ const guidanceBody = `${skillBody}
607
+
608
+ ## Local Install Notes
609
+
610
+ - Human auth: \`coil auth login\`
611
+ - Agent auth: \`${agentAuthCommand(args.profile)}\`
612
+ - API base URL: \`${args.baseUrl
613
+ ? `coil config set-base-url ${shellQuote(args.baseUrl)} --profile ${shellQuote(args.profile)}`
614
+ : `coil config set-base-url https://www.usecoil.com --profile ${shellQuote(args.profile)}`}\`
615
+ - First discovery command: \`coil --profile ${shellQuote(args.profile)} agent-context --json\`
616
+ - Compatible CLI: \`${SUPPORTED_CLI_RANGE}\`
617
+ - Env fallback: \`COIL_API_KEY\` and \`COIL_BASE_URL\`
618
+ - Do not set \`COIL_ORG_ID\`; Coil API keys are organization-scoped.
619
+ `;
620
+ let existingGuidance = '';
621
+ let guidanceMode = 0o600;
622
+ if (existsSync(guidanceDest)) {
623
+ const guidanceStat = lstatSync(guidanceDest);
624
+ if (guidanceStat.isSymbolicLink() || !guidanceStat.isFile()) {
625
+ throw new Error(`${runtimeMetadata.guidanceFile} must be a regular file, not a symlink or special file.`);
626
+ }
627
+ existingGuidance = readFileSync(guidanceDest, 'utf8');
628
+ guidanceMode = guidanceStat.mode & 0o777;
629
+ }
630
+ writeFileSync(stagedGuidance, upsertSection(existingGuidance, '# Coil Integration', guidanceBody), { mode: guidanceMode });
631
+ chmodSync(stagedGuidance, guidanceMode);
632
+ }
633
+ } catch (error) {
634
+ rmSync(stagingRoot, { recursive: true, force: true });
635
+ const rollbackFailed = installedCli && !rollbackCliInstall();
636
+ console.error(`Error: Coil skill installation could not be staged: ${error.message}`);
637
+ if (rollbackFailed) console.error('Warning: automatic rollback of the newly installed Coil CLI failed; remove @usecoil/cli manually.');
638
+ process.exit(1);
639
+ }
640
+
641
+ const targets = [
642
+ { name: 'skill', staged: stagedSkill, target: skillDest, backup: join(backupRoot, 'coil-api') },
643
+ ...(stagedReferences ? [{ name: 'references', staged: stagedReferences, target: referencesDest, backup: join(backupRoot, 'coil-references') }] : []),
644
+ ...(stagedGuidance ? [{ name: 'guidance', staged: stagedGuidance, target: guidanceDest, backup: join(backupRoot, runtimeMetadata.guidanceFile) }] : []),
645
+ ];
646
+ const applied = [];
647
+
648
+ try {
649
+ for (const item of targets) {
650
+ mkdirSync(dirname(item.target), { recursive: true });
651
+ const previousStat = lstatIfPresent(item.target);
652
+ if (previousStat?.isSymbolicLink()
653
+ || (item.name === 'guidance' ? previousStat && !previousStat.isFile() : previousStat && !previousStat.isDirectory())) {
654
+ throw new Error(`${item.target} must be a regular ${item.name === 'guidance' ? 'file' : 'directory'}, not a symlink or special file.`);
655
+ }
656
+ const hadPrevious = Boolean(previousStat);
657
+ if (hadPrevious) renameSync(item.target, item.backup);
658
+ try {
659
+ renameSync(item.staged, item.target);
660
+ } catch (error) {
661
+ if (hadPrevious && existsSync(item.backup)) renameSync(item.backup, item.target);
662
+ throw error;
663
+ }
664
+ applied.push({ ...item, hadPrevious });
665
+ maybeInjectFailure(item.name);
666
+ }
667
+ rmSync(stagingRoot, { recursive: true, force: true });
668
+ } catch (error) {
669
+ for (const item of applied.reverse()) {
670
+ rmSync(item.target, { recursive: true, force: true });
671
+ if (item.hadPrevious && existsSync(item.backup)) renameSync(item.backup, item.target);
672
+ }
673
+ rmSync(stagingRoot, { recursive: true, force: true });
674
+ const rollbackFailed = installedCli && !rollbackCliInstall();
675
+ console.error(`Error: Coil skill installation rolled back: ${error.message}`);
676
+ if (rollbackFailed) console.error('Warning: automatic rollback of the newly installed Coil CLI failed; remove @usecoil/cli manually.');
677
+ process.exit(1);
678
+ }
679
+
680
+ console.log(`Verified Coil CLI ${cli.version} (${SUPPORTED_CLI_RANGE}).`);
681
+ if (installedCli && !(process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').includes(globalBinDir())) {
682
+ const guidance = process.platform === 'win32'
683
+ ? `Add ${globalBinDir()} to PATH before running coil in a new terminal.`
684
+ : `Run: export PATH=${shellQuote(`${globalBinDir()}:$PATH`)}`;
685
+ console.log(guidance);
686
+ }
687
+ console.log(`Installed Coil skill to ${skillDest}`);
688
+ if (referencesDest) console.log(`Installed Coil API references to ${referencesDest}`);
689
+ if (guidanceDest) console.log(`Wrote Coil guidance to ${guidanceDest}`);
690
+ console.log('Human auth: coil auth login');
691
+ console.log(`Agent auth: ${agentAuthCommand(args.profile)}`);
692
+
693
+ if (args.baseUrl) {
694
+ console.log(`Required base URL setup: coil config set-base-url ${shellQuote(args.baseUrl)} --profile ${shellQuote(args.profile)}`);
695
+ } else {
696
+ console.log(`Required public-service setup: coil config set-base-url https://www.usecoil.com --profile ${shellQuote(args.profile)}`);
697
+ }
698
+
699
+ console.log(`Done. After base URL setup and authentication, run coil --profile ${shellQuote(args.profile)} agent-context --json.`);
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@usecoil/skill-claude",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code skill package for Coil agent workflows",
5
+ "type": "module",
6
+ "bin": {
7
+ "coil-skill-claude": "./bin/install.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "skill",
12
+ "README.md"
13
+ ],
14
+ "keywords": [
15
+ "coil",
16
+ "claude-code",
17
+ "agents",
18
+ "lead-generation"
19
+ ],
20
+ "license": "UNLICENSED",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/antons-agents/coil.git",
24
+ "directory": "packages/skill-claude"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "coilCompatibility": {
30
+ "cli": "0.1.x",
31
+ "preferredVersion": "0.1.0"
32
+ },
33
+ "coilRuntime": {
34
+ "name": "claude",
35
+ "displayName": "Claude Code",
36
+ "directoryOption": "--claude-dir",
37
+ "defaultDirectory": ".claude",
38
+ "skillDirectory": "skills/coil-api",
39
+ "referencesDirectory": null,
40
+ "guidanceFile": null
41
+ },
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
45
+ "dependencies": {
46
+ "cross-spawn": "7.0.6"
47
+ }
48
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,222 @@
1
+ ---
2
+ name: coil-api
3
+ description: Use Coil's CLI and API for agent-operated outbound recipes, durable runs, lead management, automations, provider integrations, feedback, and runtime discovery. Use when an agent needs to install, authenticate, discover, or operate Coil through the same organization state humans use in the dashboard.
4
+ ---
5
+
6
+ # Coil API
7
+
8
+ Coil is an agent-operated outbound platform. Recipes are reusable workflows and runs are durable executions; prospect and Sales Navigator scrapes remain backing source/result records and compatibility commands.
9
+
10
+ The project principle is human-agent parity: when a feature exists in the UI, agents should have an API route, CLI command, and skill-level discoverability path for the same capability.
11
+
12
+ ## First Command
13
+
14
+ Install the supported CLI if `coil` is not already available, then ask it what
15
+ it can do:
16
+
17
+ ```bash
18
+ if ! command -v coil >/dev/null 2>&1; then
19
+ npm install --global @usecoil/cli@0.1.0
20
+ fi
21
+ coil --version
22
+ ```
23
+
24
+ Start every new Coil runtime by asking the CLI what it can do:
25
+
26
+ ```bash
27
+ coil agent-context --json
28
+ ```
29
+
30
+ For named environments:
31
+
32
+ ```bash
33
+ coil --profile prod agent-context --json
34
+ ```
35
+
36
+ `agent-context` returns the selected profile, API base URL, auth mode, actor context, global flags, command manifests, output version information, and current caveats.
37
+
38
+ ## Authentication
39
+
40
+ Prefer profile-based CLI auth:
41
+
42
+ ```bash
43
+ printf '%s' "$COIL_API_KEY" | coil auth login --profile prod --key -
44
+ coil auth use prod
45
+ coil --profile prod auth status --json
46
+ ```
47
+
48
+ On PowerShell:
49
+
50
+ ```powershell
51
+ $env:COIL_API_KEY | coil auth login --profile prod --key -
52
+ coil auth use prod
53
+ coil --profile prod auth status --json
54
+ ```
55
+
56
+ Coil API keys are first-party organization API keys. They start with `ak_` and are already scoped to an organization.
57
+
58
+ Do not configure `COIL_ORG_ID`. Coil does not need it for CLI/API calls.
59
+
60
+ Environment fallback is supported when a profile is not available:
61
+
62
+ ```bash
63
+ export COIL_API_KEY="ak_..."
64
+ export COIL_BASE_URL="https://your-coil-host.example.com"
65
+ coil scrapes list --json
66
+ ```
67
+
68
+ The CLI's built-in base URL is for local development. For the public service,
69
+ explicitly run `coil config set-base-url https://www.usecoil.com --profile prod`
70
+ before authenticating. Use `COIL_BASE_URL` only as an environment fallback when
71
+ you cannot persist a profile.
72
+
73
+ ## Runtime setup
74
+
75
+ This skill is portable across Codex, Claude Code, Hermes Agent, and OpenClaw.
76
+ The runtime-specific installer or registry handles placement; Coil operations
77
+ always use the same public JSON CLI:
78
+
79
+ ```bash
80
+ npm install --global @usecoil/cli@0.1.0
81
+ coil config set-base-url https://www.usecoil.com --profile prod
82
+ printf '%s' "$COIL_API_KEY" | coil auth login --profile prod --key -
83
+ coil --profile prod agent-context --json
84
+ ```
85
+
86
+ On PowerShell, use `$env:COIL_API_KEY | coil auth login --profile prod --key -`
87
+ for the authentication step.
88
+
89
+ Supply `COIL_API_KEY` through the runtime secret manager and never place it in
90
+ command arguments. OpenClaw may provide it through the `coil-api` skill entry;
91
+ other runtimes may use their own secret manager. Do not add `COIL_ORG_ID`.
92
+
93
+ ## CLI JSON Rules
94
+
95
+ - Use `--json` for machine-readable output.
96
+ - Use `--output-version 2` for standard list envelopes where supported.
97
+ - Version 2 list output shape is `{ resource, items, total, limit, offset, nextOffset, truncated, hint }`.
98
+ - Diagnostics and errors go to stderr.
99
+ - Global flags: `--profile`, `--base-url`, `--json`, `--output-version`.
100
+
101
+ ## Common Workflows
102
+
103
+ ### Orient
104
+
105
+ ```bash
106
+ coil --profile prod agent-context --json
107
+ coil --profile prod config show --json
108
+ coil --profile prod auth status --json
109
+ ```
110
+
111
+ ### Guided activation
112
+
113
+ Use the server-derived activation state before starting a new workspace flow. Provider-backed runs require a healthy saved connection and explicit spend confirmation.
114
+
115
+ ```bash
116
+ coil --profile prod activation status --json
117
+ coil --profile prod marketplace templates --json
118
+ coil --profile prod marketplace install coil.prospect-search --json
119
+ coil --profile prod recipes run <starter-recipe-id> --starter-run --confirm-provider-spend --new-scrape-name "Starter prospects" --titles "Founder" --locations "Singapore" --sizes "1-10" --fetch-count 25 --input '{"fetch_count":25}' --json
120
+ ```
121
+
122
+ The starter flow is capped at 25 leads. A queued or running response is not success; inspect or wait for the durable recipe run before claiming usable leads.
123
+
124
+ ### Provider connections
125
+
126
+ Credential mutation and testing require a human organization admin. Keep secrets on stdin and never place them in arguments, logs, issue bodies, or recipe inputs.
127
+
128
+ ```bash
129
+ printf '%s' "$APIFY_API_TOKEN" | coil --profile prod integrations set apify --json
130
+ coil --profile prod integrations status apify --json
131
+ coil --profile prod integrations test apify --json
132
+ coil --profile prod integrations rotate --json
133
+ coil --profile prod integrations rotate --apply --json
134
+ coil --profile prod integrations disconnect apify --json
135
+ ```
136
+
137
+ ### Recipes and durable runs
138
+
139
+ ```bash
140
+ coil --profile prod recipes list --status published --json
141
+ coil --profile prod recipes view <recipe-id> --json
142
+ coil --profile prod recipes validate <recipe-id> --json
143
+ coil --profile prod recipes run <recipe-id> --confirm-provider-spend --new-scrape-name "Q2 prospects" --input '{"fetch_count":100}' --json
144
+ coil --profile prod recipe-runs view <run-id> --json
145
+ coil --profile prod recipe-runs wait <run-id> --timeout 300 --interval 2 --json
146
+ coil --profile prod recipe-runs cancel <run-id> --json
147
+ coil --profile prod recipe-runs retry <run-id> --json
148
+ ```
149
+
150
+ Publication and ambiguous-effect reconciliation require a human organization admin. Retry/cancel behavior follows the durable run state returned by the API.
151
+
152
+ ### Scrapes
153
+
154
+ ```bash
155
+ coil --profile prod scrapes list --json --output-version 2
156
+ coil --profile prod scrapes get <scrape-id> --json
157
+ coil --profile prod scrapes create --type prospect --name "Q2 prospects" --titles "VP Sales,Head of Growth" --locations "United States" --sizes "11-50,51-200" --confirm-provider-spend --json
158
+ printf '%s' "$LINKEDIN_COOKIE" | coil --profile prod scrapes create --type sales-nav --name "Sales Nav export" --url "https://www.linkedin.com/sales/search/people?..." --user-agent "Mozilla/5.0 ..." --cookie-stdin --confirm-provider-spend --json
159
+ ```
160
+
161
+ ### Leads
162
+
163
+ ```bash
164
+ coil --profile prod leads list --limit 50 --json --output-version 2
165
+ # Add a scrape ID after `list` to scope the result to one run.
166
+ coil --profile prod leads get <lead-id> --json
167
+ coil --profile prod leads export <scrape-id> --output leads.csv
168
+ coil --profile prod leads export <scrape-id> --view <view-id> --output saved-view.csv
169
+ coil --profile prod leads export <scrape-id> --ids <lead-id-1>,<lead-id-2> --output selected.csv
170
+ coil --profile prod --json leads export <scrape-id> --output leads.csv
171
+ coil --profile prod leads emails <scrape-id>
172
+ ```
173
+
174
+ ### Automations
175
+
176
+ API-key agents can create draft automations. Human org admins publish drafts after validation.
177
+
178
+ ```bash
179
+ coil --profile prod automations create --name "Enrich leads" --webhook-url "https://hooks.example.com/enrich" --input-fields email,company_name --scope global --json
180
+ coil --profile prod automations list --status draft --json --output-version 2
181
+ coil --profile prod automations view <automation-id> --json
182
+ coil --profile prod automations validate <automation-id> --json
183
+ coil --profile prod automations results list --automation <automation-id> --json --output-version 2
184
+ coil --profile prod automations results links add <result-id> --kind webhook_evidence --url "https://logs.example.com/run/1" --label "n8n execution" --json
185
+ ```
186
+
187
+ Publishing, deletion, and egress policy management require a human org admin session:
188
+
189
+ ```bash
190
+ coil automations publish <automation-id> --json
191
+ coil automations policy get --json
192
+ coil automations policy set --domains hooks.example.com --fields email,company_name --json
193
+ ```
194
+
195
+ ### SmartLead
196
+
197
+ ```bash
198
+ coil --profile prod integrations status smartlead --json
199
+ coil --profile prod smartlead campaigns --json
200
+ coil --profile prod smartlead sequences --campaign <campaign-id> --json
201
+ coil --profile prod smartlead send <scrape-id> --campaign <campaign-id> --filter '[{"column":"email","operator":"is_not_empty","value":""}]' --json
202
+ ```
203
+
204
+ ### Feedback
205
+
206
+ Use feedback when a Coil task exposes product friction or a platform bug. Do not include secrets.
207
+
208
+ ```bash
209
+ coil feedback "SmartLead send failed for selected leads" --type bug --json
210
+ coil feedback draft --type feature "Add export link metadata" --json
211
+ coil feedback drafts --json --output-version 2
212
+ coil feedback resend <feedback-id> --json
213
+ ```
214
+
215
+ ## API Reference
216
+
217
+ Use the local reference files when you need raw HTTP details:
218
+
219
+ - `references/api-endpoints.md`
220
+ - `references/api-fields.md`
221
+
222
+ Prefer the CLI for routine operations because it handles auth, profiles, output normalization, and local retry behavior.
@@ -0,0 +1,139 @@
1
+ # Coil API Endpoints
2
+
3
+ Base URL comes from the selected CLI profile or `COIL_BASE_URL`.
4
+
5
+ All authenticated requests use:
6
+
7
+ ```http
8
+ Authorization: Bearer <session-token-or-ak-key>
9
+ Content-Type: application/json
10
+ ```
11
+
12
+ Coil organization API keys (`ak_...`) are accepted by agent-compatible routes.
13
+
14
+ ## Auth and Context
15
+
16
+ | Method | Path | Notes |
17
+ | --- | --- | --- |
18
+ | GET | `/api/auth/status` | Returns authenticated actor, org, role, token type, and auth mode. |
19
+ | GET | `/api/auth/api-keys` | Lists Coil organization API keys for human org admins. |
20
+ | DELETE | `/api/auth/api-keys/{id}` | Revokes a Coil organization API key for human org admins. |
21
+ | POST | `/api/auth/verify-key` | Verifies a Coil organization API key without exposing it. |
22
+
23
+ CLI discovery wrapper:
24
+
25
+ ```bash
26
+ coil agent-context --json
27
+ ```
28
+
29
+ ## Activation, Marketplace, and Recipes
30
+
31
+ | Method | Path | CLI |
32
+ | --- | --- | --- |
33
+ | GET | `/api/activation` | `coil activation status --json` |
34
+ | GET | `/api/marketplace/templates` | `coil marketplace templates --json` |
35
+ | POST | `/api/marketplace/templates/{id}/install` | `coil marketplace install <template-id> --json` |
36
+ | GET | `/api/recipes` | `coil recipes list --json` |
37
+ | GET | `/api/recipes/{id}` | `coil recipes view <recipe-id> --json` |
38
+ | POST | `/api/recipes/{id}/validate` | `coil recipes validate <recipe-id> --json` |
39
+ | POST | `/api/recipes/{id}/publish` | `coil recipes publish <recipe-id> --json` |
40
+ | POST | `/api/recipes/{id}/run` | `coil recipes run <recipe-id> --confirm-provider-spend ... --json` |
41
+ | GET | `/api/recipe-runs/{id}` | `coil recipe-runs view <run-id> --json` |
42
+ | POST | `/api/recipe-runs/{id}/cancel` | `coil recipe-runs cancel <run-id> --json` |
43
+ | POST | `/api/recipe-runs/{id}/retry` | `coil recipe-runs retry <run-id> --json` |
44
+
45
+ Provider-backed admission requires explicit spend confirmation. A `202` queued response is durable admission, not terminal success. Recipe publication and ambiguous-effect reconciliation require a human organization admin.
46
+
47
+ ## Scrapes
48
+
49
+ | Method | Path | CLI |
50
+ | --- | --- | --- |
51
+ | GET | `/api/scrapes?limit=100` | `coil scrapes list --json` |
52
+ | POST | `/api/scrapes` | `coil scrapes create ... --json` |
53
+ | GET | `/api/scrapes/{id}` | `coil scrapes get <id> --json` |
54
+ | PATCH | `/api/scrapes/{id}` | `coil scrapes rename <id> --name ...` |
55
+ | DELETE | `/api/scrapes/{id}` | `coil scrapes delete <id> --force` |
56
+ | POST | `/api/scrapes/{id}/recount` | `coil scrapes recount <id> --json` |
57
+ | GET | `/api/scrapes/{id}/leads` | `coil leads list <scrape-id> --json` |
58
+ | GET | `/api/leads` | `coil leads list --json` |
59
+
60
+ Machine callers can create scrapes. Admin-gated destructive routes require a human org admin session.
61
+
62
+ ## Leads
63
+
64
+ | Method | Path | CLI |
65
+ | --- | --- | --- |
66
+ | GET | `/api/scrapes/{scrapeId}/leads?limit=50&offset=0` | `coil leads list <scrape-id> --json` |
67
+ | GET | `/api/leads?limit=50&offset=0` | `coil leads list --json` |
68
+ | GET | `/api/leads/{id}` | `coil leads get <id> --json` |
69
+ | PATCH | `/api/leads/{id}` | `coil leads update <id> --fields '{...}' --json` |
70
+ | DELETE | `/api/leads/{id}` | `coil leads delete <id> --force --json` |
71
+ | PATCH | `/api/leads/bulk` | `coil leads bulk-update --ids ... --fields '{...}' --json` |
72
+ | POST | `/api/export/csv` | `coil leads export <scrape-id> [--filter/--view/--ids] [--output]` |
73
+ | GET | `/api/leads/emails?scrapeId={id}` | `coil leads emails <scrape-id>` |
74
+
75
+ ## Automations
76
+
77
+ | Method | Path | CLI |
78
+ | --- | --- | --- |
79
+ | GET | `/api/automations?status=published` | `coil automations list --json` |
80
+ | POST | `/api/automations` | `coil automations create ... --json` |
81
+ | GET | `/api/automations/{id}` | `coil automations view <id> --json` |
82
+ | PATCH | `/api/automations/{id}` | `coil automations update <id> ... --json` |
83
+ | DELETE | `/api/automations/{id}` | `coil automations delete <id> --force --json` |
84
+ | POST | `/api/automations/{id}/validate` | `coil automations validate <id> --json` |
85
+ | POST | `/api/automations/{id}/publish` | `coil automations publish <id> --json` |
86
+ | POST | `/api/automations/{id}/run` | `coil automations run <id> --scrape <scrape-id> --json` |
87
+ | GET | `/api/automations/{id}/results` | `coil automations results list --automation <id> --json` |
88
+ | GET | `/api/automations/results` | `coil automations results list --json` |
89
+ | GET | `/api/automations/results/{resultId}` | `coil automations results view <result-id> --json` |
90
+ | GET | `/api/automations/results/{resultId}/links` | `coil automations results links list <result-id> --json` |
91
+ | POST | `/api/automations/results/{resultId}/links` | `coil automations results links add <result-id> --kind webhook_evidence --url https://... --json` |
92
+ | DELETE | `/api/automations/results/{resultId}/links/{linkId}` | `coil automations results links delete <result-id> <link-id> --json` |
93
+ | GET | `/api/automations/policy` | `coil automations policy get --json` |
94
+ | PUT | `/api/automations/policy` | `coil automations policy set --domains ... --fields ... --json` |
95
+
96
+ API-key callers create draft automations. Publishing, deletion, and policy changes require a human org admin session. Automation result links are deliberately narrow traceability records for delivery evidence; they are not a generic metadata surface.
97
+
98
+ ## Settings, Preferences, Members
99
+
100
+ | Method | Path | CLI |
101
+ | --- | --- | --- |
102
+ | GET | `/api/settings` | `coil settings get --json` |
103
+ | PATCH | `/api/settings` | `coil settings set <key> <value> --json` |
104
+ | GET | `/api/preferences/columns?scrapeId={id}` | `coil preferences get --scrape <id> --json` |
105
+ | PUT | `/api/preferences/columns` | `coil preferences set --scrape <id> ... --json` |
106
+ | GET | `/api/members` | `coil members list --json` |
107
+
108
+ ## Integrations and SmartLead
109
+
110
+ | Method | Path | CLI |
111
+ | --- | --- | --- |
112
+ | GET | `/api/integrations` | `coil integrations list --json` |
113
+ | GET | `/api/integrations/{provider}` | `coil integrations status <provider> --json` |
114
+ | PUT | `/api/integrations/{provider}` | `printf '%s' "$PROVIDER_API_KEY" \| coil integrations set <provider> --json` |
115
+ | DELETE | `/api/integrations/{provider}` | `coil integrations disconnect <provider> --json` |
116
+ | POST | `/api/integrations/{provider}/test` | `coil integrations test <provider> --json` |
117
+ | POST | `/api/integrations/rotate` | `coil integrations rotate [--apply] --json` |
118
+ | GET | `/api/integrations/smartlead/campaigns` | `coil smartlead campaigns --json` |
119
+ | GET | `/api/integrations/smartlead/campaigns/sequences?campaignId={id}` | `coil smartlead sequences --campaign <id> --json` |
120
+ | POST | `/api/integrations/smartlead/send` | `coil smartlead send <scrape-id> --campaign <id> --json` |
121
+
122
+ ## Feedback
123
+
124
+ | Method | Path | CLI |
125
+ | --- | --- | --- |
126
+ | POST | `/api/feedback` | `coil feedback "..." --json` |
127
+
128
+ The CLI also supports local feedback drafts and resend commands for retryable failures.
129
+
130
+ ## Activity and Usage
131
+
132
+ | Method | Path | CLI |
133
+ | --- | --- | --- |
134
+ | GET | `/api/activity` | `coil activity list --json` |
135
+ | GET | `/api/usage?view=summary` | `coil usage summary --json` |
136
+ | GET | `/api/usage?view=events` | `coil usage events --json` |
137
+ | GET | `/api/usage?view=limits` | `coil usage limits --json` |
138
+
139
+ Activity cursors are opaque. Usage endpoints require an organization admin, so machine API keys are intentionally rejected by the shared role gate.
@@ -0,0 +1,202 @@
1
+ # Coil API Fields
2
+
3
+ Responses use snake_case. Request bodies generally accept the field names shown by the CLI and API route schemas.
4
+
5
+ ## Auth Context
6
+
7
+ `GET /api/auth/status` returns:
8
+
9
+ | Field | Type | Notes |
10
+ | --- | --- | --- |
11
+ | `authenticated` | boolean | Whether the bearer token is valid. |
12
+ | `userId` | string/null | Human Clerk user ID when present. |
13
+ | `orgId` | string/null | Clerk organization ID. |
14
+ | `orgRole` | `org:admin`/`org:member`/null | Role for human sessions; API keys map to member behavior. |
15
+ | `tokenType` | string/null | Usually `session_token` or `api_key`. |
16
+ | `actor` | object | `{ type, id, name }` where type is `human` or `machine`. |
17
+
18
+ ## CLI Output Envelope
19
+
20
+ Use `--json --output-version 2` on list commands when possible:
21
+
22
+ ```json
23
+ {
24
+ "resource": "scrapes",
25
+ "items": [],
26
+ "total": 0,
27
+ "limit": 25,
28
+ "offset": 0,
29
+ "nextOffset": null,
30
+ "truncated": false,
31
+ "hint": null
32
+ }
33
+ ```
34
+
35
+ ## Scrape
36
+
37
+ | Field | Type | Notes |
38
+ | --- | --- | --- |
39
+ | `id` | string | UUID. |
40
+ | `org_id` | string | Tenant isolation key. |
41
+ | `name` | string | Human-visible scrape name. |
42
+ | `status` | string | `pending`, `running`, `completed`, `failed`, or legacy/null values. |
43
+ | `job_titles` | string[] | Prospect scrape filter. |
44
+ | `locations` | string[] | Prospect scrape filter. |
45
+ | `company_sizes` | string[] | Prospect scrape filter. |
46
+ | `industries_include` | string[]/null | Prospect filter. |
47
+ | `industries_exclude` | string[]/null | Prospect filter. |
48
+ | `search_url` | string/null | Sales Navigator source URL in create payloads. |
49
+ | `fetch_count` | number | Requested prospect count. |
50
+ | `prospect_count` | number | Current stored lead count. |
51
+ | `smartlead_campaign_id` | string/null | Optional auto-send campaign. |
52
+ | `created_by_user_id` | string/null | Human creator when known. |
53
+ | `created_at` | ISO string | Creation timestamp. |
54
+
55
+ Create payloads:
56
+
57
+ ```json
58
+ {
59
+ "type": "prospect",
60
+ "name": "Q2 prospects",
61
+ "job_titles": ["VP Sales"],
62
+ "locations": ["United States"],
63
+ "company_sizes": ["11-50"],
64
+ "fetch_count": 100,
65
+ "confirm_provider_spend": true
66
+ }
67
+ ```
68
+
69
+ ```json
70
+ {
71
+ "type": "sales-nav",
72
+ "name": "Sales Nav export",
73
+ "search_url": "https://www.linkedin.com/sales/search/people?...",
74
+ "user_agent": "Mozilla/5.0 ...",
75
+ "linkedin_cookie": "li_at=...",
76
+ "confirm_provider_spend": true
77
+ }
78
+ ```
79
+
80
+ `confirm_provider_spend` is required for provider-backed acquisition. Sales Navigator credentials are resolved server-side for execution and must not be stored in recipe inputs or copied into logs.
81
+
82
+ ## Lead
83
+
84
+ | Field | Type | Notes |
85
+ | --- | --- | --- |
86
+ | `id` | string | UUID. |
87
+ | `org_id` | string | Tenant isolation key. |
88
+ | `scrape_id` | string | Parent scrape. |
89
+ | `full_name` | string/null | Lead name. |
90
+ | `first_name` | string/null | First name. |
91
+ | `last_name` | string/null | Last name. |
92
+ | `email` | string/null | Email when known. |
93
+ | `linkedin` | string/null | LinkedIn profile URL. |
94
+ | `job_title` | string/null | Role/title. |
95
+ | `company_name` | string/null | Company. |
96
+ | `company_domain` | string/null | Company domain. |
97
+ | `location` | string/null | Lead location. |
98
+ | `is_bad_fit` | boolean/null | Qualification marker. |
99
+ | `added_to_smartlead` | boolean/null | SmartLead send marker. |
100
+ | `smartlead_campaign` | string/null | Campaign name. |
101
+ | `date_scraped` | string/null | Scrape date. |
102
+
103
+ Filter conditions use:
104
+
105
+ ```json
106
+ {
107
+ "column": "email",
108
+ "operator": "is_not_empty",
109
+ "value": ""
110
+ }
111
+ ```
112
+
113
+ Supported operators include equality, contains, empty/not-empty, booleans, and date comparisons. Use `coil agent-context --json` for the current command manifest.
114
+
115
+ ## Automation
116
+
117
+ | Field | Type | Notes |
118
+ | --- | --- | --- |
119
+ | `id` | string | UUID. |
120
+ | `org_id` | string | Tenant isolation key. |
121
+ | `name` | string | Automation name. |
122
+ | `webhook_url` | string | HTTPS webhook URL. |
123
+ | `input_fields` | string[] | Lead fields sent to the webhook. |
124
+ | `scope` | `global`/`scrape` | Whether automation applies globally or to one scrape. |
125
+ | `scrape_id` | string/null | Required for scrape-scoped automation. |
126
+ | `status` | `draft`/`published`/`disabled` | API-key agents create drafts. |
127
+ | `created_by_actor_type` | `human`/`machine` | Actor attribution. |
128
+ | `created_by_actor_id` | string | User ID or API key ID. |
129
+ | `created_by_actor_name` | string/null | API key display name when available. |
130
+ | `published_by_user_id` | string/null | Human admin publisher. |
131
+ | `published_at` | ISO string/null | Publication timestamp. |
132
+
133
+ Create payload:
134
+
135
+ ```json
136
+ {
137
+ "name": "Enrich leads",
138
+ "webhook_url": "https://hooks.example.com/enrich",
139
+ "input_fields": ["email", "company_name"],
140
+ "scope": "global",
141
+ "scrape_id": null
142
+ }
143
+ ```
144
+
145
+ ## Automation Result
146
+
147
+ | Field | Type | Notes |
148
+ | --- | --- | --- |
149
+ | `id` | string | UUID. |
150
+ | `automation_id` | string | Parent automation. |
151
+ | `lead_id` | string | Processed lead. |
152
+ | `status` | `pending`/`running`/`success`/`error` | Delivery state. |
153
+ | `result` | string/null | Webhook response or summary. |
154
+ | `error_message` | string/null | Failure detail. |
155
+ | `created_at` | ISO string | Creation timestamp. |
156
+ | `updated_at` | ISO string | Last status update. |
157
+
158
+ ## Automation Result Link
159
+
160
+ | Field | Type | Notes |
161
+ | --- | --- | --- |
162
+ | `id` | string | UUID. |
163
+ | `automation_result_id` | string | Parent automation result. |
164
+ | `kind` | `webhook_evidence`/`smartlead_campaign`/`delivery_evidence` | Accepted evidence class. |
165
+ | `label` | string/null | Short label or delivery ID, max 120 characters. |
166
+ | `url` | string | HTTPS URL, max 2048 characters. `smartlead_campaign` URLs must use a `smartlead.ai` host. |
167
+ | `created_at` | ISO string | Creation timestamp. |
168
+
169
+ Create payload:
170
+
171
+ ```json
172
+ {
173
+ "kind": "webhook_evidence",
174
+ "label": "n8n execution",
175
+ "url": "https://logs.example.com/run/1"
176
+ }
177
+ ```
178
+
179
+ ## Automation Policy
180
+
181
+ | Field | Type | Notes |
182
+ | --- | --- | --- |
183
+ | `domains` | string[] | Exact or wildcard allowed webhook hosts. |
184
+ | `fields` | string[] | Allowed lead fields for automation payloads. |
185
+
186
+ Only human org admins can update policy.
187
+
188
+ ## Settings
189
+
190
+ Settings are stored in org-scoped JSON. Common keys:
191
+
192
+ | Key | Type | Notes |
193
+ | --- | --- | --- |
194
+ | `has_smartlead_key` | boolean | Read-only redacted presence marker. |
195
+ | `smartlead_api_key` | string | Legacy setting. New writes are rejected; configure SmartLead with `printf '%s' "$SMARTLEAD_API_KEY" \| coil integrations set smartlead` or `PUT /api/integrations/smartlead`. |
196
+ | `default_columns` | string[] | Optional lead table defaults. |
197
+
198
+ ## Agent Caveats
199
+
200
+ - API-key agents currently behave as org members, not org admins.
201
+ - Admin-gated actions include destructive scrape/automation operations, automation policy management, and automation publishing.
202
+ - Prefer `coil feedback` for platform friction discovered while operating Coil.