Skip to content

Run a harness

Choose your path

Pick the entry point that matches what you are building:

  1. Inside a registered task handler, call harnessCall. This is the primary copy-first path for one schema-validated agent turn inside Orchard task work.
  2. Starting a multi-task workflow, call workflow.start(). Register tasks with task, compose them with workflow, then start the returned WorkflowRef.
  3. Advanced detached starts — plain startWorkflow, startWorkflowRun, or the runtime app.spawn surface without a WorkflowRef — see Advanced workflow starts and the package reference.

Import beginner APIs from the @snevins/orchard-core root: harnessCall, cursor, codex, claude, devin, task, and workflow.

Terms

  • Harness — CLI adapter that runs cursor-agent (cursor()), the Codex SDK (codex()), Claude Code (claude()), or Devin CLI (devin()).
  • harnessCall — one agent call saved as a schema-validated memoized step via ctx.step. Use this instead of calling the harness directly.
  • TaskRef — a registered task (from task) you can compose into workflows or spawn.
  • WorkflowRef — typed handle returned by workflow; call .start() to run the workflow.
  • Memoized step — Orchard records step output in memory and reuses it across retries within the same run (harnessCall checkpoints through ctx.step). It does not survive the process exiting.

Choose a harness

@snevins/orchard-core ships four harness factories. All implement the same Harness interface; task code should always use harnessCall, not low-level runUnstructuredTurn.

HarnessRequirementStructured-output behaviorWhen to use
cursor()cursor-agent available on the worker PATHprompt + repair fallback (no native structured turn)Cursor-backed review or read-only tasks
codex()optional @openai/codex-sdk configurednative structured turn via runStructuredTurn when availableCodex-backed review or edit tasks
claude()claude available on the worker PATHnative structured turn via Claude Code JSON schema outputClaude Code-backed review or edit tasks
devin()devin available on the worker PATHprompt + repair fallback (no native structured turn)Devin-backed review or edit tasks

Access contract

Orchard exposes one harness-neutral access intent: access: 'read' | 'edit'. Each harness maps that intent to the closest stable native mechanism it supports; Orchard does not promise identical sandbox guarantees across different CLIs.

Orchard accessCodex mappingClaude mappingCursor mapping
'read'read-only sandbox--permission-mode dontAskno stable headless sandbox flag; uses supported flags
'edit'workspace-write--permission-mode acceptEditsno stable headless sandbox flag; uses supported flags

Built-in harness options intentionally do not expose dangerous bypass modes such as Claude Code bypassPermissions. If a workflow needs different trust boundaries, split the work by harness or run it outside Orchard's built-in harness factories.

cursor() accepts CLI readiness options such as binary, model, apiKey, and defaultTimeoutMs — see the CursorCliOpts reference for every option, its default, and when to set it. codex() accepts optional defaults such as model and skipGitRepoCheck; see the CodexOpts reference. claude() accepts CLI readiness options such as binary, model, apiKey, and defaultTimeoutMs; see the ClaudeOpts reference. devin() accepts CLI readiness options such as binary, model, defaultTimeoutMs, and sandbox; see the DevinOpts reference.

Request structured output inside a task

Register a task with task, then call harnessCall from the handler. The ctx argument (including ctx.step) comes from Orchard's in-process runtime when it runs your registered handler:

ts
import { z } from 'zod'
import type { Absurd, TaskContext } from '@snevins/orchard-core'
import { cursor, task, harnessCall, type Harness } from '@snevins/orchard-core'

const reviewInputSchema = z.object({
  cwd: z.string(),
  prompt: z.string(),
})
const reviewOutputSchema = z.object({
  summary: z.string(),
  approved: z.boolean(),
})

function modelFromHeaders(ctx: TaskContext): string | undefined {
  const model = ctx.headers['orchard.model']
  return typeof model === 'string' && model.length > 0 ? model : undefined
}

