Skip to content
nikcli
Docs
Documentation

Orchestrating missions

A mission is a dependency graph of features grouped into milestones — parallel where it can be, gated where it must be.

What a mission is

A mission describes a body of work as milestones, each containing features. A feature names an objective and the agent that will do it; a feature can declare that it depends on others, which is what turns a list into a graph.

The difference from a loop is the shape, not the size. A loop is a pipeline that repeats on a cadence. A mission is a graph that runs once, to completion, with gates between milestones.

The shape

ts
await client.mission.upsert({
  name: "search-v2",
  brief: "Replace the current search with a ranked index, without changing the public API.",
  milestones: [
    {
      id: "m1",
      name: "Indexing",
      validation: "scrutiny",
      features: [
        {
          id: "schema",
          name: "Index schema",
          objective: "Design the index schema and write the migration. No query changes yet.",
          agent: "plan",
        },
        {
          id: "writer",
          name: "Index writer",
          objective: "Write documents into the index on every mutation.",
          agent: "build",
          dependsOn: ["schema"],
        },
      ],
    },
  ],
})

brief is the whole mission's context: every feature is executed with it in scope, so constraints that apply throughout — "without changing the public API" — belong there rather than repeated in each objective.

Features and dependencies

FieldRequiredWhat it does
idyesWhat other features name in dependsOn.
nameyesHuman label in the UI and in exec history.
objectiveyesThe prompt for this unit of work.
agentyesWhich agent runs it, and so which tools and permissions apply.
modelnoOverrides the model for this feature.
tokenBudgetnoCaps this feature's spend.
dependsOnnoFeature ids that must finish first.
statusnopending, running, done, blocked, skipped, error.

Features with no unmet dependency are free to run together. Declaring a dependency you do not actually have is the most common way to make a mission needlessly serial.

Depend on outputs, not on order

dependsOn should mean "this cannot be written until that exists", not "I would rather this happened first". Two features touching different files usually have no dependency at all, even when a human would do them in order.

Validation gates

A milestone can declare how it is checked before the next one starts.

ValueMeaning
scrutinyThe work is reviewed before the milestone is accepted.
user-testA human tests it; the mission waits.
noneRoll straight into the next milestone.
user-test blocks until someone answers

A mission gated on user-test and left unattended is stopped, not failed. In unattended runs use scrutiny, and keep user-test for the milestones where a person really is going to look.

Running one

ts
await client.mission.start({ id: mission.id })
await client.mission.pause({ id: mission.id })
await client.mission.cancel({ id: mission.id })

const { data: list } = await client.mission.list()
const { data: one } = await client.mission.get({ id: mission.id })

As with loops, generate drafts a mission from prose and templates returns the built-in starting points.

ts
const { data: draft } = await client.mission.generate({
  description: "Add OAuth login with Google and GitHub, behind a feature flag.",
})

Watching progress

ts
const { data: execs } = await client.mission.execs({ id: mission.id })
const { data: recent } = await client.mission.recentExecs({ limit: 20 })

Feature status is the thing to render: blocked means a dependency has not finished, skipped means the mission moved past it, and error carries the reason in error.

Intervening mid-run

A mission is editable while it runs. mission.feature.mutate changes one feature's status, records an error, or adds a dependency that was not obvious when the graph was written.

ts
// this turned out to need the schema after all
await client.mission.feature.mutate({
  id: mission.id,
  featureID: "writer",
  appendDependsOn: ["schema"],
})

// we handled this one by hand
await client.mission.feature.mutate({
  id: mission.id,
  featureID: "legacy-shim",
  status: "skipped",
})

// unblock a feature that failed for an external reason
await client.mission.feature.mutate({
  id: mission.id,
  featureID: "writer",
  status: "pending",
  error: undefined,
})
This is the escape hatch that makes missions usable

Graphs written up front are wrong in small ways. Being able to add a dependency or skip a feature without tearing the mission down is what keeps a long-running one alive.

A complete mission

search-v2.ts
ts
import { createNikcliClient } from "@nikcli-ai/sdk/httpapi"

const client = createNikcliClient({
  baseUrl: "http://127.0.0.1:4096",
  directory: process.cwd(),
})

const { data: mission, error } = await client.mission.upsert({
  name: "search-v2",
  brief:
    "Replace the current substring search with a ranked index. " +
    "The public search API must not change shape; existing callers keep working.",
  milestones: [
    {
      id: "m1",
      name: "Indexing",
      validation: "scrutiny",
      features: [
        {
          id: "schema",
          name: "Index schema",
          objective: "Design the index schema and write the migration. Do not touch query code.",
          agent: "plan",
          tokenBudget: 40_000,
        },
        {
          id: "writer",
          name: "Index writer",
          objective: "Write documents into the index on every mutation, with tests.",
          agent: "build",
          dependsOn: ["schema"],
          tokenBudget: 120_000,
        },
        {
          id: "backfill",
          name: "Backfill",
          objective: "Backfill the index from existing rows. Idempotent and resumable.",
          agent: "build",
          dependsOn: ["schema"],
          tokenBudget: 80_000,
        },
      ],
    },
    {
      id: "m2",
      name: "Querying",
      validation: "user-test",
      features: [
        {
          id: "ranker",
          name: "Ranking",
          objective: "Rank results by relevance. Keep the response shape identical.",
          agent: "build",
          dependsOn: ["writer", "backfill"],
          tokenBudget: 120_000,
        },
      ],
    },
  ],
})
if (!mission) throw error

await client.mission.start({ id: mission.id })

writer and backfill both depend only on schema, so they are free to run together once it lands; ranker waits for both. That is the whole point of writing it as a graph rather than a list.

Failure modes

SymptomCause
Everything runs one at a timeA dependsOn chain that encodes preference rather than necessity.
Feature stuck on blockedA dependency errored or was never started; check its status and error.
Mission stops and nothing happensA user-test gate waiting on a human.
dependsOn appears to be ignoredIt names a feature name instead of its id.
Features contradict each otherShared constraints live in the objectives instead of the brief.
One feature consumes the whole budgetNo tokenBudget on a broad objective.