SWIRLS_
Workflows

Reviews

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

What it is. A gate that waits for a person. The run pauses at the node until someone approves, rejects, or fills in a form.

Use it when a human must sign off before the system acts: sending the email, posting the refund, publishing the page.

Works with workflows (review is a config on any node) and context.reviews (the response downstream).

A review-enabled node is a gate, not a step. When the run reaches it, the node's own work does not execute. The run pauses, a review appears in the project Inbox, and a reviewer responds:

  • Approve resumes the run. The reviewer's submitted form data is recorded as the node's output and exposed to downstream nodes at context.reviews.<nodeName>.
  • Reject fails the run with the rejection reason.
  • No response within the review timeout fails the run. The default timeout is 7 days.

Because the review replaces the node's own execution, do the work in an upstream node and make the reviewed node a dedicated gate.

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. Remember that 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.

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

On this page