Workflows

Reviews

Add human-in-the-loop approval steps to your workflows.

A review pauses a workflow until someone approves, rejects, or submits a form. Use it before actions that require human approval, such as sending an email or issuing a refund.

When a run reaches a review-enabled node, the node's own work does not execute. A review appears in the project Inbox:

  • Approve resumes the run. The submitted form data becomes the node's output and is available downstream at context.reviews.<nodeName>.
  • Reject fails the run with the rejection reason.
  • No response before the timeout fails the run. The default is 7 days.

Put the work being reviewed in an upstream node and use a dedicated node for the review.

Enabling review on a node

Add review: true for simple approval, or use a full review config block:

node review_gate {
  type: code
  label: "Review before sending"
  code: @ts {
    return context.nodes.draft.output
  }
  review: {
    enabled: true
    title: "Review before sending"
    description: "Approve the draft or request changes."
    schema: @json {
      {
        "type": "object",
        "required": ["approved"],
        "properties": {
          "approved": { "type": "boolean", "title": "Approve" },
          "feedback": { "type": "string", "title": "Feedback" }
        },
        "additionalProperties": false
      }
    }
  }
}

The code: block here is a placeholder; it never runs. On approval the reviewer's form data ({ approved, feedback }) becomes the node's recorded output.

Review config fields

FieldTypeRequiredDescription
enabledbooleanNoDefaults to true when the review block is present.
titlestringNoTitle shown to the reviewer.
descriptionstringNoDescription shown to the reviewer.
schema@json { } blockNoJSON Schema for the review form. Validates the submitted form data and shapes context.reviews.<nodeName>.
contentstringNoAdditional content shown to the reviewer.
actionsarrayNoCustom buttons; each item has id, label, and outcome ("approve" | "reject").

Approve, reject, and timeout

  • Approve accepts optional form data. When the review declares a schema, the submitted data is validated against it; invalid data is rejected and the review stays pending. On approval the run resumes with the form data as the node's output.
  • Reject fails the run with the rejection reason. There is no reject-and-continue. To model "request changes", give the action an approve outcome, capture the decision in a schema field, and route downstream with a switch node.
  • Timeout. A pending review does not wait forever. If nobody responds within the review timeout (7 days by default), the run fails with Review timed out.

A review can be answered once. After approval or rejection, it cannot be reopened.

Reviewers respond in the Cloud UI project Inbox, or programmatically through the SDK (swirls.client.reviews): list pending reviews, fetch one with its execution context, approve with form data, or reject with a reason.

Custom actions

When you need labeled actions beyond the default approve/reject flow, define actions. Any reject outcome fails the run, so every path that should keep the run alive needs an approve outcome. Pair the actions with a review schema field (for example decision) and route in a switch node.

node editorial_review {
  type: code
  label: "Editorial review"
  code: @ts {
    return context.nodes.draft.output
  }
  review: {
    enabled: true
    title: "Editorial review"
    schema: @json {
      {
        "type": "object",
        "required": ["decision"],
        "properties": {
          "decision": { "type": "string", "enum": ["approve", "revise"] },
          "editor_notes": { "type": "string" }
        },
        "additionalProperties": false
      }
    }
    actions: [
      { id: "approve", label: "Approve & Publish", outcome: "approve" },
      { id: "revise", label: "Request Revisions", outcome: "approve" },
      { id: "reject", label: "Reject", outcome: "reject" }
    ]
  }
}

node route_decision {
  type: switch
  label: "Route"
  cases: ["publish", "request_revisions"]
  router: @ts {
    const decision = context.reviews.editorial_review?.decision
    return decision === "approve" ? "publish" : "request_revisions"
  }
}

"Request Revisions" carries an approve outcome so the run continues into the switch. The "Reject" action ends the run with the rejection reason.

Accessing review data

In @ts { } blocks, use context.reviews.<nodeName> to access the reviewer's response:

@ts {
  // In any node downstream of the gate
  const approved = context.reviews.review_gate?.approved
  const feedback = context.reviews.review_gate?.feedback ?? ""
}

