One file can hold the complete business system.

Here is one editorial desk end to end: writers submit drafts, AI reads them first, an editor approves, and the piece posts to Slack. Everything it runs on is written in a single .swirls file: where drafts are kept, the steps they move through, who is allowed to approve, and the screen the editors work on. This page builds it one piece at a time.

Triggers

A writer submits a draft.

A form is where the work begins. You point that form at the process that should handle it, and that is the entire connection. There is no service to stand up in between and no queue to wire together. If the work should start from another tool instead, or every morning at nine, those attach the same way.

Learn more
editorial/desk
editorial-desk.swirls
schema submission_input {
  label: "Article submission"
  schema: @json {
    {
      "type": "object",
      "required": ["title", "body", "author_email"],
      "properties": {
        "title": { "type": "string", "title": "Title" },
        "body": { "type": "string", "title": "Body" },
        "author_email": { "type": "string", "title": "Author email", "format": "email" }
      },
      "additionalProperties": false
    }
  }
}

form article_submission {
  label: "Article submission"
  description: "Submit a draft for editorial review."
  enabled: true
  schema: submission_input
}

trigger on_submission {
  form:article_submission -> publish_article
  enabled: true
}
Form · article_submission
TitleShipping the review queue
BodyNotes from the week we moved editor review into the same file as the workflow…
Submit draft

Workflows

The work has to survive the wait.

Tidy up the submission, let AI read it, save what it found, then stop and wait for a person. The editor might take a day, or a week. Swirls remembers exactly where the work paused, so an approval picks it up from that point and carries on to publishing and the Slack post. Nothing gets lost and nothing runs twice.

Learn more
editorial/desk
editorial-desk.swirls
workflow publish_article {
  label: "Publish article"

  root {
    type: code
    label: "Normalize submission"
    inputSchema: submission_input
    code: @ts {
      const { title, body, author_email } = context.nodes.root.input
      return {
        title: title.trim(),
        body: body.trim(),
        author_email: author_email.toLowerCase().trim(),
        word_count: body.split(/\s+/).length,
      }
    }
  }

  node screen {
    type: ai
    kind: object
    label: "Screen draft"
    model: "google/gemini-2.5-flash"
    prompt: @ts {
      const { title, body } = context.nodes.root.output
      return [
        "Summarize this draft in one sentence, then list editorial concerns.",
        "Return an empty flags array when the draft is clean.",
        "",
        "Title: " + title,
        "Body: " + body,
      ].join("\n")
    }
  }

  node editorial_review {
    type: code
    label: "Editor review"
    code: @ts {
      const { summary, flags } = context.nodes.screen.output
      return { summary, flags }
    }
    review: {
      enabled: true
      title: "Editorial review"
      actions: [
        { id: "approve", label: "Approve & publish", outcome: "approve" },
        { id: "revise", label: "Request revisions", outcome: "reject" }
      ]
    }
  }

  flow {
    root -> screen
    screen -> editorial_review
    editorial_review -> publish
    publish -> announce
  }
}
publish_article · exec_9K2mPaused
  1. Normalize submission

    done
  2. Screen draft

    done
  3. Save screened draft

    done
  4. Editor review

    paused

    Waiting on editor

  5. Mark published

    queued
  6. Announce in Slack

    queued

Review

Approve “Shipping the review queue”?

ApproveRequest revisions

Agents

The agent gets one job and a written guide.

The assistant is given a single job and a short list of things it is allowed to do. Anything with fixed rules stays in the process, where it behaves the same way every time. The judgment calls, your house tone, when to credit a source, what counts as a problem, live in a written guide the assistant opens when it needs one. You edit that guide the way you would edit any other document.

Learn more
editorial/desk
editorial-desk.swirls
skill editorial_voice {
  name: "editorial-voice"
}

agent editor_assistant {
  label: "Editor assistant"
  secrets: editorial_ops
  provider: openrouter
  model: "openai/gpt-4o-mini"
  maxSteps: 8
  skills: [editorial_voice]
  tools: [publish_article]
  system: @ts {
    return [
      "You help the editorial desk move drafts through review.",
      "Open the editorial-voice skill before judging tone, attribution, or house style.",
      "Never publish a draft an editor has not approved.",
    ].join("\n")
  }
}

channel editorial_room {
  label: "Editorial room"
  platform: swirls
  agent: editor_assistant
  mode: mention
}
Skill · editorial-voice

