May 1, 2026

Expressing Community Operation in Code — Implementation Patterns for Policy, Moderation, and Invite Flows on Slack

CommunityOperationsSlackEngineeringProgrammable

Why “express it in code” — the limits of person-dependent operation

Once a Slack community grows past around 100 people, the operator runs into situations like:

  • Every new invitation requires individually deciding “should we let this person in” in the operator chat
  • No one knows who sent the welcome message or when
  • So many channels have piled up that no one can grasp what each one is for
  • Responses to rule violations swing depending on the mood of whichever operator is on duty
  • Every time an operator changes, more handover documents accumulate

These are all symptoms born from the operation existing only inside the operator’s head. We covered the limits of person-dependent operation and the thinking behind it in The idea of community development — a programmable theory of community design. This article is the implementation companion, showing concretely how to express it in code with Slack as the subject.

“Express it in code” means bringing into community operation the Infrastructure as Code (IaC) mindset that became widespread in web infrastructure. Channel structure, terms of use, moderation rules, and the invite flow are managed not as screen operations but as text files, with history kept in Git and changes reviewed via Pull Requests. This is the core of Community-as-Code.


The three components of Community-as-Code

What you codify in Slack operation can be organized into three buckets.

ComponentWhat gets codifiedTools mostly used
Operational policy (Policy as Code)Terms of use, channel structure, roles, code of conductMarkdown / YAML + Git
Moderation (Moderation as Code)Intervention triggers, automated responses, escalation pathsYAML + Bot / Workflow Builder
Invite Flow (Invite Flow as Code)Invitation → review → onboarding → role assignmentForm + Webhook + Slack API

Community-as-Code is bundling these three together as a single community operation foundation in a repository. We’ll walk through each in order.


Operational policy (Policy as Code)

Declare channel structure in YAML

