Tutorial: build an organisation

Declare culture, teams, seats, memory and permissions step by step, then watch a request travel from a human to a tracker project.

This tutorial builds an organisation declaration from scratch: a culture, a representative for one person, and an engineering team that can create work in Linear. Each section adds one kind of record and explains what the platform does with it. The finished file is close to the quickstart's organisation/main.tf, which uses the reusable modules to say the same thing more compactly.

Complete the Quickstart first, so the platform is installed. Work in a copy of its organisation/ directory, which already reads the cluster, namespace and secret references from the platform stage. The examples below use literal values to keep them readable. If you run from source instead, work in examples/organisation/, where module sources are local paths such as ../../modules/instruction-bundle.

1. The provider and the organisation resource

The provider writes a single AgentOrganization object and then watches it converge. kube_context is required on purpose: the provider never falls back to whatever context happens to be current.

terraform {
  required_providers {
    steadmesh = {
      source  = "darcys22/steadmesh"   # from the Terraform Registry
      version = "~> 0.1.0"
    }
  }
}

provider "steadmesh" {
  kubeconfig_path = "~/.kube/config"
  kube_context    = "my-cluster"
  namespace       = "steadmesh"            # created by the platform stage
}

resource "steadmesh_organization" "this" {
  key            = "example-company"     # stable; renaming it is a migration
  display_name   = "Example Company"
  data_retention = "retain"              # keep history and workspaces if destroyed
  wait_for_ready = true

  spec = {
    # everything below goes here
  }

  timeouts {
    create = "20m"
    update = "20m"
  }
}

2. Culture and instructions

Instructions are versioned text, stored outside the organisation object and referenced by content digest. That way a change is always a visible, reviewable diff. The instruction-bundle module publishes a markdown file as a ConfigMap and outputs a reference such as configmap:steadmesh-culture/culture.md#sha256:9f2c….

module "culture" {
  source      = "github.com/darcys22/steadmesh//modules/instruction-bundle?ref=v0.1.0"
  name        = "steadmesh-culture"
  namespace   = "steadmesh"
  source_path = "${path.module}/instructions/culture.md"
}

module "representative_role" {
  source      = "github.com/darcys22/steadmesh//modules/instruction-bundle?ref=v0.1.0"
  name        = "steadmesh-role-representative"
  namespace   = "steadmesh"
  source_path = "${path.module}/instructions/representative.md"
}
  spec = {
    culture_refs = [module.culture.ref]

Each seat receives its instructions in a fixed order: organisation culture, then team guidance in declared order, then the seat's role, then seat-specific additions. The controller checks every digest before it renders them into the seat. If a ConfigMap no longer matches its digest, the seat is never started with it.

Instructions guide behaviour. They are not a security boundary. No instruction, culture text or chat message can widen what a seat is allowed to do. Only grants can.

3. Profiles: how seats run

Three independent profiles describe how a seat runs. A seat can switch harness without changing its memory, or change resources without changing its role.

    harness_profiles = {
      primary = {
        adapter      = "fake"                   # or "claude-code"
        image_digest = "steadmesh/seat-fake:dev" # pin image@sha256:… in production
      }
    }

    execution_profiles = {
      interactive = {
        backend      = "kubernetes"
        idle_policy  = "warm_then_stop"   # scale to zero when idle, wake on messages
        idle_timeout = "15m"
        memory_limit = "2Gi"
      }
    }

    sandbox_profiles = {
      standard = {}   # default: deny-all network except the platform, non-root, resource limits
    }

Unsupported combinations fail at plan time and are never quietly downgraded. For example, idle_policy = "suspend" is rejected because the Kubernetes backend cannot suspend processes:

Error: Invalid organisation specification

execution_profiles.interactive.idle_policy: backend "kubernetes" does not support
"suspend" (requires feature "process_suspend"); refusing to substitute a weaker policy

4. Connections

A connection says how to reach a service. It holds a reference to credentials, never the credentials themselves. k8s:<name> names a Secret in the control-plane namespace, which only the platform service can read. A connection on its own grants no seat any access.

    connections = {
      slack = {
        adapter      = "slack"
        account_id   = "T0FAKE"                    # Slack workspace (team) ID
        secret_ref   = "k8s:slack-credentials"
        endpoint_ref = "http://steadmesh-fakes.steadmesh-system.svc:8090"   # omit for real Slack
      }
      linear = {
        adapter    = "linear"
        secret_ref = "k8s:linear-credentials"
        config     = { team_id = "TEAM-FAKE" }
        endpoint_ref = "http://steadmesh-fakes.steadmesh-system.svc:8091"
      }
    }

Connections are required by default: the organisation does not report ready until each one has authenticated. If you put a raw token in secret_ref, the plan is rejected.

5. Memory and seats

Declare the memory stores first, then the seats. A seat names its personal store in personal_memory, which implicitly gives it full personal-memory operations on that store and nothing else.

    memory_stores = {
      organisation = {}
      engineering  = {}
      rep_sean     = {}
      eng_lead     = {}
      reviewer     = {}
    }

    seats = {
      representative_sean = {
        role_ref          = module.representative_role.ref
        display_name      = "Sean's representative"
        harness_profile   = "primary"
        execution_profile = "interactive"
        sandbox_profile   = "standard"
        personal_memory   = "rep_sean"
        workspace         = { persistent = true }
      }
    }

The seat key (representative_sean) is its stable name. The platform assigns each seat an immutable ID the first time it sees the seat. Changing the display name, instructions or image keeps that identity. Removing a seat retires it: it first finishes its current turn, saves a handoff and hands over its work (see Retiring a seat), and its data is retained by default. A new seat that reuses the same key does not inherit the old private data unless it names the old seat in adopt_from.

6. Teams and templates

A template is reusable team configuration. Templates can extend each other, and roles are defined on the template. A concrete team instantiates a template, and seats join teams by listing them; the team itself has no member list.

    team_templates = {
      base = {
        instruction_refs = [module.base_guidance.ref]
      }
      engineering = {
        extends          = "base"
        instruction_refs = [module.engineering_guidance.ref]
        roles = {
          engineering_lead = module.lead_role.ref
          reviewer         = module.reviewer_role.ref
        }
        shared_memory = { engineering = ["read", "search", "write", "revise"] }
      }
    }

    teams = {
      engineering = { template = "engineering" }
    }

Add the team's seats. role:<name> picks a role from the templates of the seat's teams:

      eng_lead = {
        role_ref = "role:engineering_lead"
        teams    = ["engineering"]
        harness_profile = "primary", execution_profile = "interactive", sandbox_profile = "standard"
        personal_memory = "eng_lead"
      }
      reviewer = { role_ref = "role:reviewer", teams = ["engineering"], personal_memory = "reviewer" }  # plus the same three profiles

Merge rules are explicit:

The provider resolves templates before writing the organisation, so the controller only ever sees concrete configuration.

The modules modules/team-base, modules/team-engineering and modules/team-accounting package these templates with their bundles.

7. Grants

Grants are the only way a seat gains capabilities beyond its own personal memory. Each grant names a subject (seat: or team:), a resource (connection:, memory: or workspace:) and a list of operations.

    grants = {
      engineering_linear = {
        subject    = "team:engineering"
        resource   = "connection:linear"
        operations = ["project.create", "task.write"]
      }
      sean_reads_org_memory = {
        subject    = "seat:representative_sean"
        resource   = "memory:organisation"
        operations = ["read", "search"]
      }
    }

Only three permissions are implicit, and each is listed in the seat's resolved capability manifest with an implicit: source:

8. Message routes

Routes say who may message whom. With reply = true, the recipient may reply within conversations opened on that route. That does not let the recipient start new conversations with the sender.

    message_routes = {
      sean_to_lead    = { from = "seat:representative_sean", to = "seat:eng_lead", reply = true }
      lead_reviewer   = { from = "seat:eng_lead", to = "seat:reviewer", bidirectional = true }
    }

The message graph is separate from Terraform's dependency graph, so two-way communication never creates a Terraform dependency cycle.

9. Channel bindings

A channel binding connects a verified external identity to a representative. Incoming Slack events are authorised by the verified workspace and user IDs. A name typed inside the message text is never trusted.

    channel_bindings = {
      sean = {
        connection       = "slack"
        external_user_id = "U0SEAN"
        seat             = "representative_sean"
      }
    }
  }   # end of spec