export function registerReviewWorker(
  app: Pick<Absurd, 'registerTask' | 'spawn'>,
  harness: Harness = cursor(),
) {
  return task({
    app,
    name: 'docs.review-worker',
    input: reviewInputSchema,
    output: reviewOutputSchema,
    queue: 'harness',
    async run(input, ctx) {
      const opts = {
        cwd: input.cwd,
        access: 'read' as const,
        timeoutMs: 1_800_000,
      }
      const model = modelFromHeaders(ctx)
      const output = await harnessCall(
        ctx,
        'review',
        harness,
        `Review ${input.cwd}: ${input.prompt}`,
        reviewOutputSchema,
        model === undefined ? opts : { ...opts, model },
      )
      return output
    },
  })
}

The task's returned output is typed from the schema. If you need the harness session for troubleshooting, use harnessCallWithTurn and read turn.sessionId.

For a full local wiring example, see @snevins/orchard-examples and the First run tutorial.

Use run.model with harnessCall

Workflow run config sets the selected model on spawned child tasks as the orchard.model header. When opts.model is omitted, harnessCall reads that header and passes it to the harness automatically:

ts
await harnessCall(ctx, 'review', harness, prompt, schema, {
  cwd,
})

Pass opts.model only when a specific harness call should override the task's run.model. readOrchardModel(ctx) remains available for handlers that need to inspect the raw header directly.

Compose task-based workflows

Workflows compose existing TaskRefs rather than defining another prompt-step language, graph DSL, or scheduler. Each workflow task invocation has a stable id, an existing task, an input mapper, and optional grouped run config such as model or timeout:

ts
import { z } from 'zod'
import type { Absurd } from '@snevins/orchard-core'
import {
  Steps,
  workflow as createWorkflow,
  type StartWorkflowRunOptions,
  type TaskRef,
} from '@snevins/orchard-core'

const workflowInputSchema = z.object({
  cwd: z.string(),
  prompt: z.string(),
  focus: z.string().optional(),
})
const workflowOutputSchema = z.object({ summary: z.string() })
const reviewInputSchema = workflowInputSchema.extend({ focus: z.string() })
const reviewOutputSchema = z.object({
  summary: z.string(),
  approved: z.boolean(),
})

type ReviewTask = TaskRef<typeof reviewInputSchema, typeof reviewOutputSchema>

export function registerChangeWorkflow(
  app: Pick<Absurd, 'registerTask' | 'spawn'>,
  reviewTask: ReviewTask,
) {
  const workflow = createWorkflow({
    app,
    name: 'docs.change-flow',
    input: workflowInputSchema,
    output: workflowOutputSchema,
    queues: { workflow: 'workflow', tasks: 'harness' },
    defaults: { model: 'workflow-default-model' },
    steps: Steps({
      review: {
        task: reviewTask,
        input: (ctx) => ({
          cwd: ctx.input.cwd,
          prompt: ctx.input.prompt,
          focus: ctx.input.focus ?? 'correctness',
        }),
        run: { model: 'review-model' },
      },
    }),
    run: (ctx) => ({
      summary: ctx.step.review().summary,
    }),
  })

  return {
    workflow,
    start(
      params: z.infer<typeof workflowInputSchema>,
      options?: StartWorkflowRunOptions<'review'>,
    ) {
      return workflow.start(params, options)
    },
  }
}

In this example, verification-model wins for the review task because per-run task overrides beat invocation run, run defaults, and workflow-definition defaults.

PrioritySourceExample in snippet
1per-run task overrideworkflow.steps.review.runverification-model
2invocation run on task definitionstep runreview-model
3per-run workflow defaultworkflow.runrun-default-model
4workflow definition defaultdefaultsworkflow-default-model

Task invocation IDs are preserved in the WorkflowRef type, so typed callers get compile-time checking for per-task run overrides. They must also be stable and unique because they become the await step names (workflow:{id}) memoized for that run.

Add a review rework loop

