Live demo

This form is a .swirls file.

The feedback form below is backed by real Swirls. Submit something and the same engine that runs in production handles your input: normalize, analyze with an LLM, persist structured output.

feedback.swirls/file to power the form
schema feedback_submission {
  label: "Feedback submission"
  description: "Shared payload for the feedback form and normalize step"
  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
    }
  }
}

form feedback {
  label: "Product feedback"
  description: "Share feedback and we analyze it with a workflow graph."
  enabled: true
  schema: feedback_submission
}

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: feedback_submission
    outputSchema: feedback_submission
    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

How it works

Three steps from submission to result.

Define

Write the form schema, the processing graph, and the trigger across .swirls files. Version them alongside the rest of your code.

Submit

Users submit the form. The runtime validates the payload against your JSON Schema and dispatches the trigger.

Execute

The graph runs end-to-end: normalize, analyze with an LLM, and produce structured output. Every step is checkpointed.

Five minutes from install to an agent you'd ship.

Install the CLI. Write .swirls files. Run them locally with the same engine that runs in production. When you're ready, git push or swirls cloud deploy. No CI/CD to build.

Building something with Swirls? Come hang out in Discord.

Join the Discord