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 loginCheck 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 jsonIf 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 yamlUse 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, orlongterm
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
specinputs - Cross-module
inputstyped 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>, thenraptor 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: standardintent, 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 examplesensitive(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:
- awsThis 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.
Module anatomy
This section explains the building blocks of a Facets module. You’ll learn how modules declare what they offer, what they require, and how they connect with others using a shared type system.
Generate Modules via AI
Use the Facets Module Generation MCP server from your AI coding host to turn a module concept into a working, standards-aligned Terraform module for Facets in minutes.