LIVE DEMO

THIS FORM IS A .swirls FILE.

The feedback form below is backed by a real .swirls file. Submit feedback to see the same pipeline you use in the editor: normalize, AI analysis, and structured output.

feedback.swirls/file to power the form
form feedback {
  label: "Product feedback"
  description: "Share feedback and we analyze it with a workflow graph."
  enabled: true
  schema: @json {
    {
      "type": "object",
      "required": ["name", "email", "feedback", "rating"],
      "properties": {
        "name": { "type": "string", "title": "Name" },
        "email": { "type": "string", "title": "Email", "format": "email" },
        "feedback": { "type": "string", "title": "Feedback" },
        "rating": {
          "type": "integer",
          "title": "Rating",
          "minimum": 1,
          "maximum": 5
        }
      },
      "additionalProperties": false
    }
  }
}

secret slack_feedback_webhook {
  label: "Slack incoming webhook"
  description: "Three path segments from hooks.slack.com/services/T/B/token"
  vars: [SLACK_WEBHOOK_TEAM_ID, SLACK_WEBHOOK_CHANNEL_ID, SLACK_WEBHOOK_TOKEN]
}

graph process_feedback {
  label: "Process feedback"
  description: "Normalize input, analyze with AI, then notify Slack"

  root {
    type: code
    label: "Normalize"
    inputSchema: @json {
      {
        "type": "object",
        "required": ["name", "email", "feedback", "rating"],
        "properties": {
          "name": { "type": "string" },
          "email": { "type": "string" },
          "feedback": { "type": "string" },
          "rating": { "type": "integer" }
        },
        "additionalProperties": false
      }
    }
    outputSchema: @json {
      {
        "type": "object",
        "required": ["name", "email", "feedback", "rating"],
        "properties": {
          "name": { "type": "string" },
          "email": { "type": "string" },
          "feedback": { "type": "string" },
          "rating": { "type": "integer" }
        },
        "additionalProperties": false
      }
    }
    code: @ts {
      const { name, email, feedback, rating } = context.nodes.root.input

      return {
        name: name.trim(),
        email: email.toLowerCase().trim(),
        feedback: feedback.trim(),
        rating: Math.min(5, Math.max(1, Number(rating) || 1)),
      }
    }
  }

  node analyze {
    type: ai
    kind: object
    label: "Analyze feedback"
    schema: @json {
      {
        "type": "object",
        "required": ["sentiment", "category"],
        "properties": {
          "sentiment": { "type": "string" },
          "category": { "type": "string" }
        }
      }
    }
    model: "google/gemini-2.5-flash"
    prompt: @ts {
      const { feedback, rating } = context.nodes.root.output
      return `You classify product feedback. Rating is 1-5. Return JSON with:
- sentiment: one of positive, neutral, negative
- category: one of bug, feature_request, praise, other

Feedback: ${feedback}
Rating: ${rating}`
    }
  }

  node assemble {
    type: code
    label: "Assemble submission"
    schema: @json {
      {
        "type": "object",
        "required": ["name", "email", "feedback", "rating", "sentiment", "category", "receivedAt"],
        "properties": {
          "name": { "type": "string" },
          "email": { "type": "string" },
          "feedback": { "type": "string" },
          "rating": { "type": "integer" },
          "sentiment": { "type": "string" },
          "category": { "type": "string" },
          "receivedAt": { "type": "string" }
        }
      }
    }
    code: @ts {
      const { name, email, feedback, rating } = context.nodes.root.output
      const { sentiment, category } = context.nodes.analyze.output

      return {
        name,
        email,
        feedback,
        rating,
        sentiment,
        category,
        receivedAt: new Date().toISOString(),
      }
    }
  }

  node notify_slack {
    type: http
    label: "Notify Slack"
    method: "POST"
    secrets: {
      slack_feedback_webhook: [SLACK_WEBHOOK_TEAM_ID, SLACK_WEBHOOK_CHANNEL_ID, SLACK_WEBHOOK_TOKEN]
    }
    url: @ts {
      const teamId = context.secrets.slack_feedback_webhook.SLACK_WEBHOOK_TEAM_ID
      const channelId = context.secrets.slack_feedback_webhook.SLACK_WEBHOOK_CHANNEL_ID
      const token = context.secrets.slack_feedback_webhook.SLACK_WEBHOOK_TOKEN
      
      return `https://hooks.slack.com/services/${teamId}/${channelId}/${token}`
    }
    body: @ts {
      type FeedbackSlackPayload = {
        name: string
        email: string
        feedback: string  
        rating: number
        sentiment: string
        category: string
      }

      const formatFeedbackSlackText = (p: FeedbackSlackPayload) => {
        const meta = `From: ${p.name} (${p.email})
Rating: ${p.rating}/5 | Sentiment: ${p.sentiment} | Category: ${p.category}`
        
        return ["New product feedback", meta, "", p.feedback].join("\n")
      }

      const text = formatFeedbackSlackText({
        name: context.nodes.root.output.name,
        email: context.nodes.root.output.email,
        feedback: context.nodes.root.output.feedback,
        rating: context.nodes.root.output.rating,
        sentiment: context.nodes.analyze.output.sentiment,
        category: context.nodes.analyze.output.category,
      })

      return JSON.stringify({ text: text })
    }
  }

  flow {
    root -> analyze
    analyze -> assemble
    assemble -> notify_slack
  }
}

trigger on_feedback {
  form:feedback -> process_feedback
  enabled: true
}

Try the demo

Submit feedback—it runs through the workflow on the left.

HOW IT WORKS

1. Define

Write your form schema, processing graph, and triggers in a single .swirls file. Version it in Git like any other code.

2. Submit

Users submit feedback. Input is validated against your JSON Schema and sent to the Swirls runtime.

3. Execute

The trigger runs your graph: normalize, analyze with an LLM, and produce structured output. Each node runs in order.