Start your launch Sign in
APIs 5 min read

The reference executor

One Node file that runs the whole loop for a fleet: pull tasks, draft just in time, post from your own browser profiles, write results back, and report outcomes a day later.

Last updated July 23, 2026

The executor is the smallest complete integration: around 140 lines of Node that pull each account’s tasks, draft the one about to be worked, hand it to your browser tooling, report the result, and come back a day later to say whether each comment survived. It exists so you can see the whole loop in one place before writing your own.

Example code, not a product

This script is unsupported by design. The Nilkick half (the API calls) is stable contract; the browser half is yours, and its selectors will need attention whenever Reddit changes its DOM. Compliance with each platform’s terms stays your responsibility, exactly as it does when you post by hand.

01 · The codeThe script

executor.js
// The Nilkick reference executor. Node 20+, no dependencies beyond your own
// browser-profile vendor. Unsupported example code: adapt freely.
//
//   NILKICK_KEY=nk_live_...  node executor.js work      # the daily pass
//   NILKICK_KEY=nk_live_...  node executor.js outcomes  # ~a day later
//
// State lives in ./executor-state.json: comments posted that still need an
// outcome report.

import { readFile, writeFile } from 'node:fs/promises';

const API = 'https://api.nilkick.com/v1';
const KEY = process.env.NILKICK_KEY;
const STATE_FILE = new URL('./executor-state.json', import.meta.url);
const OUTCOME_AFTER_MS = 18 * 60 * 60 * 1000;

// ── Nilkick over HTTP ────────────────────────────────────────────────────

async function nilkick(method, path, body) {
	const res = await fetch(API + path, {
		method,
		headers: {
			authorization: `Bearer ${KEY}`,
			...(body ? { 'content-type': 'application/json' } : {})
		},
		body: body ? JSON.stringify(body) : undefined
	});
	if (res.status === 429) {
		const wait = Number(res.headers.get('retry-after') ?? 60);
		await new Promise((resolve) => setTimeout(resolve, wait * 1000));
		return nilkick(method, path, body);
	}
	const data = await res.json();
	if (!res.ok) {
		throw new Error(`${method} ${path}: ${data.error?.code} ${data.error?.message}`);
	}
	return data;
}

// ── Your side: the three functions to replace ────────────────────────────

/**
 * Map a reddit username to a profile in YOUR manager and launch it. Every
 * vendor (GoLogin, AdsPower, Multilogin, Dolphin Anty) exposes some form of
 * "start profile, hand me a puppeteer/playwright endpoint". Return
 * { browser, close } with a puppeteer-compatible Browser.
 */
async function launchProfile(username) {
	throw new Error(`launchProfile(${username}): wire up your vendor's SDK here`);
}

/**
 * Post `text` as a reply in the thread at `url`; return the comment permalink.
 * Deliberately left to you: selectors rot with every Reddit redesign, and this
 * function should stay slow and human-shaped.
 */
async function postReply(browser, url, text) {
	throw new Error('postReply: implement against your own browser tooling');
}

/** Does the comment at `url` still exist for a logged-out reader? */
async function commentSurvived(browser, url) {
	throw new Error('commentSurvived: a logged-out read of the permalink');
}

// ── Pass 1: the day's work ───────────────────────────────────────────────

async function workPass() {
	const state = await loadState();
	const { warmups } = await nilkick('GET', '/v1/warmups');

	for (const warmup of warmups.filter((w) => w.status === 'active')) {
		const { tasks } = await nilkick('GET', `/v1/tasks?warmup_id=${warmup.id}&status=ready`);
		const replies = tasks.filter((t) => t.mode === 'thread_comment');

		if (replies.length) {
			const { browser, close } = await launchProfile(warmup.username);
			try {
				for (const task of replies) {
					// Draft just in time: never the whole batch up front.
					let asset = task.generated_asset;
					if (task.draft_state === 'lazy') {
						({ generated_asset: asset } = await nilkick(
							'POST',
							`/v1/tasks/${task.id}/draft`,
							{ action: 'lazy' }
						));
					}

					const commentUrl = await postReply(browser, task.target.url, asset.comment);
					const done = await nilkick('POST', `/v1/tasks/${task.id}/status`, {
						status: 'done',
						result: { comment_url: commentUrl }
					});
					state.posted.push({ taskId: task.id, commentUrl, at: new Date().toISOString() });

					// The second comment often unlocks six hours after the first:
					// schedule the next run from this instead of polling.
					if (done.unlock_at) console.log(`${warmup.username}: next slot ${done.unlock_at}`);
				}
			} finally {
				await close();
			}
		}

		// Joins, votes, and post-shaped work still show up in the app with
		// one-tap copy: leave them to a human rather than skipping them.
		for (const other of tasks.filter((t) => t.mode !== 'thread_comment')) {
			console.log(`${warmup.username}: needs a human, ${other.mode}: ${other.why}`);
		}
	}

	await saveState(state);
}

// ── Pass 2, ~a day later: did each comment survive? ─────────────────────

async function outcomesPass() {
	const state = await loadState();
	const due = state.posted.filter((p) => Date.now() - Date.parse(p.at) > OUTCOME_AFTER_MS);
	if (!due.length) return;

	// Any profile works here; the check is a logged-out read.
	const { browser, close } = await launchProfile('checker');
	try {
		for (const entry of due) {
			const alive = await commentSurvived(browser, entry.commentUrl);
			await nilkick('POST', `/v1/tasks/${entry.taskId}/outcome`, {
				status: alive ? 'live' : 'removed'
			});
			state.posted = state.posted.filter((p) => p.taskId !== entry.taskId);
		}
	} finally {
		await close();
	}
	await saveState(state);
}

// ── State + entry ────────────────────────────────────────────────────────

async function loadState() {
	try {
		return JSON.parse(await readFile(STATE_FILE, 'utf8'));
	} catch {
		return { posted: [] };
	}
}

async function saveState(state) {
	await writeFile(STATE_FILE, JSON.stringify(state, null, '\t'));
}

if (!KEY) throw new Error('Set NILKICK_KEY to an nk_live_ key.');
const mode = process.argv[2];
if (mode === 'work') await workPass();
else if (mode === 'outcomes') await outcomesPass();
else console.log('Usage: node executor.js work | outcomes');

02 · AdaptingMake it yours

The three stubbed functions are the whole integration surface. launchProfile is a lookup plus your vendor’s start call, and because the API returns each account’s reddit username, the mapping to profiles named after those accounts is usually one line. postReply is where your existing automation goes; keep it unhurried. commentSurvived can be a browser visit or a plain HTTP read, whatever your stack prefers.

Scouts run the identical loop: swap /v1/warmups for /v1/scouts and filter tasks by scout_id. Scout tasks are replies to live conversations, so the needs-a-human branch mostly disappears.

03 · PacingWhat not to optimise

Do not chase speed inside one account. Nilkick paces comments at two per account per day with the second held six hours behind the first, so a script that could post in three seconds still waits for its slot. Fleets parallelise across accounts, not within one, and your vendor’s concurrent-session cap staggers the draft calls naturally. The right place to spend effort is reliability: the write-backs after posting and the outcome pass a day later, because that reporting is what keeps each account’s plan adapting.


FAQ

Common questions

No. It is documentation in code form: copy it, rewrite it, own it. There is no npm package, no versioning, and no promise it keeps working as platforms change their pages.