Skip to content

Developing Molds

This guide walks you through the full workflow for building, testing, and publishing molds. If you haven’t read the mold reference yet, start with Creating Molds for the YAML schema and Forge Actions for the full action catalog.

Before you start:

  • Shoehorn CLI installed (shoehorn version works)
  • Authenticated to your Shoehorn instance (shoehorn auth login)
  • A GitHub repository connected to Shoehorn (the crawler discovers molds from .shoehorn/molds/)
  • Forge GitHub App configured for repository creation, file writes, pull requests, and access changes
Terminal window
# 1. Create the molds directory in your repo
mkdir -p .shoehorn/molds
# 2. Write a mold
cat > .shoehorn/molds/create-repo.yaml << 'EOF'
version: "1.0.0"
metadata:
name: create-repo
displayName: Create Repository
description: Create a new GitHub repository
author: my-team
icon: git-branch
category: repository
tags:
- github
inputs:
type: object
required:
- name
- owner
properties:
name:
type: string
title: Repository Name
pattern: "^[a-zA-Z0-9._-]+$"
owner:
type: string
title: GitHub Organization
private:
type: boolean
title: Private Repository
defaults:
private: true
actions:
- action: github.repo.create
label: Create Repository
primary: true
steps:
- id: create-repo
name: Create GitHub Repository
action: github.repo.create
inputs:
name: "${{ inputs.name }}"
owner: "${{ inputs.owner }}"
private: "${{ inputs.private }}"
output:
links:
- title: Repository
url: "${{ steps.create-repo.output.html_url }}"
EOF
# 3. Validate the mold
shoehorn validate mold .shoehorn/molds/create-repo.yaml
# 4. Commit and push (crawler will discover it)
git add .shoehorn/molds/create-repo.yaml
git commit -m "feat: add create-repo mold"
git push
# 5. Test with dry-run (no real resources created)
shoehorn forge execute create-repo \
--input name=test-repo --input owner=my-org --dry-run
# 6. Execute for real
shoehorn forge execute create-repo \
--input name=my-new-service --input owner=my-org

Create a .yaml file under .shoehorn/molds/ in any repository connected to Shoehorn. The crawler watches for files matching .shoehorn/molds/**/*.{yaml,yml,json}.

Start with the minimal structure:

version: "1.0.0"
metadata:
name: my-mold # unique slug -- this becomes the mold identifier
displayName: My Mold
description: What it does
author: your-team
icon: server # Lucide icon name
category: repository # repository, service, infrastructure
tags: []
inputs:
type: object
required: []
properties: {}
actions:
- action: my.action.id
label: Run
primary: true
steps: []

Then add inputs, steps, and actions as needed.

Before pushing, run the offline validator:

Terminal window
shoehorn validate mold .shoehorn/molds/my-mold.yaml

This checks:

  • YAML syntax is valid
  • Required fields are present (version, metadata.name, steps or actions)
  • Each step has id, name, and either action or adapter
  • Action IDs follow the provider.resource.operation format
  • Adapter names are in the valid set
  • Approval flow constraints (if defined)
  • Input schema structure

Fix any errors before pushing.

Step 3: Push and let the crawler discover it

Section titled “Step 3: Push and let the crawler discover it”

Commit and push. The crawler indexes .shoehorn/molds/ files from connected repositories. Once discovered, the mold appears in Forge within the next crawl cycle (usually a few minutes).

Terminal window
git add .shoehorn/molds/my-mold.yaml
git commit -m "feat: add my-mold"
git push

Check that the crawler found it:

Terminal window
shoehorn forge molds list
shoehorn forge molds get my-mold

Dry-run validates inputs and simulates execution without creating real resources:

Terminal window
# Via CLI
shoehorn forge execute my-mold \
--input name=test --input owner=my-org --dry-run
# Via UI: toggle "Dry Run" in the run form

Dry-run checks:

  • All required inputs are provided
  • Input types match the schema
  • The mold and action exist
  • Idempotency key handling

It does not execute Forge actions or make GitHub API calls.

Run for real against a test org or with disposable resources:

Terminal window
shoehorn forge execute my-mold \
--input name=test-deleteme --input owner=my-org