The assistant opens this while it reads a draft. House tone and attribution rules stay in writing, where anyone can edit them.

.agents/skills/editorial-voice/SKILL.md

Prefer concrete claims. Flag missing attribution. Never invent quotes.

Tool call

publish_article

Data

The desk has to remember things.

Describe the records you keep, a draft's title, its status, the notes AI left on it, and Swirls creates and runs the database for you. There is no server to set up and no password to pass around. The process, the assistant, and the editors' screen all read and write those same records.

Learn more
editorial/desk
editorial-desk.swirls
database articles {
  label: "Editorial articles"
  schema: @prisma {
    enum ArticleStatus {
      SUBMITTED
      SCREENED
      PUBLISHED
    }

    model Article {
      id          Int           @id @default(autoincrement())
      title       String
      body        String
      authorEmail String
      status      ArticleStatus @default(SUBMITTED)
      summary     String?
      flags       String[]
      submittedAt DateTime      @default(now())
      publishedAt DateTime?

      @@map("articles")
    }
  }
}

node save_draft {
  type: database
  database: articles
  operation: insert
  run: @ts {
    const draft = context.nodes.root.output
    const { summary, flags } = context.nodes.screen.output
    const article = await context.db.articles.article.create({
      data: {
        title: draft.title,
        authorEmail: draft.author_email,
        status: "SCREENED",
        summary,
        flags,
      },
    })
    return { id: article.id }
  }
}
articles · managed Postgres
idtitlestatusflags
12Shipping the review queuein_review1
11Voice guidelines v3published0
10Partner announcement draftneeds_revision2

Connections

Reaching other tools is part of the same file.

Posting to Slack is written down like every other step: which service, which account, and what gets sent. Swirls keeps the login for that account, so no password or key ever goes in the file and nobody has to email one around. When something goes wrong, the Slack post shows up in the same history as the rest of the work.

Learn more
editorial/desk
editorial-desk.swirls
secret editorial_ops {
  label: "Editorial vendor keys"
  vars: [ OPENROUTER_API_KEY ]
}

connection editorial_slack {
  label: "Editorial Slack"
  provider: slack
}

node announce {
  type: integration
  label: "Announce in Slack"
  connection: editorial_slack
  action: slack_post_message
  params: @ts {
    return {
      channel: "#editorial",
      text: "Published: " + context.nodes.publish.output.title,
    }
  }
}
Credentials
  • Editorial Slack

    editorial_slack

    provider: slack · chat:write

    Sign-in held by Swirls

  • editorial_ops

    OPENROUTER_API_KEY

    Named here, never stored here

Access

Roles decide who gets in and who can approve.

First say who counts as an editor, here anyone in the editorial department. Then say what editors get: the assistant, the one process it is allowed to run, and the right to approve a draft. Writers can submit through the form and see nothing else. These rules sit a few lines from the work they protect, so they get read when the rest of the file gets read.

Learn more
editorial/desk
editorial-desk.swirls
role editorial {
  description: "Editorial staff"
  match {
    department: "editorial"
  }
}

policy {
  allow editorial -> agent editor_assistant {
    workflows: [publish_article]
  }
}

app editorial_desk {
  audience {
    admission: invite
    auth: oidc
    idp: editorial_clerk
  }

  page approvals {
    label: "Awaiting review"
    reviews: [publish_article]
  }
}

Role

editorial · department: editorial

Policy

Allow editorial to use editor_assistant with publish_article as its only tool.

App audience

Invite only, through your company sign-in. Approvals happen on one page, and nowhere else.

Apps

The people doing the work need a screen.

Three short page descriptions give the editors one: the queue of drafts, the approvals waiting on them, and a room where they can ask the assistant. Swirls hosts and runs it, so there is no second application to build, deploy, or keep in step with the first.

Learn more
editorial_desk
editorial-desk.swirls
app editorial_desk {
  label: "Editorial Desk"
  icon: "newspaper"

  nav {
    section "Desk" {
      page: queue
      page: approvals { label: "Awaiting review" }
    }
    section "Team" {
      page: room { label: "Editorial room" }
    }
  }

  page queue {
    label: "Queue"
    viewport: full
    render: @openui {
      root = Gutter([
        TableEditor("articles", "articles", {
          title: "Review queue",
          fill: true,
          columns: ["title", "status", "summary", "flags"]
        })
      ], "lg")
    }
  }

  page approvals {
    reviews: [publish_article]
  }

  page room {
    channel: editorial_room
  }
}
Review queue