Slack channels can be created and deleted from the admin screen, but operating them through screen operations causes the following problems:

  • Half a year later, no one remembers what the channel was created for
  • Similar channels proliferate (#dev and #development and #engineering)
  • Archive judgments become person-dependent

To prevent this, manage channel structure declaratively in the repository.

config/channels.yaml

channels:
  - name: announcements
    purpose: Official announcements from operators. Posting is operators only.
    topic: Important news goes here
    visibility: public
    post_permission: admins_only
    archive_policy: never

  - name: introduction
    purpose: Self-introductions. New members post here first.
    visibility: public
    post_permission: members
    archive_policy: never

  - name: random
    purpose: Chit-chat. Off-topic conversations belong here.
    visibility: public
    post_permission: members
    archive_policy: low_activity_90d

  - name: events-2026q2
    purpose: Operation for 2026 Q2 events
    visibility: public
    post_permission: members
    archive_policy: end_of_quarter

This file is managed in Git, and changes go through Pull Requests. Just by adopting the convention “always write the reason for existence in the purpose field,” the cleanup six months later becomes dramatically easier.

A script that reads this file and hits the Slack API to sync channels can start as a small one-off script. Even syncing manually is fine — what matters is that the operations team shares the relationship “the repository is the source of truth, Slack follows.” That alone significantly prevents person-dependence.

Manage terms of use and code of conduct in Markdown

Terms of use (Code of Conduct) tend to be operated as messages pinned in Slack’s #welcome or #rules channels, but with this approach you cannot trace history and cannot review changes.

docs/
  ├── code-of-conduct.md       # Code of conduct (changes via PR + operator agreement)
  ├── terms-of-use.md          # Terms of use (changes via PR + operator agreement)
  ├── moderation-policy.md     # Moderation policy
  └── escalation-flow.md       # Escalation path during incidents

Place them as Markdown under docs/ in the repository, and adopt a rule that changes always go through Pull Request. The pinned Slack messages should only be links pointing to URLs in this repository. This way, “when, who, and why the rules were changed” all remain in Git history.

Declare roles in a config file

Slack’s User Groups (paid plan) is a feature for creating mention-able roles in the form @moderators. Since these are also operationally meaningful units, manage them in code.

config/roles.yaml

roles:
  - handle: moderators
    purpose: Holds responsibility for first-pass moderation and new-member support
    members:
      - alice
      - bob
    promotion_rule: Operator agreement + 3+ months of continuous participation

  - handle: speakers
    purpose: Members scheduled to speak at the monthly LT event
    members:
      - charlie
      - dave
    promotion_rule: Self-nomination + operator approval
    archive_policy: Auto-removed 30 days after speaking

Granting and revoking roles can be done by hand, but declaring who holds what role in a file has value in itself. The “wait, why is this person in @moderators again?” problem disappears.


Moderation (Moderation as Code)

Externalize intervention triggers as rules

When building a moderation bot, the first decision is “do we write the rules (what to detect) together with the execution code (how to act), or separately?” At a glance, the same file looks simpler, but in production it always falls apart. Here’s why rules go in config files, execution logic goes in code:

  1. Change frequencies differ by orders of magnitude. Bot execution logic is touched maybe once every few months, while rules get added or tuned almost weekly to match real-world events like “this spam wording trended last week” or “let’s loosen up only for new members on day one.” Putting frequently changing things and rarely changed things in the same file means frequent changes drag the rare changes along, increasing both reviews and accidents.
  2. You want to separate who is allowed to edit. Moderators and operators (including non-engineers) need to be able to edit rules directly. Embedding them in code creates an engineer-bottleneck for adding a single rule line, and you end up regressing to “manually handling it through the Slack admin screen.” With a config file, sharing how to write YAML lets operators self-serve.
  3. Leave a record of “why this rule exists.” Through description fields and PR discussions, the reason for adding or removing a rule is etched into Git history. Six months later when someone asks “why are we banning this keyword?” you can recover the original context. Conversely, an if ... contains("FREE BTC") embedded in code carries no context.
  4. High reversibility, easy to test. Rules are declarative data, so if one isn’t working you just delete it. Embedded in execution code, every deletion forces you to re-read logic and verify impact. You can also write unit tests of “does this message match rule X?” against the rule set + execution logic.

These four reasons are the same as why rule engines (LaunchDarkly’s feature flags, Open Policy Agent’s Rego, etc.) became widespread in web services. Physically separating concerns with different change frequency, edit permissions, auditability, and reversibility into different files — bring that mindset into community operation.

Concretely, it looks like this:

config/moderation-rules.yaml

rules:
  - id: link-in-first-post
    description: Detect when a member on their first day posts an external link
    condition:
      member_age_days: { lte: 1 }
      contains: ["http://", "https://"]
    action:
      type: notify_moderators
      channel: "#mod-alerts"
      template: "New member {{user}} posted a link on day one: {{message_url}}"
    severity: low

  - id: spam-keywords
    description: Detect known spam keywords
    condition:
      contains_any: ["FREE BTC", "click here to win"]
    action:
      type: auto_remove
      notify_moderators: true
    severity: high

  - id: high-frequency-post
    description: Detect rapid consecutive posting
    condition:
      messages_in_window: { count: 10, window_minutes: 5 }
    action:
      type: rate_limit
      duration_minutes: 30
    severity: medium

Design the bot to load this rule file and act according to the definitions. Rule changes happen via Pull Requests and are merged after operator team review. A state where “who added or changed which rule, when” can be traced in Git is the bedrock of governance.

Use a hybrid of “automatic + human” for intervention timing

Rather than automating everything, design with a two-stage automatic detection → human judgment pattern. This is realistic.

SeverityAutomatic actionHuman involvement
LowNotify moderators onlyOne person confirms within 24 hours
MediumTemporary restriction + notificationSame-day operator agreement on response
HighImmediate removal + notificationLead does immediate post-hoc verification and personal contact

A design that minimizes “automatically removing” and maximizes “automatically noticing” prevents trust damage from false positives. We plan to publish a separate article, “When should moderators speak up — the optimal timing of intervention,” but in short, the optimal moment is when “throughput has reached cognitive limits” and “threads are not branching autonomously.”


Invite Flow (Invite Flow as Code)

The invite flow is the area where Community-as-Code shows the most visible results. With person-dependent operation, you tend to end up like this:

  • An operator who got asked for an invitation just sends a Slack invite link on the spot
  • There is no system for sharing applicant information across the operator team, and no one knows who invited whom
  • Right after joining, a new member doesn’t know what to do, and silent participants pile up

Design this as a state transition like the following.

[Apply] → [Review] → [Approve] → [Invite] → [Join] → [Onboard] → [Full registration]
              ↓                                           ↓
           [Reject]                                    [Drop off]

Express each state’s transition condition and automation in code.

The minimal setup of invite form + Webhook

In the minimal setup, instead of sending a Slack invite directly, you accept an application via a form, and only after operator approval is an invitation link issued. Example combining Astro / Cloudflare Workers / Slack API:

// functions/invite-request.ts
export async function onRequestPost({ request, env }) {
  const { email, name, motivation, referrer } = await request.json();

  // 1. Record the application in D1 (status = pending)
  await env.DB.prepare(`
    INSERT INTO invitations (email, name, motivation, referrer, status, created_at)
    VALUES (?, ?, ?, ?, 'pending', ?)
  `).bind(email, name, motivation, referrer, Date.now()).run();

  // 2. Notify the operators' #invite-review channel
  await fetch("https://slack.com/api/chat.postMessage", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${env.SLACK_BOT_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      channel: env.INVITE_REVIEW_CHANNEL,
      blocks: buildInviteReviewBlocks({ email, name, motivation, referrer }),
    }),
  });

  return new Response(JSON.stringify({ ok: true }));
}

