Start your launch Sign in
APIs 13 min read

The API task loop

One GET for the day's work, one POST per action taken, one POST for what happened after. Three calls, and the third is what makes tomorrow's list smarter.

Last updated August 11, 2026

The whole daily flow is three endpoints: pull tasks, draft the one you are about to work, and write back what happened. This page walks the loop in order, with the exact shapes.

01 · GET /v1/tasksThe pull

GET /v1/tasks returns your work across every warmup and scout, sorted the way the product sorts it: value_rank ascending, then created_at. On a launch that is genuinely best first; on a warm-up it is the day’s running order, and on a scout it is oldest first. What value_rank means has the detail, and it is worth reading before you build a “top task” around it. Filter with query params:

  • warmup_id or scout_id: one account’s queue, the usual fleet pattern.
  • status: most scripts want ready. Also accepted: pending, scheduled, locked, done, skipped.
  • since: only tasks created after an ISO 8601 timestamp, for incremental pulls.
  • available_before: everything actionable by a given time, including scheduled tasks whose unlock lands before it.
  • limit: default 100, max 200. The response echoes limit back, so tasks.length === limit means there may be more.

Omit status entirely and the pull returns the whole plan: ready, scheduled, locked, done and skipped together. Everything is always visible; the status filter is a convenience, not a curtain. The response also carries a plan summary block covering the whole warmup_id/scout_id scope regardless of the list’s own filters, with per-status counts, next_available_at (the earliest known unlock among scheduled tasks) and revision (an opaque timestamp that changes whenever anything mutates the plan, for cache invalidation):

The pull response envelope
{
	"tasks": [ ... ],
	"limit": 100,
	"plan": {
		"total": 41, "ready": 9, "scheduled": 2, "locked": 0, "pending": 0, "done": 27, "skipped": 3,
		"next_available_at": "2026-08-12T03:15:00.000Z",
		"revision": "2026-08-11T21:04:12.000Z"
	}
}

Running a fleet of executors, or want crash-safe claim semantics on top of this pull? Task batches wrap the same loop in an atomic claim: at most three tasks, leased, drafted, replayable by idempotency key.

A task (abridged)
{
	"id": "b7e41d80-52c6-4f0a-9c1e-7d3a9e2f5c88",
	"status": "ready",
	"mode": "thread_comment",
	"category": "community",
	"tier": "blob",
	"venue_slug": "reddit",
	"warmup_id": "3f2b7c1e-9d5a-4f42-8f6e-2a91d0c47b13",
	"scout_id": null,
	"project_id": null,
	"value_rank": 100,
	"why": "A direct question in r/selfhosted that your product answers.",
	"target": {
		"kind": "thread",
		"subreddit": "selfhosted",
		"thread_url": "https://www.reddit.com/r/selfhosted/comments/1abcdef/...",
		"thread_title": "What are you all using for scheduled backups?",
		"discovery": "bd_scan"
	},
	"draft_state": "ready",
	"generated_asset": {
		"kind": "reddit_comment",
		"comment": "Been running exactly this setup for a while..."
	},
	"result": null,
	"available_at": null,
	"created_at": "2026-07-26T08:00:12.000Z",
	"completed_at": null
}

Every task has that envelope, whatever engine built it. What changes between tasks is mode, and what target and generated_asset hold inside it: a thread carries thread_url, an account move carries url, a directory carries the venue’s form fields. The full field reference is further down; the three sections in between are the rest of the loop.

There is no target.url on a comment task

Thread work is keyed on target.thread_url. target.url exists on account moves (warmup_action) and on launch venue cards, and is absent on thread_comment, so a script reading target.url everywhere silently gets undefined on exactly the tasks it most wants to open.

02 · DraftingDrafts are two steps

The pull never writes copy inline: drafting is a real model call, and drafting twelve tasks you might not action is exactly the waste the lazy tail exists to prevent. Tasks arrive with draft_state of ready (text included) or lazy (generated_asset is null). For a lazy task, ask for the draft at the moment you need it:

The daily loop
const { tasks } = await nilkick('GET', '/v1/tasks?scout_id=SCOUT_ID&status=ready');

for (const task of tasks) {
	// Draft only what you are about to work. Skipped tasks never cost anything.
	let asset = task.generated_asset;
	if (task.draft_state === 'lazy') {
		({ generated_asset: asset } = await nilkick('POST', `/v1/tasks/${task.id}/draft`, {
			action: 'lazy'
		}));
	}

	// Your side, your infrastructure. Comment work is keyed on thread_url.
	const url = await postWithYourStack(task.target.thread_url, asset.comment);

	// Tell Nilkick what happened. This is what drives the adaptation.
	await nilkick('POST', `/v1/tasks/${task.id}/status`, {
		status: 'done',
		result: { comment_url: url }
	});
}
Draft one task at a time

Never draft the whole batch up front. Draft immediately before acting: the couple of seconds hide inside browser work you are already doing, skipped tasks stay free, and the draft endpoint’s tighter rate budget never gets in your way. The call is idempotent, so a retry on a task that already has text costs nothing.

