Manually using Raptor
Build a Facets module by hand with the Praxis CLI: define the facets.yaml contract, write the Terraform against the standard variables, then validate, upload, and publish.
This is the manual authoring loop with raptor. Prefer to let an agent do it for you? See Build through Praxis.
Before you write anything
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 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. See Module anatomy for what each part means. Settle the contract 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.
Sign in once. praxis login covers raptor too, and raptor is what reads and writes the control plane.
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 Repo before you start: authoring outside the repo can be rejected 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.
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 workflow
1. Plan the capability
Model a reusable capability, like provisioning an S3 bucket. Decide what developers should control and what should be opinionated logic. For example, an S3 bucket module might let developers set a bucket name, choose public or private, and pick a lifecycle policy from simple enums (standard, short, longterm). The module translates those internally (short = 30 days, standard = 90 days, longterm = 365 days), which is organizational context and abstraction in action: consistent, and simpler for the consumer than raw retention periods.
2. Define the contract (facets.yaml)
facets.yaml is the module's single source of truth for configuration and wiring. It carries intent, flavor, version, and cloud provider; the developer-facing spec; cross-module inputs typed via @facets/... or @custom/...; and structured outputs.
Scaffold and edit the contract rather than writing it by hand:
# Creates facets.yaml, variables.tf, main.tf and outputs.tf
raptor module init --intent <intent> --flavor <flavor> --version <version>
# Add a spec field. --title is the UI label and is required on every field
raptor module set-spec --add bucket_name --type string --title "Bucket Name"
# Wire an input from another module, and declare an output
raptor module add-input --name cluster --type "@facets/kubernetes-details"
raptor module add-output --name bucket --type "@custom/s3-bucket"
# Check it against the contract and the control plane before uploading
raptor module validateraptor module show prints the contract as it stands. remove-input and remove-output undo the two above, and --ui-order on set-spec places a field in the form rather than appending it.
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. Individual fields like 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 consumers know what they are getting.
3. Write the Terraform logic
Your Terraform must only use the standard variables the Facets engine injects. 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, a flat key-value map where each key is an output field other modules can consume. Optionally define output_interfaces if your module exposes an interface developers can connect to, such as a Postgres reader or writer (with 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 with 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. 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 that includes a usable aws provider configuration. The other side of that relationship is a module that provides the provider: a module creating a cloud account or a Kubernetes cluster exposes one on an output, which downstream modules consume:
raptor module set-output-provider --output cloud_account --provider awsThe command maps provider config attributes onto an existing output; pass no provider to remove one. raptor module discover --project-type AWS shows which modules in a project type already expose providers and how the chain fits together, worth running before you build. This approach lets each building block independently receive the provider it needs, and enables gradual upgrades: not every module has to move to a new provider version at once.
6. Preview, upload, and publish
Check the form a developer will see before the module goes near the control plane:
raptor module preview .It renders the spec with the real Control Plane form engine in headless Chrome and writes a PNG plus a JSON report. Exit code 0 means it rendered cleanly, 1 a render error, 2 a tool failure such as an invalid facets.yaml. See Form UI for what the report contains.
Then validate, upload, and publish:
raptor module validate
# Uploads at the PREVIEW stage, where only a test project can use it
raptor create iac-module -f .
# PREVIEW to PUBLISHED, once you are happy with it
raptor publish iac-module <intent>/<flavor>/<version>A module stays at PREVIEW until you publish it. See Module Registry for what the registry shows at each stage, and Versioning for when a change needs a new version rather than an update to this one.
Build through Praxis (AI Agent)
Build a Facets module by describing it to your AI coding agent. Signing in installs the module skills, and the agent scaffolds, validates, adds actions, and publishes for you.
Form UI (X-UI Tags)
Reference for the x-ui-* extension fields in facets.yaml that shape module config forms: conditional display, dynamic dropdowns, layout, and validation.