Review happens via Approve / Reject buttons in Slack; once approved, a separate Worker sends the invitation email. When and by whose decision and why each invitation was approved — all of it remains in the DB and Slack history.

Onboarding automation

The experience right after joining directly affects retention. A common design is to receive Slack’s team_join event via Webhook and have an onboarding bot send messages automatically.

config/onboarding.yaml

onboarding:
  - step: 1
    timing: on_join
    action: send_dm
    message_template: docs/onboarding/welcome.md
    next_action_required: true

  - step: 2
    timing: on_join + 1h
    condition: { has_posted_in: introduction }
    action: invite_to_channels
    channels: [random, weekly-checkin]

  - step: 3
    timing: on_join + 24h
    condition: { has_not_posted_in: introduction }
    action: send_dm
    message_template: docs/onboarding/nudge-introduction.md

  - step: 4
    timing: on_join + 7d
    action: send_survey
    survey_id: first-week-feedback

By declaring per-step conditional branches in a config file rather than embedding them in code, non-engineer members of the operations team can also propose improvements.


The operational platform — principles for keeping the bot and documentation site running for the long haul

The YAML and TypeScript above don’t run on their own. You need a place for the bot to execute, a pipeline to roll out config, a delivery target for the documentation members read, and access controls for all of it. In parallel with the Slack-side operation (first half of this article), how you assemble this operational platform decides whether you can sustain Community-as-Code.

The specific tool choices (Cloudflare Workers / Vercel / GitHub Actions / VitePress / Hugo / etc.) can vary with your environment. What matters more are the five design principles you cannot drop regardless of tool choice.

PrincipleWhat it achieves
1. Bundle all elements in a monorepoKeep regulations, bot, and documentation consistent
2. Think of bot execution location in three tiersAvoid over-engineering and operational burden
3. Deliver public documentation as a static siteCreate a place members can re-read
4. Don’t write authentication into the app — push it to the infra layerKeep a generic design not bound to one community
5. Automate deployment via PR triggersStructurally eliminate “manual production releases”

We’ll go through each principle in order.

Principle 1: Bundle all elements in a monorepo

If the invite form, moderation bot, documentation site, and shared config are managed in separate repositories, operations break down the moment a change spans multiple repos. “When we update the terms of use, we want to change the documentation site and the bot’s message templates together” happens daily.

To avoid this, we recommend managing every element related to community operation in a single repository. Physically being in the same repo means the regulation change and the bot-side behavior change that references it can be reviewed together in one PR.

Conceptually the repository contents fall into four buckets:

  • Public documentation (handbook, regulations, and guides members read)
  • Applications (Slack bot, invite-form Webhook, measurement jobs)
  • Shared config (channels, roles, moderation rules, onboarding YAML)
  • Shared libraries (config loaders, Slack API clients, and other logic reused across multiple apps)

Any tool that supports monorepos works (Bun workspaces / pnpm workspaces / Turborepo / Nx, etc.). What matters is maintaining a state where “a bot change and a documentation change can be explained in the same PR.”

Principle 2: Think of bot execution location in three tiers

When building a Slack bot, you don’t need to spin up your own server from the start. The execution-location choices form three tiers, and the iron rule for keeping operational burden down is try them from the bottom and only move up when you have to.

