Docs

Building a Facets Module

This section explains how to turn a module concept into a working Facets module. You'll define the module interface using `facets.yaml`, connect it to Terraform logic, and use the Facets CLI to scaffold and validate your work.

Before you write anything

A few things are worth knowing before you open an editor.

You are usually changing a module, not creating one. Importing a project type gives you the official modules for your cloud. When you need a capability that is close to one you already have, start from that module and change it. Author from scratch only when nothing in your catalogue is close.

You build against a contract. A module's contract is its facets.yaml: the spec developers configure, the inputs it consumes from other modules, and the outputs it exposes. The contract is the part other modules depend on, so it is worth settling before the Terraform. Define the output type first, then write the module that satisfies it, and consumers can be wired up in parallel by someone else.

You do this from your terminal, with an AI agent if you want one. Sign in with praxis login and the Facets module skills are installed into your AI host automatically, so an agent working in your module directory already knows the contract rules, the validation steps and the publish flow. Nothing needs to be cloned or browsed by hand: raptor reads and writes the control plane, and praxis supplies the agent skills.

praxis login
raptor login

Check whether your org has a modules repository, because it decides where you work. An organisation can link one git repository to its control plane as the canonical home of its custom modules. When that link exists, you author in a branch of that repo and CI publishes on merge, rather than uploading from a scratch directory:

raptor get modules-repo -o json

If that returns a repository, read Modules repository before you start: authoring outside the repo can be rejected outright depending on the enforcement mode. If it returns nothing, your org is not linked and you upload directly, which is what the rest of this page describes.

Prefer MCP?

The Module Generation MCP server is an alternative for AI hosts that connect over MCP rather than reading skills from disk.

Defining the contract first

An output type is the contract between a module that produces something and every module that consumes it. It is a JSON Schema with attributes (data fields the module produces, such as IDs and endpoints) and interfaces (connection contracts).

Write the schema, register it, then reference it from the module:

raptor module create-output-type @custom/gcp_project -f ./gcp_project.yaml
raptor module get-output-type @custom/gcp_project -o yaml

Use raptor module get-output-type on an existing type to see a working example before writing your own.

Once the type exists, a module binds an output to it and consumers declare an input of the same type. That is what lets two teams build either side independently: agree the contract, then implement against it.


The module development workflow

We follow the recommended Facets module development workflow:

1. Plan the Capability

The goal is to model a reusable capability, like provisioning an S3 bucket. You should define what parts of the configuration developers should control, and what should be embedded as opinionated logic.

For example, this S3 bucket module lets developers:

  • Set a bucket name
  • Choose whether it is public or private
  • Select a lifecycle policy using simple enums: like standard, short, or longterm

These enums are an example of organizational context and abstraction. Instead of asking developers to input raw retention periods, the module translates these values internally (e.g., short = 30 days, standard = 90 days, longterm = 365 days). This ensures consistency and simplifies configuration for consumers.


2. Define the Module Contract (facets.yaml)

Use facets.yaml to define your module’s public interface and metadata. This includes:

  • intent, flavor, version, and cloud provider
  • Developer-facing spec inputs
  • Cross-module inputs typed via @facets/... or @custom/...
  • Structured outputs (bucket, policies, etc.)

This file serves as the module's single source of truth for configuration and wiring.

Use the Facets CLI (raptor) to scaffold and validate this YAML: raptor module init --intent <intent> --flavor <flavor> --version <version>, then raptor module validate.

intent: aws-s3-bucket
flavor: secure-bucket
version: '1.0'
description: Provision a secure S3 bucket with lifecycle and access policies.
clouds:
  - aws
intentDetails:
  type: Cloud & Infrastructure
  description: A secure S3 bucket with lifecycle and access policies.
  displayName: AWS S3 Bucket
  iconUrl: https://raw.githubusercontent.com/Facets-cloud/facets-modules-redesign/main/icons/s3.svg
spec:
  title: S3 Bucket Settings
  description: Inputs to configure bucket behavior.
  type: object
  properties:
    bucket_name:
      type: string
      title: Bucket Name
    is_public:
      type: boolean
      title: Make Public
      default: false
    lifecycle_policy:
      type: string
      title: Lifecycle Policy
      enum: [standard, short, longterm]
      default: standard
  required:
    - bucket_name

inputs:
  cloud_account:
    type: "@facets/aws_cloud_account"
    providers:
      - aws
outputs:
  default:
    type: "@custom/secure_bucket"
    title: Secure S3 Bucket

sample:
  kind: aws-s3-bucket
  flavor: secure-bucket
  version: "1.0"
  disabled: false
  spec:
    bucket_name: my-bucket
    is_public: false
    lifecycle_policy: standard

intent, flavor and version identify the module, and raptor create iac-module will not accept a facets.yaml without them. sample is also validated on upload: omitting it fails with required field 'sample' is missing, and its sample.spec must carry a value for every field listed under required. intentDetails supplies the catalog metadata the Control Plane shows for the intent; omitting it is reported as Warning: 'intentDetails' field is missing in facets.yaml, not as a hard failure.

A module declares named outputs, and each name is bound to exactly one output type. The individual fields, bucket_name, read_policy and write_policy, are not separate outputs: they live in the attributes schema of the @custom/secure_bucket output type. Create that type once with raptor module create-output-type, then every module that produces a secure bucket declares the same type and the consumers know what they are getting.


3. Write the Terraform Logic

Your Terraform module must only use the standard variables injected by the Facets engine. These map directly to your facets.yaml:

variable "instance" {
  description = "Developer-supplied configuration."
  type = object({
    kind    = string
    flavor  = string
    version = string
    spec = object({
      bucket_name       = string
      is_public         = bool
      lifecycle_policy  = string
    })
    metadata = any
  })
}

variable "instance_name" {
  description = "Globally unique resource name."
  type        = string
}

variable "environment" {
  description = "Environment metadata."
  type = object({
    name        = string
    unique_name = string
    namespace   = string
    cloud_tags  = optional(map(string), {})
  })
}

variable "inputs" {
  description = "Cross-module inputs."
  type = object({
    cloud_account = object({
      region = string
    })
  })
}

Do not define additional input variables.

raptor module init generates variables.tf for you from facets.yaml, and raptor create iac-module validates that the two stay consistent. Let the CLI own this file rather than hand-editing it.


4. Generate Outputs via Locals

Facets modules expose outputs using the output_attributes object. This is a flat key-value map where each key represents an output field that can be consumed by other modules.

You may optionally define output_interfaces if your module exposes an interface that developers can connect to, such as a Postgres reader or writer, which typically includes standard attributes like url, user, and password.

locals {
  output_interfaces = {}

  output_attributes = {
    bucket_name       = aws_s3_bucket.this.bucket
    read_policy       = aws_iam_policy.read_policy.policy
    write_policy      = aws_iam_policy.write_policy.policy
  }
}

🔐 Mark sensitive outputs using the sensitive(...) wrapper, for example sensitive(aws_s3_bucket.this.bucket). Never expose secrets as plain text: marking them sensitive keeps them out of logs and the UI.


5. Providers

Facets modules do not define provider blocks internally. Instead, they consume providers through upstream modules using typed inputs.

To declare that your module depends on a provider, use the providers key inside the inputs: block in facets.yaml:

inputs:
  cloud_account:
    type: "@facets/aws_cloud_account"
    providers:
      - aws

This declares that the module expects a cloud_account input which includes a usable aws provider configuration.

This approach allows each building block to independently receive and use the provider it needs. It also enables gradual upgrades, not every module must migrate to a new provider version at the same time.