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.
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:
approval:<id>:requestThe checkpoint value includes:
{
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:
orchard.approval:<taskID>:<id>:decisionThe 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:
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:
── approval requested ── gate review.attempt-1 · task 019f...
{
"summary": "Ship release 42"
}
[a]pprove / [r]eject with feedback / [s]kip:aemits{ "status": "approved" }.rcollects feedback lines (finish with an empty line) and emits{ "status": "rejected", "feedback": "..." }.sleaves 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:
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
timeoutwhen the workflow should fail instead of waiting indefinitely. - Reuse the same
idfor retries of the same logical approval; use a differentidfor a separate approval point.
Related
- Write tests - local event and approval testing
- Inspect a local run - read run status while a workflow waits