For automatic review repair, keep the loop control in deterministic TypeScript:

  1. implement from the current spec,
  2. review the implementation,
  3. finish with { kind: 'done', result: ... } when the structured review is approved,
  4. finish with a terminal blocked result when the workflow can report a graceful blocker,
  5. when review needs rework, run a spec-fix task from the review finding,
  6. continue with { kind: 'continue', state: ... } and carry the updated spec plus previous implementation context forward.

A failed review is expected workflow feedback, not infrastructure failure. Model reimplementation append-forward as another loop iteration; do not rollback, reset, or mutate prior step results. { kind: 'done', result: ... } is the terminal path, and the result schema decides whether that terminal outcome is approved, gracefully blocked, or something else. Throw only for hard blockers or infrastructure/error paths where the run itself should fail instead of returning a workflow result.

Use loopCtx.childStepName(...) for child tasks inside loopUntil. Each iteration needs a distinct child step identity such as loop:review-rework:2:child:implement. Reusing the same stepName or idempotency key intentionally resumes the same in-memory child task result; it is not a new reimplementation attempt.

For generated artifacts, child tasks composed through workflow must run on a queue distinct from the parent workflow queue as part of the generated-artifact contract, even though the in-process runtime has no worker pool and cannot deadlock on a queue. Orchard passes the per-directory queue config to registerWorkflow({ queues }); generated artifacts should pass queues to workflow({ queues }) so the root workflow uses queues.workflow, and declare queue: queues.tasks on each child task so it registers on the tasks queue the workflow spawns it onto. When a queues.tasks task waits on more children, put those awaited children on `${queues.tasks}-workers` and pass that queue in the spawn helper's run.queue. Generated artifacts may use only queues.workflow, queues.tasks, and `${queues.tasks}-workers`. orchard init derives these queue names for the directory; there is nothing to create or provision, and orchard run records them as metadata only.

Manual spawnAndAwaitTask requires a caller-owned stable child identity. workflow sets run.stepName to workflow:<invocation.id> for each composed task automatically; manual spawnAndAwaitTask calls do not. The run options require stepName or idempotencyKey at the type level (SpawnRunOptions), so omitting both is a compile error; untyped callers hit the same rule at runtime before spawning:

text
spawnAndAwaitTask for review requires run.stepName or run.idempotencyKey for durable idempotent spawning

Inside loopUntil, pass iteration-scoped identity with loopCtx.childStepName(...):

ts
await spawnAndAwaitTask(
  app,
  ctx,
  tasks.review,
  {
    spec: state.spec,
    patchSummary: implementation.patchSummary,
  },
  { run: { stepName: loopCtx.childStepName('review') } },
)
// e.g. parent-task:review:loop:review-rework:2:child:review

Keep retry semantics separate from review feedback. run.retry is for transient execution failures while spawning/running a child task, not for a reviewer saying the implementation needs changes. Likewise, failureMode: 'snapshot' lets a parent inspect failed or cancelled child-task snapshots as data; it is not rollback or snapshot restore.

Use maxIterations to fail loudly instead of looping forever.

ts
import { z } from 'zod'
import type { Absurd, TaskContext } from '@snevins/orchard-core'
import {
  loopUntil,
  spawnAndAwaitTask,
  type TaskRef,
} from '@snevins/orchard-core'

export const specSchema = z.object({
  revision: z.number(),
  requirements: z.array(z.string()),
})

export const implementInputSchema = z.object({
  spec: specSchema,
  previousPatch: z.string().optional(),
  reviewHistory: z.array(z.string()),
})
export const implementationOutputSchema = z.object({ patchSummary: z.string() })

export const reviewInputSchema = z.object({
  spec: specSchema,
  patchSummary: z.string(),
})
export const failedReviewSchema = z.object({
  verdict: z.literal('failed'),
  finding: z.string(),
  requiredSpecChange: z.string(),
})
export const reviewOutputSchema = z.discriminatedUnion('verdict', [
  z.object({ verdict: z.literal('approved'), summary: z.string() }),
  failedReviewSchema,
  z.object({ verdict: z.literal('blocked'), reason: z.string() }),
])