TierSuited forCost feel
L1. Platform-standard features (Workflow Builder, etc.)Templated responses, fixed invite flowsZero. No outage risk
L2. Serverless functions (any FaaS)Webhook-driven event handling, short jobsPay-per-use. Auto-scales
L3. Always-on server (any VM / container)Long-running tasks, WebSocket maintenance, large batches24h running cost + operational responsibility

If you start with “let me just rent a VPS and run the bot,” operational load and cost arrive first, exhausting you while the community is still small. Ship the smallest working thing first, and upgrade only when the need is visible — keep that order.

When choosing L2 serverless functions, the only generic types worth memorizing are these two handlers:

  • Webhook handler — Receives Slack events (message posts, joins, reactions) and responds against the config file
  • Schedule handler — Runs on time triggers (once a day, weekly, etc.) for routine aggregation and notifications

Those two cover moderation, onboarding, and health reporting. The actual implementation just changes shape per runtime (Workers / Lambda / Cloud Functions / etc.), but the structure is the same.

Principle 3: Deliver public documentation as a static site

Managing terms of use and code of conduct in Git is one thing — delivering them in a form members can read is another problem. Relying only on Slack’s pinned messages gives you poor searchability, and after six months no one reads them.

Having an independent public documentation site for the community establishes the following:

  • Members can resolve “where was that rule written again?” by search
  • Regulation changes flow to production via PR review and automatic deploy
  • For new-member onboarding, a single external URL pointed from Slack is enough

Any static site generator that takes Markdown as input works (VitePress / Astro / Hugo / Docusaurus / MkDocs, etc.). What matters is the separation of “the regulation’s source of truth is Markdown in Git, delivery is the static site generator, hosting is a static site delivery service” — not the specific combo.

If you point the documentation site URL as a fixed link from Slack channel topics or a /help workflow, regulation changes automatically reflect in the member experience.

Principle 4: Don’t write authentication into the app — push it to the infra layer

Requirements like “make the documentation site member-only” or “show the invite form only to existing members” will always come up. At that point, not writing authentication into application code is one of the most valuable design decisions in Community-as-Code.

“Don’t write authentication” means completing login UI, session management, password reset, and MFA in front of the app (reverse proxy / Identity-Aware Proxy / SSO gateway), and writing the app on the premise that “only authenticated requests reach it.”

This design works for three reasons:

  1. Authentication is a hotbed of bugs and security incidents. In-house implementations carry heavy audit and operational burden — leaving it to specialized services is overwhelmingly safer.
  2. Permission changes complete via config. Revoking permissions for departing members and granting permissions to new moderators can be done by updating the allowlist alone, without touching app code.
  3. Replication cost as a template drops. If the app has no authentication logic, replicating the repo to another community doesn’t require thinking about tenant-specific authentication implementation.

For tool choices, combine an IdP (Google / GitHub / Microsoft SSO, etc.) with a gateway product that verifies the authentication result in front of your app (Cloudflare Access / Google IAP / AWS Cognito + ALB / Auth0 + reverse proxy, etc.). Whichever you pick, the right goal is for the app-side code to converge on a single line that “trusts the authenticated header.”

Conversely, the moment “I want to build a login screen” comes up, suspect the design — that’s about the right level of skepticism.

Principle 5: Automate deployment via PR triggers

If a config change only takes effect “when an operator runs a deploy command by hand,” person-dependence comes back even after codification. Bring the GitOps mindset of “main branch is production” and “PR merges auto-deploy” into community operation.

The minimum flows to automate are these three:

  • Regulation / documentation changes → Documentation site rebuild and redeployment
  • Config file (YAML) changes → Bot loads the new config on next start (or hot-reload via Webhook)
  • App code changes → Auto-deploy to the bot execution environment

In CI configuration, use paths filters for optimizations like “don’t redeploy the bot just because docs changed.” At the same time, the cardinal rule is to keep API tokens and credentials out of the repository, in CI secret management. Once that’s strict, replicating the repository itself to another community won’t leak secrets.

Just creating a state where only PR-reviewed changes reach production is itself a governance instrument for the operator team. Having “when, who, and why a rule was changed” persist as PR discussion is value you can never get from verbal agreement.


Pitfalls of programmable-ization

The deeper you go into implementation, the clearer the pitfalls to avoid become.

1. Over-mechanizing destroys emotional value

If you hand the welcome message to a bot and individual outreach from operators disappears, new members lose the “feeling of being welcomed.” Automation exists not to reduce outreach, but to free up time for outreach.