Shipping the review queue

in_review · 1 flags

Open

Voice guidelines v3

published

Open

Partner announcement draft

needs_revision · 2 flags

Open

One file

The whole desk, in one file.

Storage, the form, the steps, the AI, the Slack post, the permissions, and the screen, in the order the desk needed them. It is a working file, checked by the Swirls compiler every time this page is built. Your coding agent writes the first draft, and you read it the way you would read a document.

editorial/desk
editorial-desk.swirls
secret editorial_ops {
  label: "Editorial vendor keys"
  vars: [ OPENROUTER_API_KEY ]
}

database articles {
  label: "Editorial articles"
  description: "Drafts, review state, and published pieces for the editorial desk."
  schema: @prisma {
    enum ArticleStatus {
      SUBMITTED
      SCREENED
      PUBLISHED
    }

    model Article {
      id          Int           @id @default(autoincrement())
      title       String
      body        String
      authorEmail String
      status      ArticleStatus @default(SUBMITTED)
      summary     String?
      flags       String[]
      submittedAt DateTime      @default(now())
      publishedAt DateTime?

      @@map("articles")
    }
  }

  connection app {
    label: "Editorial application connection"
  }
}

schema submission_input {
  label: "Article submission"
  schema: @json {
    {
      "type": "object",
      "required": ["title", "body", "author_email"],
      "properties": {
        "title": { "type": "string", "title": "Title" },
        "body": { "type": "string", "title": "Body" },
        "author_email": { "type": "string", "title": "Author email", "format": "email" }
      },
      "additionalProperties": false
    }
  }
}

form article_submission {
  label: "Article submission"
  description: "Submit a draft for editorial review."
  enabled: true
  schema: submission_input
}

trigger on_submission {
  form:article_submission -> publish_article
  enabled: true
}

workflow publish_article {
  label: "Publish article"
  description: "Screen a draft with AI, hold it for an editor, then publish and announce."

  root {
    type: code
    label: "Normalize submission"
    inputSchema: submission_input
    outputSchema: @json {
      {
        "type": "object",
        "required": ["title", "body", "author_email", "word_count"],
        "properties": {
          "title": { "type": "string" },
          "body": { "type": "string" },
          "author_email": { "type": "string" },
          "word_count": { "type": "integer" }
        },
        "additionalProperties": false
      }
    }
    code: @ts {
      const { title, body, author_email } = context.nodes.root.input
      return {
        title: title.trim(),
        body: body.trim(),
        author_email: author_email.toLowerCase().trim(),
        word_count: body.split(/\s+/).length,
      }
    }
  }

  node screen {
    type: ai
    kind: object
    label: "Screen draft"
    model: "google/gemini-2.5-flash"
    schema: @json {
      {
        "type": "object",
        "required": ["summary", "flags"],
        "properties": {
          "summary": { "type": "string" },
          "flags": { "type": "array", "items": { "type": "string" } }
        }
      }
    }
    prompt: @ts {
      const { title, body } = context.nodes.root.output
      return [
        "Summarize this draft in one sentence, then list editorial concerns.",
        "Flag missing attribution, unsourced claims, and inflammatory language.",
        "Return an empty flags array when the draft is clean.",
        "",
        "Title: " + title,
        "Body: " + body,
      ].join("\n")
    }
  }

  node save_draft {
    type: database
    label: "Save screened draft"
    database: articles
    operation: insert
    run: @ts {
      const draft = context.nodes.root.output
      const { summary, flags } = context.nodes.screen.output
      const article = await context.db.articles.article.create({
        data: {
          title: draft.title,
          body: draft.body,
          authorEmail: draft.author_email,
          status: "SCREENED",
          summary,
          flags,
        },
      })
      return { id: article.id }
    }
  }

  node editorial_review {
    type: code
    label: "Editor review"
    code: @ts {
      const { title, word_count } = context.nodes.root.output
      const { summary, flags } = context.nodes.screen.output
      return { title, word_count, summary, flags }
    }
    review: {
      enabled: true
      title: "Editorial review"
      description: "Approve the draft, or send it back with notes."
      schema: @json {
        {
          "type": "object",
          "required": ["decision"],
          "properties": {
            "decision": { "type": "string", "title": "Decision", "enum": ["approve", "revise"] },
            "editor_notes": { "type": "string", "title": "Editor notes" }
          },
          "additionalProperties": false
        }
      }
      actions: [
        { id: "approve", label: "Approve & publish", outcome: "approve" },
        { id: "revise", label: "Request revisions", outcome: "reject" }
      ]
    }
  }

  node publish {
    type: database
    label: "Mark published"
    database: articles
    operation: update
    run: @ts {
      const { id } = context.nodes.save_draft.output
      const article = await context.db.articles.article.update({
        where: { id },
        data: { status: "PUBLISHED", publishedAt: new Date() },
      })
      return { id: article.id, title: article.title }
    }
  }

  node announce {
    type: integration
    label: "Announce in Slack"
    connection: editorial_slack
    action: slack_post_message
    params: @ts {
      return {
        channel: "#editorial",
        text: "Published: " + context.nodes.publish.output.title,
      }
    }
  }

  node published {
    type: code
    label: "Return published article"
    schema: @json {
      {
        "type": "object",
        "required": ["id", "title"],
        "properties": {
          "id": { "type": "integer" },
          "title": { "type": "string" }
        },
        "additionalProperties": false
      }
    }
    code: @ts {
      return context.nodes.publish.output
    }
  }

  flow {
    root -> screen
    screen -> save_draft
    save_draft -> editorial_review
    editorial_review -> publish
    publish -> announce
    announce -> published
  }
}