The response is { generated_asset, draft_state: "ready" }, the same asset the pull would have returned had it been drafted already. { action: 'lazy' } is the only accepted body: rewrites and variants stay in the app, because a script looping on them spends model time without a ceiling.

Drafted copy lives in generated_asset, and its kind tells you which fields to read: comment for replies, post_title and post_body for subreddit posts, title and body or a fields object for launch venue cards. The exact shapes are in what each mode carries. Warm-up drafts are written ahead of time by the warmer itself, so the lazy tail is mostly a scout concern.

03 · Write-backsReporting status

POST /v1/tasks/{id}/status with { "status": "done" }, "skipped", or "ready" (which restores a task you marked by mistake). When you posted something, include where: { "status": "done", "result": { "comment_url": "..." } }. The URL is how the outcome pass finds the comment later, and it is kept only when it is a reddit.com permalink: anything else is dropped without an error, since the write must never fail over an optional field. Marking done always works with no result at all.

The response is { ok, status, unlock_at }. A non-null unlock_at means completing this task started a timer on a held sibling, the everyday case being an account’s second comment of the day unlocking six hours after the first. That held task shows up in pulls as status: "scheduled" with available_at set, so a single morning fetch under-collects by design: queue the afternoon slot from the timestamp, use available_before, or let the webhook ping you.

Warm-up tasks write through a checked transaction, so some writes are refusals rather than silent no-ops, and both arrive as conflict (409). A scheduled comment refuses until its six hours have elapsed, so there is no scheduled to done shortcut. And a warm-up day stays writable only for a short grace window after it passes, two days for comments and three for account moves; older days are evidence and stay as recorded. Repeating a write you already made is a no-op, not an error. Launch and scout tasks take the write directly, with none of those checks.

04 · OutcomesOutcomes are the adaptation

About a day after posting, check whether each comment survived and report it: POST /v1/tasks/{id}/outcome with { "status": "live" }, "removed", or "unknown".

A removal can also say why, and it is worth doing: { "status": "removed", "reason": "age_gate" } when AutoModerator pulled the comment because the account is too new or too low on karma (its reply says so in as many words), "reason": "other" for a moderator removal or anything else. Absent means other. The difference matters: an age gate is a property of that subreddit, so the engine holds only that subreddit until the account is 30 days old and leaves the rest of the plan alone; a strike engages the account-wide cooldown below.

This endpoint covers warm-up thread comments, and only after you have marked them done: removal cooldowns and standing re-assessment are warm-up mechanics. A scout or launch task id returns not_found (404), and a warm-up comment still sitting at ready returns conflict (409). Post first, report the status, then report the outcome.

The outcome response
{
	"ok": true,
	"result": { "outcome": { "status": "removed", "reason": "other", "checked_at": "2026-07-27T09:12:00.000Z" } },
	"cooldown_until": "2026-07-29T09:12:00.000Z",
	"excluded_until": "2026-08-03T09:12:00.000Z",
	"canceled": 3,
	"applied": true
}

result is the task’s stored result after the write. cooldown_until, excluded_until and canceled are populated on a removal. For a strike: the account pauses commenting until cooldown_until (48 hours, or 72 after a second strike in a week), the subreddit is excluded until excluded_until (seven days), and canceled counts the queued comments pulled back across the whole plan. For an age gate: cooldown_until is unchanged, excluded_until is the day the account clears the gate (30 days old, never sooner than seven days out), and canceled counts only that subreddit’s queued comments. applied: false means an explicit answer was already on record and this one was ignored, since the first live or removed wins. A provisional unknown can still be replaced later by a real answer, never the other way round.

Treat this as a requirement of a correct integration, not an optional field. Outcome reports drive removal cooldowns, timed subreddit exclusions, standing re-assessment, and the never-offer-the-same-thread-twice guarantee. An integration that only reads gets a static list and none of the adaptation, which is exactly the part you cannot get from a prompt. The reference executor shows a clean way to run the check as a second daily pass.

05 · Field referenceThe task object

Every task carries the same envelope, whichever engine built it. The row holds more than this (venue rules, engine flags, thread grounding), and the contract is additive, so read the fields you need and ignore the rest.

Field Type What it is
id uuid The task. Every POST in the loop takes it in the path.
status enum Where the task sits in the queue. Values below.
mode enum or null The shape of the work, and the field to branch on. Values below.
category string The venue family: community, directory, ai_native, ai_directory, review_site, dev, app_store, vertical.
tier string How much work it is. blob is one field or one paste (every Reddit and forum task), copy_helper is a multi-field form.
venue_slug string Which venue, for example reddit, hacker-news, product-hunt. A discovered: prefix means the crawl found it rather than our library.
warmup_id, scout_id, project_id uuid or null Exactly one is set. project_id means the task belongs to a one-time launch.
value_rank integer The sort ordinal. Lower first. Read the section below before you trust it.
why string The one-line reason the engine picked this. Worth logging next to whatever you post.
target object Where the work goes. The keys depend on mode, see the next section.
draft_state ready or lazy lazy means the copy is not written yet and generated_asset is null. Ask the draft endpoint when you are about to work it.
generated_asset object or null The drafted copy. Its kind names the shape, see the next section.
result object or null What has been written back. comment_url if you sent one, posted_at stamped when a comment is marked done, outcome once you report one.
available_at ISO 8601 or null When a scheduled task unlocks. Null on everything else, and null on a scheduled task whose trigger has not happened yet.
created_at ISO 8601 When the engine queued it. On a scout this is the field that means “found today”, not value_rank.
completed_at ISO 8601 or null Stamped when you mark the task done or skipped, cleared if you restore it to ready.

