Skip to main content
Pauses the workflow until a human approves or denies. mode="approve" writes an ApprovalDecision to the configured output:
type ApprovalDecision = {
  approved: boolean;
  note: string | null;
  decidedBy: string | null;
  decidedAt: string | null;
};
decidedAt is reserved for compatibility, but Smithers keeps the actual approval timestamp in internal approval records and the event log instead of the durable task output.

Import

import { Approval, approvalDecisionSchema } from "smithers-orchestrator";

Props

PropTypeDefaultDescription
idstring(required)Unique node id within the workflow.
mode"approve" | "select" | "rank""approve"Approval shape. "approve" returns a boolean decision, "select" returns one option, "rank" returns an ordered list.
optionsApprovalOption[]undefinedRequired for mode="select" and mode="rank".
outputz.ZodObject | Table | string(required)Where to persist the decision. Zod schema from outputs (recommended), Drizzle table, or string key.
outputSchemaz.ZodObjectapprovalDecisionSchemaOverride the decision schema (manual DB API).
request{ title: string; summary?: string; metadata?: Record<string, unknown> }(required)Human-facing request. title becomes the node label.
onDeny"fail" | "continue" | "skip""fail"Behavior after denial. "continue" and "skip" still persist the denial.
allowedScopesstring[]undefinedOptional gateway scopes allowed to decide this approval.
allowedUsersstring[]undefinedOptional gateway user IDs allowed to decide this approval.
autoApproveApprovalAutoApproveundefinedAuto-approval policy. Supports immediate auto-approval, approval-after-history, and audited auto-approvals.
asyncbooleanfalseWhen true, unrelated downstream flow can continue while this approval is pending. Explicit dependencies still wait for the resolved decision.
dependsOnstring[]undefinedTask IDs that must complete first.
needsRecord<string, string>undefinedNamed deps. Keys become context keys, values are task IDs.
skipIfbooleanfalseSkip this node entirely.
timeoutMsnumberundefinedMax wait in ms. Node fails on timeout.
retriesnumber0Retry attempts before failure.
retryPolicyRetryPolicyundefined{ backoff?: "fixed" | "linear" | "exponential", initialDelayMs?: number }
continueOnFailbooleanfalseWorkflow continues even if this node fails.
cacheCachePolicyundefined{ by?: (ctx) => unknown, version?: string }. Skip re-execution on cache hit.
labelstringrequest.titleDisplay label override.
metaRecord<string, unknown>undefinedExtra metadata merged with request fields.

Schema-driven Example

import {
  Approval,
  Sequence,
  Task,
  Workflow,
  approvalDecisionSchema,
  createSmithers,
} from "smithers-orchestrator";
import { z } from "zod";

const { smithers, outputs } = createSmithers({
  publishApproval: approvalDecisionSchema,
  publishResult: z.object({
    status: z.enum(["published", "rejected"]),
  }),
});

export default smithers((ctx) => {
  const decision = ctx.outputMaybe(outputs.publishApproval, {
    nodeId: "approve-publish",
  });

  return (
    <Workflow name="publish-flow">
      <Sequence>
        <Approval
          id="approve-publish"
          output={outputs.publishApproval}
          request={{
            title: "Publish the draft?",
            summary: "Human review is required before production publish.",
            metadata: { channel: "blog" },
          }}
          onDeny="continue"
        />

        {decision ? (
          <Task id="record-decision" output={outputs.publishResult}>
            {{
              status: decision.approved ? "published" : "rejected",
            }}
          </Task>
        ) : null}
      </Sequence>
    </Workflow>
  );
});

Manual API Example

Pass outputSchema={approvalDecisionSchema} when output is a Drizzle table.
<Approval
  id="approve-deploy"
  output={deployApprovalTable}
  outputSchema={approvalDecisionSchema}
  request={{
    title: "Deploy to production?",
    summary: "Build 2026.03.15 passed all checks.",
  }}
/>

Selection and ranking modes

<Approval> can also return typed non-boolean outputs.
import {
  Approval,
  approvalRankingSchema,
  approvalSelectionSchema,
} from "smithers-orchestrator";

<Approval
  id="pick-plan"
  mode="select"
  output={outputs.selection}
  request={{ title: "Pick a rollout plan" }}
  options={[
    { key: "canary", label: "Canary" },
    { key: "regional", label: "Regional" },
  ]}
/>

<Approval
  id="rank-plans"
  mode="rank"
  output={outputs.ranking}
  request={{ title: "Rank the rollout plans" }}
  options={[
    { key: "canary", label: "Canary" },
    { key: "regional", label: "Regional" },
    { key: "global", label: "Global" },
  ]}
/>
  • mode="select" returns { selected: string, notes: string | null }
  • mode="rank" returns { ranked: string[], notes: string | null }

Scoped approvals and auto-approval

<Approval
  id="deploy-prod"
  output={outputs.deployApproval}
  request={{ title: "Deploy to production?" }}
  allowedScopes={["approve"]}
  allowedUsers={["user:oncall", "user:release-manager"]}
  autoApprove={{ after: 2, audit: true }}
/>
  • allowedScopes and allowedUsers are enforced by Gateway.
  • autoApprove={{ after: N }} auto-approves after N consecutive manual approvals for the same workflow node.
  • audit: true preserves an approval record and emits ApprovalAutoApproved.
The full ApprovalAutoApprove type:
type ApprovalAutoApprove = {
  after?: number;
  condition?: (ctx: WorkflowContext) => boolean;
  audit?: boolean;
  revertOn?: (ctx: WorkflowContext) => boolean;
};
FieldDescription
afterAuto-approve after this many consecutive manual approvals for the same node.
conditionPredicate evaluated at render time. When it returns true, the node is auto-approved immediately without waiting for human input.
auditWhen true, an approval record is written and ApprovalAutoApproved is emitted even for auto-approvals. Defaults to true.
revertOnPredicate evaluated at render time. When it returns true, a previously triggered auto-approval is reverted and the node goes back to waiting for human input.
condition and revertOn are re-evaluated each render, so they can react to upstream task output or workflow state.

Behavior

  • Workflow enters waiting-approval when this node is reached.
  • With async, the run can keep traversing unrelated later nodes while this approval is pending.
  • smithers approve / smithers deny updates the record durably.
  • On resume, the node resolves to a decision object; downstream JSX branches on the value.
  • onDeny="fail" — hard gate.
  • onDeny="continue" — branch on decision.approved.
  • Use ctx.outputMaybe(...) when branching on an async approval’s output, since the decision may not exist yet during earlier renders.

Metrics

Async approvals contribute to the Prometheus gauge smithers_external_wait_async_pending{kind="approval"} while waiting for human input.

Durable deferred resolution

<Approval> uses a durable deferred mechanism to survive process restarts. When the node enters waiting-approval state, an @effect/workflow DurableDeferred is created and awaited by the executing task fiber. The deferred is keyed to the run, node, and iteration, so it survives process restarts: if the worker crashes while waiting, the next worker that picks up the task will re-await the same deferred and receive the resolution as soon as a human submits a decision. When smithers approve or smithers deny is called, bridgeApprovalResolve resolves the deferred, which unblocks the awaiting fiber and lets the compute function proceed to read the decision from the database. No polling is needed.

<Approval> vs needsApproval

UseWhen
<Approval>Decision must be persisted as data and consumed by downstream nodes.
needsApproval on <Task>Simple pause before a task; no separate decision value needed.