Several people can share one Slack app installation. Each person gets their own representative and a private history that other representatives cannot read.

10. Plan

terraform plan    # from source: make -C examples plan-organisation

The plan validates the whole organisation before anything is applied. Errors point at exact attributes:

Error: Invalid organisation specification

  with steadmesh_organization.this,
  on main.tf line 88, in resource "steadmesh_organization" "this":

seats.reviewer.personal_memory: contradictory ownership: store "rep_sean" is
already the personal memory of seat "representative_sean"

A valid plan shows resolved_seats: each seat's resolved role, teams and configuration revision. When you change a shared template or culture bundle, these revisions show exactly which seats the change affects.

11. Apply and verify

terraform apply   # from source: make -C examples apply

terraform apply returns only when the current generation is OperationalReady. That means storage, identities, harness compatibility, sandbox enforcement, connections, Slack ingress, human bindings and a synthetic probe of every seat have all passed. To repeat these checks even when Terraform has nothing to change, run orgctl verify. make -C examples apply does this after every apply. That way a broken integration is caught on every deploy.

12. Follow a request

When Sean messages the bot, the platform does the following:

  1. The Slack adapter receives the event over Socket Mode. The platform maps the verified team and user IDs to the sean binding, stores the message durably and only then acknowledges it to Slack. A replayed event is recognised by its event ID and is not processed twice.
  2. The message lands in representative_sean's inbox. If the seat is stopped, the controller wakes it within a few seconds.
  3. The seat runner takes the message under an execution lease and hands it to the harness with its bootstrap context: identity, instructions, available tools and recovery state.
  4. The agent uses tools: it searches memory, then messages.sends the engineering lead over the declared route. The lead's harness calls connections.invoke → linear → project.create. The gateway checks the grant, records the operation in its ledger and calls Linear.
  5. The lead's reply returns to the representative, correlated to the original request. The representative replies to Sean through the outbox and Slack.

13. Change something

Give the reviewer the ability to comment in Linear:

      reviewer_comments = {
        subject    = "seat:reviewer"
        resource   = "connection:linear"
        operations = ["comment.write"]
      }

The plan shows the reviewer's configuration revision changing and nothing else. On apply, the new policy takes effect for the reviewer's next tool call. The seat restarts at a safe point, after any current turn finishes. Its identity, memory and workspace stay the same, and each execution records which revision it used.

What survives what

EventKept
Seat goes idle and stopsEverything: workspace, memory, history, inbox. Process state does not survive; the harness session is restored from its checkpoint.
Seat Pod crashes or is deletedEverything. The old process is fenced out before the new one takes over, so there is never more than one writer.
Instructions, image or harness changeIdentity, memory, workspace, history. When a native harness session cannot be converted, the seat resumes from a portable handoff and says so.
Seat removed from the declarationIts data, retained by default. Capabilities are revoked. Reuse needs explicit adopt_from.
Organisation destroyed with data_retention = "retain"Database records and workspace volumes. External Slack workspaces and Linear projects are never deleted.