Skip to content

Use approval gates

awaitApprovalGate pauses a task until a host sends a decision event. Use it when a workflow needs a human or external system to approve, reject, or cancel before the task can continue.

Add a gate inside a task

Define both sides of the contract with Zod: the request payload your host displays, and the decision payload your task handles after the wait resumes.

typescript
import {
  approvalDecisionEventName,
  awaitApprovalGate,
  task,
} from '@snevins/orchard-core'
import { z } from 'zod'

const requestSchema = z.object({
  summary: z.string(),
  diffUrl: z.string().url(),
})

const decisionSchema = z.discriminatedUnion('status', [
  z.object({ status: z.literal('approved') }),
  z.object({ status: z.literal('rejected'), reason: z.string() }),
  z.object({ status: z.literal('cancelled') }),
])

export const deployTask = task({
  app,
  name: 'deploy.with-approval',
  input: z.object({ summary: z.string(), diffUrl: z.string().url() }),
  output: z.object({ deployed: z.boolean(), reason: z.string().optional() }),
  async run(input, ctx) {
    const gate = await awaitApprovalGate(ctx, {
      id: 'deploy',
      requestSchema,
      request: input,
      decisionSchema,
      timeout: 60 * 60,
    })

    if (gate.decision.status === 'approved') return { deployed: true }
    if (gate.decision.status === 'rejected') {
      return { deployed: false, reason: gate.decision.reason }
    }
    return { deployed: false, reason: 'cancelled' }
  },
})

The id must be stable for the logical gate and may contain only letters, numbers, underscore, dot, and hyphen. It becomes part of the checkpoint and event names memoized for that run.

Show the request to the host

When the task reaches the gate, Orchard records a checkpoint (in memory, for the life of the run) named:

text
approval:<id>:request

The checkpoint value includes:

typescript
{
  id: string
  checkpointName: string
  decisionEventName: string
  request: unknown
}

Your host should display request to the approver and retain decisionEventName. In a live orchard run, the CLI's own approval host is the only thing that reads this checkpoint — there is no external command to inspect it. In tests, read it directly with LocalRuntime.readStepResult(taskID, checkpointName).

Send the decision

Deliver a JSON event whose name is exactly the checkpoint's decisionEventName:

text
orchard.approval:<taskID>:<id>:decision

The event payload must match decisionSchema. Orchard validates the payload after ctx.awaitEvent resumes and returns it as gate.decision; Orchard does not treat approved, rejected, or cancelled as framework-level semantics.

For local end-to-end tests, run the task against a createLocalRuntime() and deliver the deterministic decision event with approvalDecisionEventName(taskID, id) directly through the runtime's event bus:

typescript
import { createLocalRuntime } from '@snevins/orchard-core'
import { approvalDecisionEventName } from '@snevins/orchard-core'

const runtime = createLocalRuntime()
const run = await runtime.app.spawn(deployTask.name, {
  summary: 'Ship release 42',
  diffUrl: 'https://example.com/diff',
})

await runtime.emitEvent(approvalDecisionEventName(run.taskID, 'deploy'), {
  status: 'approved',
})

Approve interactively from orchard run

When orchard run executes an artifact on a terminal (stdin and stderr are TTYs), it hosts pending approval gates itself: it polls the in-process runtime for pending orchard.approval:* waits, reads the memoized request checkpoint, renders each request, and prompts:

text
── approval requested ── gate review.attempt-1 · task 019f...
{
  "summary": "Ship release 42"
}
[a]pprove / [r]eject with feedback / [s]kip:
  • a emits { "status": "approved" }.
  • r collects feedback lines (finish with an empty line) and emits { "status": "rejected", "feedback": "..." }.
  • s leaves the gate pending and is not re-asked. There is no external channel to deliver a decision afterward — the run keeps waiting until you stop the process.

The prompt emits the decision through the same in-process event bus a test would use via runtime.emitEvent. Without a terminal (piped/CI), orchard run cannot prompt for a decision at all: there is no external command that can reach into that process's in-memory event bus, so an approval-gated workflow run non-interactively will stay parked until you stop it. Keep approval-gated workflows for interactive orchard run invocations.

Interactive prompting only produces payloads matching feedbackApprovalDecisionSchema (approved, or rejected with mandatory feedback). A gate with a custom decisionSchema will fail to parse a prompted rejection, and there is no external command to deliver an arbitrary payload into a running orchard run anymore — resolve custom-schema gates from code that owns the runtime directly (as in the test example above), not through orchard run's CLI prompt.

Loop on rejection feedback

awaitApprovalWithFeedback re-runs a producing step with the accumulated feedback each time the approver rejects, and returns on the first approval:

typescript
const outcome = await awaitApprovalWithFeedback(ctx, {
  id: 'release-notes',
  requestSchema: z.object({ text: z.string() }),
  produce: async ({ feedback, childStepName }) => {
    return harnessCall(
      ctx,
      childStepName('draft'),
      harness,
      [basePrompt, ...feedback.map((f) => `Reviewer feedback: ${f}`)].join(
        '\n',
      ),
      z.object({ text: z.string() }),
    )
  },
})

Each attempt is its own gate (release-notes.attempt-1, release-notes.attempt-2, ...). Rejections never exhaust the loop: every iteration waits for an explicit decision event, so the approver stays in control of how many rounds happen.

Ctrl-C stops the run

orchard run has no detach/resume: Ctrl-C stops the process. Because nothing is persisted, an in-flight run — including any workflow parked on an approval gate — is gone once the process exits. There is no orchard resume command; start a new orchard run if you need to try again.

Keep the gate deterministic

  • Put only serializable request data in the checkpoint.
  • Branch on the validated decision payload inside the task handler.
  • Use timeout when the workflow should fail instead of waiting indefinitely.
  • Reuse the same id for retries of the same logical approval; use a different id for a separate approval point.

Licensed under MIT