The status values

Value Meaning
ready Actionable right now. This is what a daily pull should ask for.
scheduled Real work whose slot has not opened. available_at carries the unlock time once it is known.
locked A launch post held behind Reddit standing. It opens by itself when the account is marked established in the app, and there is nothing to do here.
done You reported it posted. completed_at is set.
skipped You passed on it, or the engine pulled it back (a removal cooldown cancels the plan’s remaining comments).
pending Accepted as a filter for forward compatibility. Nothing the engines build today sits in it.

The mode values

Value The work
thread_comment Reply to an existing thread, on Reddit or on a forum like Hacker News. Most warm-up and scout work is this.
subreddit_post A new post in a subreddit: the warm-up’s day question, or a launch post where the sub allows one.
warmup_action An account move with nothing to publish: verify the email, set an avatar or bio, join a sub, read its rules, browse it, open a thread. target.action names which one.
null A launch venue card: a directory, an app store, a review site, a Show HN. No thread, just a venue to submit to.

mode: null only ever appears on launch tasks, so a script that always filters by warmup_id or scout_id will never meet one. Branch on mode rather than on venue_slug: the same slug produces different work depending on the engine that queued it.

What value_rank means

value_rank is an ordinal, not a score. Lower sorts first, there is no unit or ceiling, and the distance between two numbers means nothing. Gaps in the sequence are normal, and ranks are only comparable inside one warm-up, scout or launch. How it is assigned depends on which engine built the task:

  • Launch queues rank by value per effort, computed once at queue build: how many real humans the venue can send, multiplied by how well your product fits it, with a small lift for one-field work over multi-field forms. Rank 0 is the best action on the plan, and best-first is exactly what you want.
  • Warm-ups rank by the intended order within the day, not by judgment. Setup moves come first, then joins, rules reads, browsing and thread visits, then comments, with the question post last.
  • Scouts rank by append order. Each scan numbers its finds from one past the scout’s current maximum, so ascending rank returns the oldest conversations first. For a scout, use since or sort on created_at when you want today’s finds.

A fleet-wide pull with no warmup_id or scout_id filter interleaves several independent sequences, so group by account before you sort.

06 · Targets and draftsWhat each mode carries

target and generated_asset are the two fields whose shape changes with mode. Both are additive: new keys appear over time, and optional ones are absent rather than null.

thread_comment

target is kind: "thread" plus thread_url (the destination, and there is no target.url here), thread_title, discovery, and why_thread. Reddit threads carry subreddit; forum threads carry forum and site instead. Optional keys you may see: stats (upvotes, comments and age as of the scan, absent on threads found through search), thread_context (the grounding the draft was written from), tier: "adjacent" marking the broader, softer-touch second tier, and mention_policy or links_policy on forums that restrict either.

discovery says how the thread was found: bd_scan, bd_hot and bd_new are Reddit sweeps, brand_mention means someone already named your product there, google_ranked came from search, and forum_api or forum_live from a forum read.

generated_asset is kind: "reddit_comment" (Reddit) or "value_first_comment" (forums), with the text in comment. Both kinds behave identically; read comment and ignore the kind.

subreddit_post

target is kind: "subreddit" with subreddit and subreddit_url. generated_asset is kind: "reddit_post" with post_title, post_body, and post_kind (text today).

warmup_action

target is kind: "warmup_action" with action, a url to open, subreddit when the action concerns one, and related_task_id. The actions are verify_email, set_avatar, set_bio, join_subreddit, read_rules, browse_subreddit and visit_thread; a visit_thread target also carries thread_url, thread_title and reply_eligible.

generated_asset is null for most of them. set_bio is the exception: kind: "reddit_bio" with text and up to two alternatives.

Venue cards (mode: null)

target is just the venue’s name and url. generated_asset names its shape in kind (directory_listing, alternative_listing, launch_post, show_hn, build_story) and then holds either of two shapes:

  • a fields object, mapping that venue’s form field names to text (tagline, description, category, pricing_model, topics, first_comment, and others depending on the venue), or
  • a title and body pair, for post-shaped venues.

Read the keys that are present rather than assuming a fixed set: the fields follow whatever the venue’s submission form actually asks for.


FAQ

Common questions

Those tasks are in the lazy tail: draft_state is lazy, meaning “not written yet, ask when you need it”. POST to the draft endpoint for the one you are about to work and the text arrives in a couple of seconds. Once drafted it is stored, so later pulls return it as ready.