export const fixSpecInputSchema = z.object({
  spec: specSchema,
  patchSummary: z.string(),
  failedReview: failedReviewSchema,
})
export const fixSpecOutputSchema = z.object({ spec: specSchema })

const reviewLoopStateSchema = z.object({
  spec: specSchema,
  previousPatch: z.string().optional(),
  reviewHistory: z.array(z.string()),
})
const reviewLoopResultSchema = z.discriminatedUnion('status', [
  z.object({
    status: z.literal('approved'),
    spec: specSchema,
    patchSummary: z.string(),
    reviewSummary: z.string(),
  }),
  z.object({ status: z.literal('blocked'), reason: z.string() }),
])

type ImplementTask = TaskRef<
  typeof implementInputSchema,
  typeof implementationOutputSchema
>
type ReviewTask = TaskRef<typeof reviewInputSchema, typeof reviewOutputSchema>
type FixSpecTask = TaskRef<
  typeof fixSpecInputSchema,
  typeof fixSpecOutputSchema
>

type DurableSpawnContext = Pick<
  TaskContext,
  'taskID' | 'step' | 'awaitTaskResult'
>

export async function runReviewReworkLoop(
  app: Pick<Absurd, 'spawn'>,
  ctx: DurableSpawnContext,
  tasks: {
    implement: ImplementTask
    review: ReviewTask
    fixSpec: FixSpecTask
  },
  initialSpec: z.infer<typeof specSchema>,
) {
  return loopUntil(ctx, {
    id: 'review-rework',
    stateSchema: reviewLoopStateSchema,
    resultSchema: reviewLoopResultSchema,
    initialState: { spec: initialSpec, reviewHistory: [] },
    maxIterations: 5,
    iterate: async (state, loopCtx) => {
      // Manual spawnAndAwaitTask requires run.stepName (or run.idempotencyKey);
      // workflow sets workflow:<id> automatically, but loopUntil callers
      // must supply iteration-scoped identity via loopCtx.childStepName(...).
      const implementation = await spawnAndAwaitTask(
        app,
        ctx,
        tasks.implement,
        {
          spec: state.spec,
          previousPatch: state.previousPatch,
          reviewHistory: state.reviewHistory,
        },
        { run: { stepName: loopCtx.childStepName('implement') } },
      )

      const review = await spawnAndAwaitTask(
        app,
        ctx,
        tasks.review,
        {
          spec: state.spec,
          patchSummary: implementation.patchSummary,
        },
        { run: { stepName: loopCtx.childStepName('review') } },
      )

      if (review.verdict === 'approved') {
        return {
          kind: 'done',
          result: {
            status: 'approved' as const,
            spec: state.spec,
            patchSummary: implementation.patchSummary,
            reviewSummary: review.summary,
          },
        }
      }

      if (review.verdict === 'blocked') {
        // Throw instead when a blocked review should fail the run rather than
        // returning a graceful terminal result:
        // throw new Error(`review blocked: ${review.reason}`)
        return {
          kind: 'done',
          result: { status: 'blocked' as const, reason: review.reason },
        }
      }

      const fixedSpec = await spawnAndAwaitTask(
        app,
        ctx,
        tasks.fixSpec,
        {
          spec: state.spec,
          patchSummary: implementation.patchSummary,
          failedReview: review,
        },
        { run: { stepName: loopCtx.childStepName('fix-spec') } },
      )

      return {
        kind: 'continue',
        state: {
          spec: fixedSpec.spec,
          previousPatch: implementation.patchSummary,
          reviewHistory: [...state.reviewHistory, review.finding],
        },
      }
    },
  })
}

Run bounded read-only parallel child tasks

