Skip to content

Commit 1eb6b5a

Browse files
NOTICKET Add sequence-lifecycle commands (create/update/approve/schedules) (#32)
Add the four sequence-lifecycle MCP tools the CLI was missing: sequences create POST /sequences (--name, --steps-file, --schedule-id, --permissions, --exact-daytime, --active, --label) sequences update PATCH /sequences/:id (--id, --steps-file, + --active/--inactive, etc.) sequences approve POST /emailer_campaigns/:id/approve (--id) sequences schedules GET /emailer_schedules emailer_steps is supplied as a JSON file (--steps-file), mirroring the analytics --payload / bulk-create --file idiom. Routes/verbs confirmed against the leadgenie sequences + emailer_campaigns controllers. These mirror MCP tools still behind Apollo's in-rollout mcp_sequences flag, so a token without that scope gets a clean 403 API_INACCESSIBLE today; the commands are wired correctly and will work once the flag reaches GA. The read-only live test asserts exactly that (success OR the known 403). Tests: - unit: buildSequenceCreateBody / buildSequenceUpdateBody mappings - live read-only: sequences schedules (GA-tolerant) - live write tier (APOLLO_LIVE_WRITES): sequences create/update - live side-effecting tier (APOLLO_LIVE_DANGEROUS): approve, add-contacts (real email) — all gated + fixture-driven, throwaway team only Update SKILL.md, README.md, and src/live/README.md. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bba3290 commit 1eb6b5a

7 files changed

Lines changed: 281 additions & 3 deletions

File tree

.claude/skills/apollo-cli/SKILL.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,17 @@ apollo sequences add-contacts --id <seq_id> --from-email-account <id> --label "Q
190190
apollo sequences remove-contacts --contact-id <id> --sequence-id <seq_id> --mode remove
191191
```
192192

193+
**Build and manage sequences:**
194+
195+
```bash
196+
apollo sequences schedules # list send schedules -> emailer_schedule_id
197+
apollo sequences create --name "Q2 Outbound" --steps-file ./steps.json --schedule-id <id>
198+
apollo sequences update --id <seq_id> --steps-file ./steps.json --active # --steps-file is the FULL step set
199+
apollo sequences approve --id <seq_id> # approve a sequence pending review
200+
```
201+
202+
`create`/`update` take the ordered `emailer_steps` as a JSON file (`--steps-file`); `create` requires `--name`, `update` requires `--id`. Activation: `create --active`, or `update --active`/`--inactive`. Get `--schedule-id` from `sequences schedules`. Activating + approving means contacts can start receiving real email — confirm with the user first.
203+
193204
---
194205

195206
### Phone calls

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,17 +320,25 @@ apollo deals show --id <opportunity-id>
320320

321321
### `apollo sequences`
322322

323-
Search sequences and enroll/remove contacts. **`add-contacts` sends real emails — confirm before running.**
323+
Create/update sequences, manage schedules, approve, and enroll/remove contacts. **`add-contacts` sends real emails — confirm before running.**
324324

325325
```bash
326326
apollo sequences search --query "welcome" --per-page 10
327+
apollo sequences schedules
328+
apollo sequences create --name "Q2 Outbound" --steps-file ./steps.json --schedule-id <schedule-id>
329+
apollo sequences update --id <seq-id> --steps-file ./steps.json --active
330+
apollo sequences approve --id <seq-id>
327331
apollo sequences add-contacts --id <seq-id> --from-email-account <email-account-id> --contact-id <contact-id>
328332
apollo sequences remove-contacts --contact-id <id> --sequence-id <seq-id> --mode remove
329333
```
330334

331335
| Subcommand | Notes |
332336
|---|---|
333337
| `search` | `-q/--query` matches sequence names; paging supported. |
338+
| `schedules` | Lists sending schedules; use a returned id as `--schedule-id` on create/update. |
339+
| `create` | Required: `--name`, `--steps-file` (JSON `emailer_steps`). Optional: `--schedule-id`, `--permissions`, `--exact-daytime`, `--active`, `--label`. |
340+
| `update` | Required: `--id`, `--steps-file` (the FULL step set after update). Optional: `--name`, `--schedule-id`, `--permissions`, `--exact-daytime`, `--active`/`--inactive`, `--label`. |
341+
| `approve` | `--id` required. Approves a sequence pending review. |
334342
| `add-contacts` | Required: `--id`, `--from-email-account`. Provide `--contact-id` or `--label`. Optional: `--from-email`, `--no-email`, `--unverified-email`, `--job-change`, `--active-in-other`, `--finished-in-other`, `--same-company`, `--without-ownership`, `--add-if-in-queue`, `--skip-verification`, `--status active|paused`, `--auto-unpause-at <iso>`. |
335343
| `remove-contacts` | `--contact-id`, `--sequence-id`, `--mode remove\|stop`, `--reason <text>`. |
336344

src/commands/sequences.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { buildSequenceCreateBody, buildSequenceUpdateBody } from './sequences.js';
3+
4+
const STEPS = [{ position: 1, type: 'auto_email' }];
5+
6+
describe('buildSequenceCreateBody', () => {
7+
it('always includes name and emailer_steps', () => {
8+
expect(buildSequenceCreateBody({ name: 'Q2 Outbound', stepsFile: 'x.json' }, STEPS)).toEqual({
9+
name: 'Q2 Outbound',
10+
emailer_steps: STEPS,
11+
});
12+
});
13+
14+
it('maps optional fields', () => {
15+
const body = buildSequenceCreateBody(
16+
{
17+
name: 'Q2',
18+
stepsFile: 'x.json',
19+
scheduleId: 'sched_1',
20+
permissions: 'team_can_use',
21+
exactDaytime: true,
22+
active: true,
23+
label: ['outbound'],
24+
},
25+
STEPS,
26+
);
27+
expect(body).toEqual({
28+
name: 'Q2',
29+
emailer_steps: STEPS,
30+
emailer_schedule_id: 'sched_1',
31+
permissions: 'team_can_use',
32+
sequence_by_exact_daytime: true,
33+
active: true,
34+
label_names: ['outbound'],
35+
});
36+
});
37+
38+
it('omits active when not set', () => {
39+
expect(buildSequenceCreateBody({ name: 'Q2', stepsFile: 'x.json' }, STEPS).active).toBeUndefined();
40+
});
41+
});
42+
43+
describe('buildSequenceUpdateBody', () => {
44+
it('always sends emailer_steps and leaves unset fields out', () => {
45+
expect(buildSequenceUpdateBody({ id: 'seq_1', stepsFile: 'x.json' }, STEPS)).toEqual({
46+
emailer_steps: STEPS,
47+
});
48+
});
49+
50+
it('--active sets active true; --inactive sets active false', () => {
51+
expect(buildSequenceUpdateBody({ id: 's', stepsFile: 'x', active: true }, STEPS).active).toBe(true);
52+
expect(buildSequenceUpdateBody({ id: 's', stepsFile: 'x', inactive: true }, STEPS).active).toBe(false);
53+
});
54+
});

src/commands/sequences.ts

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Command } from 'commander';
2-
import { apolloRequest } from '../api.js';
2+
import { apolloGet, apolloRequest } from '../api.js';
33
import { print, FORMAT_OPTION } from '../output.js';
44
import { parsePageOptions } from '../utils.js';
55

@@ -38,8 +38,85 @@ interface SequenceRemoveContactsOptions {
3838
format?: string;
3939
}
4040

41+
interface SequenceCreateOptions {
42+
name: string;
43+
stepsFile: string;
44+
scheduleId?: string;
45+
permissions?: string;
46+
exactDaytime?: boolean;
47+
active?: boolean;
48+
label?: string[];
49+
format?: string;
50+
}
51+
52+
interface SequenceUpdateOptions {
53+
id: string;
54+
stepsFile: string;
55+
name?: string;
56+
scheduleId?: string;
57+
permissions?: string;
58+
exactDaytime?: boolean;
59+
active?: boolean;
60+
inactive?: boolean;
61+
label?: string[];
62+
format?: string;
63+
}
64+
65+
interface SequenceApproveOptions {
66+
id: string;
67+
format?: string;
68+
}
69+
70+
interface SequenceSchedulesOptions {
71+
format?: string;
72+
}
73+
74+
// Reads emailer_steps from a JSON file (an array, or { "emailer_steps": [...] }).
75+
async function readSteps(path: string): Promise<unknown[]> {
76+
const fs = await import('node:fs/promises');
77+
const parsed: unknown = JSON.parse(await fs.readFile(path, 'utf8'));
78+
const arr = Array.isArray(parsed)
79+
? parsed
80+
: (parsed as { emailer_steps?: unknown }).emailer_steps;
81+
if (!Array.isArray(arr)) {
82+
console.error('Error: steps file must contain a JSON array (or { "emailer_steps": [...] })');
83+
process.exit(1);
84+
}
85+
return arr;
86+
}
87+
88+
// Maps sequence create options + parsed steps to the /sequences request body. Pure.
89+
export function buildSequenceCreateBody(
90+
opts: SequenceCreateOptions,
91+
steps: unknown[],
92+
): Record<string, unknown> {
93+
const body: Record<string, unknown> = { name: opts.name, emailer_steps: steps };
94+
if (opts.permissions) body.permissions = opts.permissions;
95+
if (opts.scheduleId) body.emailer_schedule_id = opts.scheduleId;
96+
if (opts.exactDaytime) body.sequence_by_exact_daytime = true;
97+
if (opts.active) body.active = true;
98+
if (opts.label) body.label_names = opts.label;
99+
return body;
100+
}
101+
102+
// Maps sequence update options + parsed steps to the /sequences/:id request body. Pure.
103+
export function buildSequenceUpdateBody(
104+
opts: SequenceUpdateOptions,
105+
steps: unknown[],
106+
): Record<string, unknown> {
107+
const body: Record<string, unknown> = { emailer_steps: steps };
108+
if (opts.name) body.name = opts.name;
109+
if (opts.permissions) body.permissions = opts.permissions;
110+
if (opts.scheduleId) body.emailer_schedule_id = opts.scheduleId;
111+
if (opts.exactDaytime) body.sequence_by_exact_daytime = true;
112+
if (opts.active) body.active = true;
113+
if (opts.inactive) body.active = false;
114+
if (opts.label) body.label_names = opts.label;
115+
return body;
116+
}
117+
41118
export function registerSequences(program: Command): void {
42-
const seq = program.command('sequences').description('Search sequences and add or remove contacts');
119+
const seq = program.command('sequences').description('Manage sequences: search, create/update, approve, schedules, and add/remove contacts');
43120

44121
seq
45122
.command('search')
@@ -123,4 +200,63 @@ export function registerSequences(program: Command): void {
123200
const data = await apolloRequest('/emailer_campaigns/remove_or_stop_contact_ids', body);
124201
print(data, opts.format);
125202
});
203+
204+
seq
205+
.command('create')
206+
.description('Create a sequence from a steps JSON file')
207+
.requiredOption('--name <name>', 'Sequence name')
208+
.requiredOption('--steps-file <path>', 'Path to JSON file with the ordered emailer_steps (array, or { "emailer_steps": [...] })')
209+
.option('--schedule-id <id>', 'EmailerSchedule ID controlling send-time windows (see "sequences schedules")')
210+
.option('--permissions <perm>', 'Who can use/view the sequence (defaults to team_can_use)')
211+
.option('--exact-daytime', 'Each step has a fixed exact datetime (one-off campaign)')
212+
.option('--active', 'Activate the sequence on creation (contacts added start sending)')
213+
.option('--label <names...>', 'Label name(s) to apply to the sequence')
214+
.option(...FORMAT_OPTION)
215+
.action(async (opts: SequenceCreateOptions) => {
216+
const steps = await readSteps(opts.stepsFile);
217+
const data = await apolloRequest('/sequences', buildSequenceCreateBody(opts, steps));
218+
print(data, opts.format);
219+
});
220+
221+
seq
222+
.command('update')
223+
.description('Update a sequence (replaces its full set of steps)')
224+
.requiredOption('--id <id>', 'Sequence ID')
225+
.requiredOption('--steps-file <path>', 'Path to JSON file with the FULL ordered emailer_steps after the update')
226+
.option('--name <name>', 'New sequence name')
227+
.option('--schedule-id <id>', 'New EmailerSchedule ID')
228+
.option('--permissions <perm>', 'New sharing permission')
229+
.option('--exact-daytime', 'Switch to exact-datetime mode')
230+
.option('--active', 'Activate the sequence as part of the update')
231+
.option('--inactive', 'Deactivate the sequence as part of the update')
232+
.option('--label <names...>', 'Replace the label set on the sequence')
233+
.option(...FORMAT_OPTION)
234+
.action(async (opts: SequenceUpdateOptions) => {
235+
if (opts.active && opts.inactive) {
236+
console.error('Error: pass only one of --active or --inactive');
237+
process.exit(1);
238+
}
239+
const steps = await readSteps(opts.stepsFile);
240+
const data = await apolloRequest(`/sequences/${opts.id}`, buildSequenceUpdateBody(opts, steps), 'PATCH');
241+
print(data, opts.format);
242+
});
243+
244+
seq
245+
.command('approve')
246+
.description('Approve a sequence pending review')
247+
.requiredOption('--id <id>', 'Sequence (emailer_campaign) ID')
248+
.option(...FORMAT_OPTION)
249+
.action(async (opts: SequenceApproveOptions) => {
250+
const data = await apolloRequest(`/emailer_campaigns/${opts.id}/approve`, {});
251+
print(data, opts.format);
252+
});
253+
254+
seq
255+
.command('schedules')
256+
.description('List sending schedules (for --schedule-id on create/update)')
257+
.option(...FORMAT_OPTION)
258+
.action(async (opts: SequenceSchedulesOptions) => {
259+
const data = await apolloGet('/emailer_schedules');
260+
print(data, opts.format);
261+
});
126262
}

src/live/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,14 @@ APOLLO_LIVE_WRITES=1 APOLLO_TEST_TEAM=<id> npm run test:live
3838
# everything, including real email / sequence activation (throwaway team only)
3939
APOLLO_LIVE_WRITES=1 APOLLO_LIVE_DANGEROUS=1 APOLLO_TEST_TEAM=<id> npm run test:live
4040
```
41+
42+
### Fixtures for the sequence tests
43+
44+
Sequences have no API delete path and `emailer_steps` is a complex structure, so the
45+
sequence write/side-effecting tests skip individually unless you supply fixtures:
46+
47+
| Env var | Used by |
48+
|---|---|
49+
| `APOLLO_TEST_STEPS_FILE` | `sequences create` / `update` (path to an `emailer_steps` JSON file) |
50+
| `APOLLO_TEST_SEQUENCE_ID` | `sequences update` / `approve` |
51+
| `APOLLO_TEST_EMAIL_ACCOUNT_ID` + `APOLLO_TEST_CONTACT_ID` | `sequences add-contacts` (real email) |

src/live/read-only.live.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ describe.skipIf(!hasCredentials())('live: read-only', () => {
5353
expect(isRecord(res.json) && isRecord(res.json.organization)).toBeTruthy();
5454
});
5555

56+
it('sequences schedules is wired (succeeds, or 403 until mcp_sequences is GA)', async () => {
57+
// The sequence-lifecycle tools are still behind Apollo's mcp_sequences rollout, so a
58+
// token without that scope gets a clean 403 API_INACCESSIBLE. Either outcome proves the
59+
// command is wired correctly — a CLI/path bug would look different.
60+
const res = await runCli(['sequences', 'schedules']);
61+
const notYetGa = res.code !== 0 && /API_INACCESSIBLE|not accessible/.test(res.stderr);
62+
expect(res.code === 0 || notYetGa, res.stderr).toBe(true);
63+
});
64+
5665
it('email-accounts list succeeds', async () => {
5766
const res = await runCli(['email-accounts', 'list']);
5867
expect(res.code, res.stderr).toBe(0);

src/live/writes.live.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { runCli, hasCredentials, writesEnabled, dangerousEnabled } from './helpers.js';
3+
4+
// Write + side-effecting live tests for the sequence-lifecycle commands.
5+
// These mutate data / send email and have no API delete path, so they:
6+
// - require the opt-in env tiers (writesEnabled / dangerousEnabled + APOLLO_TEST_TEAM)
7+
// - require operator-supplied fixtures, skipping individually when absent
8+
// Run only against a dedicated throwaway team. See ./README.md.
9+
const stepsFile = process.env.APOLLO_TEST_STEPS_FILE;
10+
const seqId = process.env.APOLLO_TEST_SEQUENCE_ID;
11+
const emailAccountId = process.env.APOLLO_TEST_EMAIL_ACCOUNT_ID;
12+
const contactId = process.env.APOLLO_TEST_CONTACT_ID;
13+
14+
describe.skipIf(!hasCredentials() || !writesEnabled())('live writes: sequences', () => {
15+
it.skipIf(!stepsFile)('create accepts a steps file', async () => {
16+
const res = await runCli([
17+
'sequences', 'create',
18+
'--name', `cli-live-test ${Date.now()}`,
19+
'--steps-file', stepsFile!,
20+
]);
21+
expect(res.code, res.stderr).toBe(0);
22+
});
23+
24+
it.skipIf(!stepsFile || !seqId)('update replaces steps on an existing sequence', async () => {
25+
const res = await runCli([
26+
'sequences', 'update',
27+
'--id', seqId!,
28+
'--steps-file', stepsFile!,
29+
]);
30+
expect(res.code, res.stderr).toBe(0);
31+
});
32+
});
33+
34+
describe.skipIf(!hasCredentials() || !dangerousEnabled())('live side-effecting: sequences', () => {
35+
it.skipIf(!seqId)('approve activates a sequence', async () => {
36+
const res = await runCli(['sequences', 'approve', '--id', seqId!]);
37+
expect(res.code, res.stderr).toBe(0);
38+
});
39+
40+
it.skipIf(!seqId || !emailAccountId || !contactId)('add-contacts enrolls a contact (SENDS REAL EMAIL)', async () => {
41+
const res = await runCli([
42+
'sequences', 'add-contacts',
43+
'--id', seqId!,
44+
'--from-email-account', emailAccountId!,
45+
'--contact-id', contactId!,
46+
]);
47+
expect(res.code, res.stderr).toBe(0);
48+
});
49+
});

0 commit comments

Comments
 (0)