action slack_post_message {
  label: "Post message"
  description: "Post a message to a Slack conversation."
  provider: slack
  method: POST
  path: "/chat.postMessage"
  encoding: form
  scopes: ["chat:write"]
  input: @json {
    {
      "type": "object",
      "required": ["channel", "text"],
      "properties": {
        "channel": { "type": "string" },
        "text": { "type": "string" }
      },
      "additionalProperties": false
    }
  }
  output: @json {
    {
      "type": "object",
      "required": ["ok"],
      "properties": {
        "ok": { "type": "boolean" },
        "ts": { "type": "string" }
      },
      "additionalProperties": true
    }
  }
}

connection editorial_slack {
  label: "Editorial Slack"
  provider: slack
}

skill editorial_voice {
  name: "editorial-voice"
}

agent editor_assistant {
  label: "Editor assistant"
  description: "Answers questions about the queue and publishes drafts an editor cleared."
  secrets: editorial_ops
  provider: openrouter
  model: "openai/gpt-4o-mini"
  maxSteps: 8
  skills: [editorial_voice]
  tools: [publish_article]
  system: @ts {
    return [
      "You help the editorial desk move drafts through review.",
      "Open the editorial-voice skill before judging tone, attribution, or house style.",
      "Never publish a draft an editor has not approved.",
    ].join("\n")
  }
}

channel editorial_room {
  label: "Editorial room"
  platform: swirls
  agent: editor_assistant
  enabled: true
  mode: mention
}

role editorial {
  description: "Editorial staff"
  match {
    department: "editorial"
  }
}

policy {
  allow editorial -> agent editor_assistant {
    workflows: [publish_article]
  }
}

app editorial_desk {
  label: "Editorial Desk"
  description: "The queue, the approvals, and the assistant in one place."
  icon: "newspaper"

  audience {
    admission: invite
    auth: oidc
    idp: editorial_clerk
  }

  nav {
    section "Desk" {
      page: queue
      page: approvals { label: "Awaiting review" }
    }
    section "Team" {
      page: room { label: "Editorial room" }
    }
  }

  page queue {
    label: "Queue"
    icon: "inbox"
    viewport: full
    render: @openui {
      root = Gutter([
        TableEditor("articles", "articles", {
          title: "Review queue",
          fill: true,
          columns: ["title", "status", "summary", "flags"]
        })
      ], "lg")
    }
  }

  page approvals {
    label: "Awaiting review"
    icon: "clipboard-check"
    reviews: [publish_article]
  }

  page room {
    icon: "hash"
    channel: editorial_room
  }
}

Start with one process you already run.

It does not have to be an editorial desk. Onboarding, refunds, claims, procurement: whatever your team tracks in spreadsheets and chases down in chat is built from the same pieces, and it fits in one file the same way.