2. Putting rules in code raises change cost

Regulations that can only be changed via Pull Request become hard to change in emergencies. To avoid this, enforce the separation of “hard-to-change layers (regulations, roles)” and “easy-to-change layers (announcements, event planning)”. Don’t codify everything; choose the storage location based on change frequency.

3. Tool dependence breeds new person-dependence

If you build too many custom bots, the moment the bot’s author leaves, operations stop. Adopt the principle that anything you can do with standard features (Workflow Builder, Slack API) should first be done with standard features, and limit custom implementations to areas that genuinely require them.

4. Areas where “human judgment” should not be removed

The final call on invitations, judging the severity of violations, the message when someone leaves the community — the right answer in domains that require trust and empathy is to deliberately not codify them. The decision not to codify is itself a design decision in community development.


A path for phased adoption

You don’t need to build everything at once. The realistic path is this phased order:

PhaseWhat to doTime guideline
0. DocumentationWrite out terms of use, channel structure, and operator roles in Markdown2 weeks
1. Policy as CodeMove the above into the repository; changes go through PR1 month
2. Operational platform foundationsSet up monorepo + documentation site + auto-deploy + authentication (outside the app)1–2 months
3. Invite flowImplement application form + operator approval + automatic invite send1–2 months
4. Onboarding automationAutomate post-join bot messages and channel invites1 month
5. Moderation as CodeMove detection rules into config files and introduce a bot2–3 months
6. Measurement dashboardAuto-compute health indicators and reference them in operator standup1–2 months

Even just phases 0 and 1 are a major step away from person-dependent operation. Don’t try to write a bot from the start — start with documentation and Git management is the steady route. Getting the operational platform in phase 2 done before building bots and forms means later phases don’t get bogged down in “just keeping things running.”


Summary — Increasing what you can express in code is a condition for keeping a community going

The goal of Community-as-Code is not to mechanize community. It is to delegate procedures that require no judgment to code, so that operators can spend time on building trust relationships.

The warmth of person-dependent operation and the reproducibility of code do not conflict. On the contrary, increasing what can be expressed in code means operators can spend time on territory that code cannot express — questions like “what is this person worrying about?” and “how is the place’s temperature?”

As a first step, try discussing these questions with your operator team:

  • What kind of judgments do operators repeat every week or every month?
  • For rules decided six months ago, can you explain why they were set that way?
  • If the operator changes, how many months until quality returns to the same level?

Any item you cannot answer immediately is the first target for Community-as-Code.

If you need accompaniment on systematizing community operation, or designing an operational platform on Slack, please consider Rokuse’s community support service.

Frequently asked questions

Q. Can I implement Community-as-Code without a paid Slack plan?
A. Partially, yes — even on the free plan. Declarative management of channel structure, externalizing the invite flow (form + Webhook), and document management of the terms of use all work without issue on the free plan. On the other hand, if you want to seriously run governance with User Groups (the @here alternative or roles), audit logs, and SCIM integration, the Business+ plan or higher is realistic. A solid path is to start by codifying operational policy and the invite flow on the free plan, and revisit the plan when you scale.
Q. What if I don't have the technical skill to build a bot?
A. You don't need to build a custom bot from the start. Slack's standard Workflow Builder covers invite flows and welcome messages well enough. Using Workflow Builder's YAML export feature, you can also manage the definitions in a repository. The realistic path is to add small scripts hitting the Slack API on a serverless environment like Cloudflare Workers or AWS Lambda once you actually need full automation.
Q. Doesn't putting rules in code make changes harder?
A. Exactly — and that is a deliberate design tradeoff. That's why we design with a separation between "layers that are hard to change" and "layers that are easy to change." Governance-touching items like terms of use and code of conduct go in the hard-to-change layer (only changeable on the main branch via PR), while welcome message wording and announcements for recurring events go in the easy-to-change layer (Notion or Google Doc that any operator can edit). You don't have to put everything in code.
Q. Can the "warmth" or "comfort" of a community be coded?
A. It cannot be — and it should not be. What should be expressed in code is only "procedures that require no judgment." Organic territory like the air that lets newcomers feel safe, accidental dialogue, and gentle interventions by operators is exactly where operators should be spending their time. The goal of Community-as-Code is to "delegate procedures that require no judgment to a system, so operators can spend time on the moves that maintain warmth" — not to auto-generate warmth itself.