When independent review or analysis subtasks are safe to overlap, call runReadOnlyParallelTasks from inside a task handler instead of changing workflow ordering. Each child declaration must include readOnly: true. Orchard propagates orchard.access: 'read' on spawned child headers, and harnessCall uses that header when opts.access is omitted.

maxConcurrency is required and must be a positive integer. Pending children start in input order. Orchard serializes the spawn checkpoints, then bounds concurrently active awaitTaskResult calls to maxConcurrency. The in-process runtime has no worker pool, so maxConcurrency is the only concurrency control for these children — it is not compensating for a queue's worker-pool size.

The batch id must be stable for a single parent task run. Inside loopUntil, include loopCtx.iteration in the batch id, for example `research-round-${loopCtx.iteration}`. A plain literal inside a loop would reuse the first round's memoized parallel-task results on later iterations.

ts
import { z } from 'zod'
import type { Absurd, TaskContext } from '@snevins/orchard-core'
import { runReadOnlyParallelTasks, type TaskRef } from '@snevins/orchard-core'

const reviewInputSchema = z.object({
  cwd: z.string(),
  prompt: z.string(),
  focusAreas: z
    .object({
      correctness: z.string(),
      userExperience: z.string(),
    })
    .optional(),
})
const reviewWorkerInputSchema = reviewInputSchema.extend({ focus: z.string() })
const reviewOutputSchema = z.object({
  summary: z.string(),
  approved: z.boolean(),
})

type ReviewWorker = TaskRef<
  typeof reviewWorkerInputSchema,
  typeof reviewOutputSchema
>

const defaultReviewFocusAreas = {
  correctness: 'correctness',
  userExperience: 'user experience',
}

export async function runParallelReviews(
  app: Pick<Absurd, 'spawn'>,
  ctx: Pick<TaskContext, 'taskID' | 'step' | 'awaitTaskResult'>,
  reviewWorker: ReviewWorker,
  params: z.infer<typeof reviewInputSchema>,
) {
  const focusAreas = params.focusAreas ?? defaultReviewFocusAreas
  return runReadOnlyParallelTasks(app, ctx, {
    id: 'reviews',
    maxConcurrency: 2,
    run: { model: 'review-model', timeout: 60 },
    tasks: [
      {
        id: 'correctness',
        task: reviewWorker,
        input: {
          cwd: params.cwd,
          prompt: params.prompt,
          focus: focusAreas.correctness,
        },
        readOnly: true,
      },
      {
        id: 'user-experience',
        task: reviewWorker,
        input: {
          cwd: params.cwd,
          prompt: params.prompt,
          focus: focusAreas.userExperience,
        },
        readOnly: true,
      },
    ],
  })
}

Child step-memo names are parallel:<batchId>:<taskId>. Idempotency keys derive from the parent task id, child task name, and that step name like manual spawnAndAwaitTask. Do not pass run.stepName or run.idempotencyKey on the batch or per-task run options; the helper owns those identities. Default failure behavior throws like spawnAndAwaitTask. Set run.failureMode: 'snapshot' on the batch run (not per-task run) to return failed or cancelled child snapshots as data keyed by task id while unrelated children continue.

Advanced workflow starts

When you need plain detached starts instead of a registered WorkflowRef:

  • startWorkflow(app, registration, params, options?) — spawn a registered workflow task by name. Export path: @snevins/orchard-core/primitives.
  • startWorkflowRun(app, registration, params, options?) — start a composed workflow run with grouped run config. Export path: @snevins/orchard-core/workflow.
  • runtime app.spawn — lowest-level spawn used internally by Orchard starts. In types this surface is named Absurd for compatibility, but it is exported by @snevins/orchard-core, not absurd-sdk.

All paths log orchard workflow: {taskID} running and return Orchard's SpawnResult (taskID, runID, attempt, created) for runtime troubleshooting. See Architecture for layering context and Packages for export paths.

Licensed under MIT