Watch the run:

Terminal window
shoehorn forge run watch <run-id>

Check the result:

Terminal window
shoehorn forge run get <run-id>
shoehorn forge run get <run-id> -o json | jq '.outputs'

The documented Forge actions are idempotent, so it’s safe to re-run the same mold with the same inputs.

Edit the YAML, push again, and the crawler updates the mold. The version field is informational — the crawler upserts by metadata.name (slug).

Common iteration patterns:

  • Add more steps (files, topics, team access)
  • Refine input validation (patterns, enums, min/max)
  • Add conditional fields with x-dependency
  • Add approval flow for production molds
  • Add catalog.entity.register to auto-register the created resource

Choosing between action and workflow steps

Section titled “Choosing between action and workflow steps”
Use Forge action steps (action:) when…Use workflow steps (adapter:) when…
All operations are on GitHubYou need HTTP calls to external APIs
Sequential execution is fineYou need explicit depends_on ordering
You want idempotent actionsYou need conditional execution (condition:)
You want the simplest YAMLYou need catalog lookups or logging

You can tell which format a mold uses by looking at the steps — action: means Forge action steps, adapter: means workflow steps. Don’t mix them in the same mold.

SyntaxDescription
${{ inputs.name }}User-provided input value
${{ parameters.name }}Alias for inputs
${{ steps.step-id.output.field }}Output from a previous step
${{ context.createdBy }}Email of the user who triggered the run
${{ context.tenantId }}Tenant/organization ID
${{ context.runId }}Unique run ID
${{ context.moldSlug }}Slug of the mold being executed
${{ secrets.KEY }}Secret value (blocked in sandboxed mode)
${{ env.KEY }}Environment variable (blocked in sandboxed mode)

Type preservation: "${{ inputs.private }}" as the entire value preserves the boolean. Mixed text like "repo-${{ inputs.name }}" coerces to string.

inputs:
type: object
required:
- name # user must fill this in
properties:
name:
type: string
title: Name
description: # optional -- not in required list
type: string
title: Description
goVersion:
type: string
title: Go Version
enum: ["1.23", "1.24", "1.25"]
name:
type: string
title: Service Name
pattern: "^[a-z0-9-]+$"
minLength: 1
maxLength: 63
ui:options:
placeholder: my-service

Show a field only when another field has a certain value:

enableNotifications:
type: boolean
title: Enable Notifications
slackChannel:
type: string
title: Slack Channel
x-dependency:
condition: enableNotifications == true
defaults:
goVersion: "1.24"
private: true
replicas: 2

Defaults are pre-filled in the UI form and applied automatically by the CLI.

Create test repos with a naming convention you can clean up later:

Terminal window
shoehorn forge execute my-mold \
--input name=test-deleteme-$(date +%s) --input owner=my-org

The documented Forge actions are idempotent. Running the same mold twice with the same inputs returns the existing resources instead of failing. Use this to verify your mold is safe to retry.

Terminal window
shoehorn forge run get <run-id> -o json | jq '.outputs'

After a successful run, check:

  • Repository was created with correct settings
  • Files were committed with correct content
  • Topics were applied
  • Team/collaborator access was set
  • Check the file is under .shoehorn/molds/ (not .shoehorn/mold/ or another path)
  • Check the file extension is .yaml, .yml, or .json
  • Wait for the next crawler cycle (check crawler health: curl http://localhost:8086/health)
  • Check crawler logs for parse errors on your file

Your mold needs at least one entry in actions: with a matching step. If the CLI can’t auto-detect the primary action, pass --action explicitly.

The CLI validates required inputs before creating a run. Pass all required fields via --input key=value.

Check the action ID uses dot notation (github.repo.create, not github:repo:create). The engine converts dots to colons internally.

  • Check the syntax: ${{ inputs.name }} (with spaces inside braces)
  • Check the namespace: inputs or parameters for user values, steps.id.output.field for step outputs
  • Unresolved expressions are left as literal strings — look for ${{ in your run outputs

If your mold has approvalFlow.required: true, runs enter pending_approval status. Approvers must approve in the Forge UI before execution begins.