The shape follows the review schema, so use optional chaining. The LSP types context.reviews from the review schema, so you get autocomplete. See Context and Type Safety for details.

Full example: draft, review, route

This workflow drafts an email with an LLM, pauses at a gate node for human review, then routes to send or revise based on the reviewer's response. The draft happens upstream in draft; review_draft is a pure gate whose output is the reviewer's form data.

secret vendor_keys {
  vars: [OPENROUTER_API_KEY, RESEND_API_KEY]
}

workflow email_with_review {
  label: "Email with Review"

  root {
    type: code
    label: "Entry"
    inputSchema: @json {
      {
        "type": "object",
        "required": ["name", "email", "topic"],
        "properties": {
          "name": { "type": "string" },
          "email": { "type": "string" },
          "topic": { "type": "string" }
        },
        "additionalProperties": false
      }
    }
    outputSchema: @json {
      {
        "type": "object",
        "required": ["name", "email", "topic"],
        "properties": {
          "name": { "type": "string" },
          "email": { "type": "string" },
          "topic": { "type": "string" }
        },
        "additionalProperties": false
      }
    }
    code: @ts {
      const { name, email, topic } = context.nodes.root.input
      return { name: name?.trim() ?? "", email: email?.trim() ?? "", topic: topic?.trim() ?? "" }
    }
  }

  node draft {
    type: ai
    kind: object
    label: "Draft email"
    schema: @json {
      {
        "type": "object",
        "required": ["subject", "body"],
        "properties": {
          "subject": { "type": "string" },
          "body": { "type": "string" }
        },
        "additionalProperties": false
      }
    }
    model: "google/gemini-2.5-flash"
    prompt: @ts {
      return `Draft a professional email to ${context.nodes.root.output.name} about: ${context.nodes.root.output.topic}`
    }
  }

  node review_draft {
    type: code
    label: "Review draft"
    code: @ts { return context.nodes.draft.output }
    review: {
      enabled: true
      title: "Review email"
      description: "Approve or request changes."
      schema: @json {
        {
          "type": "object",
          "required": ["approved"],
          "properties": {
            "approved": { "type": "boolean", "title": "Send as-is" },
            "feedback": { "type": "string", "title": "Changes requested" }
          },
          "additionalProperties": false
        }
      }
    }
  }

  node route {
    type: switch
    label: "Route"
    cases: ["send", "revise"]
    router: @ts {
      return context.reviews.review_draft?.approved ? "send" : "revise"
    }
  }

  node send {
    type: email
    label: "Send"
    from: @ts { return "[email protected]" }
    to: @ts { return context.nodes.root.output.email }
    subject: @ts { return context.nodes.draft.output.subject }
    text: @ts { return context.nodes.draft.output.body }
  }

  node revise {
    type: ai
    kind: object
    label: "Revise"
    schema: @json {
      {
        "type": "object",
        "required": ["subject", "body"],
        "properties": {
          "subject": { "type": "string" },
          "body": { "type": "string" }
        },
        "additionalProperties": false
      }
    }
    model: "google/gemini-2.5-flash"
    prompt: @ts {
      const draft = context.nodes.draft.output
      const feedback = context.reviews.review_draft?.feedback ?? ""
      return `Revise this email. Subject: ${draft.subject}. Body: ${draft.body}. Feedback: ${feedback}`
    }
  }

  node send_revised {
    type: email
    label: "Send revised"
    from: @ts { return "[email protected]" }
    to: @ts { return context.nodes.root.output.email }
    subject: @ts { return context.nodes.revise.output.subject }
    text: @ts { return context.nodes.revise.output.body }
  }

  flow {
    root -> draft
    draft -> review_draft
    review_draft -> route
    route -["send"]-> send
    route -["revise"]-> revise
    revise -> send_revised
  }
}

Further reading

Reviewing a Jev decision

Run the decision node first, then route uncertain or other results to a separate review-enabled node. The inference output retains the original probabilities; the review gate records the human’s corrections. Enabled reviews on decision inference nodes are rejected because review skips the node’s work. The complete triage example covers automatic and human branches. Rejection and timeout retain the failure behavior described above.

On this page