Manifest Reference

The Agent Environment Spec (AES) manifest is a declarative definition of a VM environment. It specifies the platform, source repositories, setup commands, services, health checks, commands, and agent configuration. Public API VMs boot from environments; store the manifest on an environment, then create VMs or sessions from that environment.

Wallfacer uses manifests at every level: base images, environment snapshots, and session boot all derive from the same format.

JSON Schema

The manifest is formally described by a JSON Schema (Draft 2020-12). The public API validates every submitted manifest against it, then applies additional API checks such as environment permissions, snapshot state, and missing secret references.

Stable URL

https://wallfacer.dev/schemas/aes/v1.json

This URL is permanent for the lifetime of version 1. Backwards-compatible additions edit the file in place. Breaking changes publish at a new URL (v2.json, and so on).

Editor integration

Point your editor at the schema URL and you get completion, validation, and inline docs while you write the manifest.

VS Code YAML (via the Red Hat YAML extension), .vscode/settings.json:

{
  "yaml.schemas": {
    "https://wallfacer.dev/schemas/aes/v1.json": ["agent-env.yml", "agent-env.yaml"]
  }
}

VS Code JSON (.vscode/settings.json):

{
  "json.schemas": [
    {
      "fileMatch": ["agent-env.json"],
      "url": "https://wallfacer.dev/schemas/aes/v1.json"
    }
  ]
}

JetBrains IDEs support the same schema via Preferences > Languages & Frameworks > Schemas and DTDs > JSON Schema Mappings.

Minimal Manifest

The smallest schema-valid manifest specifies the version. In practice, set platform.os explicitly so the environment is portable and obvious:

{
  "version": 1,
  "platform": {
    "os": "linux/amd64"
  }
}

This boots a Linux VM with default resources, no repositories, no setup, and no services beyond the automatically injected agent-api.

Full Schema

version

FieldTypeRequiredDefaultDescription
versionintegeryes--Manifest schema version. Currently 1.

platform

Declares the compute platform and resource requirements.

FieldTypeRequiredDefaultDescription
osstringnoserver defaultTarget OS. linux/amd64 or darwin/arm64. Set this explicitly for predictable environments.
memoryintegerno8192Memory in MB. Set automatically by the server; cannot be overridden by users.
vcpusintegerno2Virtual CPU count. Set automatically by the server; cannot be overridden by users.
gpubooleannofalseReserve GPU access. Set automatically by the server; cannot be overridden by users.
platform:
  os: darwin/arm64

image

Optional base image and OS tuning.

FieldTypeRequiredDefaultDescription
basestringnoplatform defaultBase image identifier, including a version tag.
system.file_descriptor_limitintegernobase image defaultMax open file descriptors per process.
system.spotlightbooleanno--Disable Spotlight indexing. macOS only.
system.tcc.apple_eventsarray of stringsno[]Binaries pre-authorized to send AppleEvents. macOS only.
system.tcc.accessibilityarray of stringsno[]Binaries pre-authorized for accessibility automation. macOS only.
system.localestringnoen_US.UTF-8System locale.

sources

Git repositories to clone into the environment. Always an array, even for a single repo. Each source is cloned during snapshot construction; boot-time source reconciliation is managed by the platform.

The first entry's workspace is the primary workspace (default working directory for commands and Claude Code).

The deprecated source field accepts one source object as shorthand. Prefer sources for new manifests.

FieldTypeRequiredDefaultDescription
repostringyes--Git repository. Short form (owner/repo) or full URL (github.com/owner/repo).
authstringnotokenAuthentication method: token or ssh-key. Credentials are injected at session time, never stored in the manifest.
workspacestringno{root}/{reponame}Absolute path where the repo is cloned. Defaults to /workspace/{reponame} (Linux) or /Users/admin/workspace/{reponame} (macOS).
branchstringnomainDefault branch for snapshot builds.
clone_strategystringnoshallowshallow (--depth 1 --single-branch, fast) or full (complete history). Use full only if the agent needs git log or blame.
identity.namestringno--Git commit author name.
identity.emailstringno--Git commit author email.
sources:
  - repo: github.com/acme/webapp
    workspace: /Users/admin/workspace
    branch: main
    auth: token
    clone_strategy: shallow
    identity:
      name: Wallfacer Agent
      email: [email protected]

Multi-repo example:

sources:
  - repo: github.com/acme/frontend
    workspace: /workspace/frontend
    branch: main
  - repo: github.com/acme/api
    workspace: /workspace/api
    branch: develop

Each source is cloned into its own workspace path. The first source's workspace is the primary working directory for commands and agent interaction unless a command or service sets working_dir.

env

Global environment variables available to all setup commands, services, and named commands.

FieldTypeRequiredDefaultDescription
(key)string----Variable name mapped to its value.

${secrets.KEY} references are valid in any string field anywhere in the manifest and are substituted by the platform before the manifest is delivered to your VM. See Secret references for the full contract, including how missing secrets are rejected.

Setup commands can emit ::set-env name=VALUE on stdout to publish ${VAR} values that persist across subsequent setup commands and services.

env:
  NODE_ENV: development
  DATABASE_URL: postgres://user:${secrets.DB_PASSWORD}@localhost:5432/app
  NPM_TOKEN: ${secrets.NPM_TOKEN}

setup

Sequential commands executed during snapshot construction. Each must complete (exit 0 or pass its healthcheck) before the next runs. Setup commands are baked into the snapshot and not re-run on session restore.

FieldTypeRequiredDefaultDescription
namestringyes--Human-readable label.
runstringyes--Shell command (executed via bash -c).
working_dirstringnofirst source workspaceWorking directory.
envobjectno{}Command-specific env vars (merged with global env).
retriesintegerno2Retry count on failure.
healthcheckobjectno--Verification after command completes. See Health Check Format.
setup:
  - name: Install project dependencies
    run: npm install
    retries: 2
    healthcheck:
      command: test -d node_modules
      retries: 3

  - name: Build project
    run: npm run build

On Linux, setup commands are automatically wrapped with sudo -E bash -c '...' so they have access to package management. On macOS, sudo is not used because Homebrew refuses to run under sudo.

services

Long-running background processes supervised by the in-VM agent. Services are launched concurrently but respect depends_on ordering via topological sort. Each service waits for its dependencies' health checks to pass before starting.

Services do not survive snapshot/restore. They are killed and restarted fresh on every boot.

FieldTypeRequiredDefaultDescription
namestringyes--Unique service name.
runstringyes--Shell command to start the service.
working_dirstringnofirst source workspaceWorking directory.
envobjectno{}Service-specific env vars (merged with global env).
depends_onarray of stringsno[]Service names that must be healthy before this one starts.
portsarray of objectsno[]Ports to expose. See Port Definitions.
healthcheckobjectno--Readiness probe. See Health Check Format.
services:
  - name: database
    run: docker compose up postgres
    healthcheck:
      url: tcp://localhost:5432

  - name: dev-server
    run: npm run dev
    depends_on: [database]
    ports:
      - port: 3000
        name: web
        protocol: http
    healthcheck:
      url: http://localhost:3000/health

commands

Named, reusable command blocks. Commands can be invoked by the platform in these ways:

  1. Lifecycle -- via the ready array (runs after services are healthy, every boot)
  2. HTTP API -- via the in-VM command API
  3. Platform tooling -- for diagnostics and infrastructure operations

Every command execution fires webhook events (command.started, command.completed, command.failed).

FieldTypeRequiredDefaultDescription
(key)stringyes--Command name (the YAML/JSON key). Must be unique. Used in the ready array and API calls.
runstringyes--Shell command (executed via bash -c).
working_dirstringnofirst source workspaceWorking directory.
envobjectno{}Command-specific env vars (merged with global env).
timeoutdurationno30mMaximum execution time.
retriesintegerno1Retry count on failure.
commands:
  migrate:
    run: npm run db:migrate
    working_dir: /workspace
  seed:
    run: npm run db:seed
    working_dir: /workspace
  reset-db:
    run: npm run db:reset && npm run db:seed
    working_dir: /workspace
    timeout: 5m

ready

An array of command names from the commands block. These run sequentially after all services are healthy, on every boot (both fresh build and snapshot restore).

Use ready for idempotent operations that depend on running services: database migrations, cache clearing, dependency updates that catch drift from code changes since the snapshot was built.

commands:
  migrate:
    run: npm run db:migrate
    working_dir: /workspace
  seed:
    run: npm run db:seed
    working_dir: /workspace

ready: [migrate, seed]

Heavy installation belongs in setup (baked into the snapshot). The ready array is for lightweight operations that should re-run every session.

simulators

iOS/Android simulator configuration. Only valid when platform.os is darwin/arm64. Each entry boots a simulator, starts a capture service, and optionally registers build/run commands.

FieldTypeRequiredDefaultDescription
platformstringyes--Simulator platform: ios (future: android).
devicestringnoserver defaultDevice name (e.g. iPhone 17 Pro Max). Set by the server; users cannot override.
runtimestringnolatestOS version (e.g. 26.2 for iOS 26). Set by the server; users cannot override.
bundle_idstringno--App bundle ID. Used to verify the app launched in the simulator. Exposed as $IOS_BUNDLE_ID.
buildstringno--Compile, install, and launch script (Swift, native). Generates a build-ios command.
runstringno--Start a dev server pointed at the simulator (Expo). Generates a run-ios command.

What the platform does with each entry:

  1. Boots the simulator via xcrun simctl
  2. Starts SimulatorCapture as a supervised service on port 8000
  3. Exposes $IOS_SIMULATOR_UDID and $IOS_BUNDLE_ID as env vars
  4. If build is set, registers a build-ios command (re-triggerable mid-session)
  5. If run is set, registers a run-ios command
  6. If bundle_id is set, waits for the app to appear in the simulator (2-minute timeout)
  7. Registers the simulator MCP server in Claude Code settings
# Swift project (compile + install + launch):
simulators:
  - platform: ios
    bundle_id: com.acme.app
    build: |
      xcodebuild -project App.xcodeproj -scheme App \
        -destination "id=$IOS_SIMULATOR_UDID" build
      xcrun simctl install $IOS_SIMULATOR_UDID \
        .build/Build/Products/Debug-iphonesimulator/App.app
      xcrun simctl launch $IOS_SIMULATOR_UDID com.acme.app
# Expo project (dev server pointed at simulator):
simulators:
  - platform: ios
    bundle_id: com.acme.app
    run: npx expo run:ios --device $IOS_SIMULATOR_UDID --no-bundler

iOS simulator and its commands run asynchronously after the VM is declared ready, so they do not block session start.

ios

Deprecated single-simulator block kept for older manifests. Prefer simulators for new manifests.

FieldTypeRequiredDefaultDescription
devicestringnoserver defaultDevice model name.
runtimestringnoserver defaultiOS runtime version.
headlessbooleannotrueRun the simulator in headless mode.
bundle_idstringno--App bundle identifier.
buildstringno--Build, install, and launch script.
timeoutdurationno30mMax build duration.
portsarrayno[]Ports exposed by the simulator capture service.

agent

Configures Claude Code inside the VM: the instruction files it loads.

FieldTypeRequiredDefaultDescription
instructionsarray of stringsno[]Paths to instruction files relative to the workspace (e.g. .claude/CLAUDE.md). Written to Claude Code's settings.

Claude Code loads the tool definitions from these servers on demand. It starts a turn knowing the tool names and fetches a tool's full schema before calling it, instead of carrying every schema in context from the first message. An environment that registers several MCP servers therefore leaves more of the context window for the work. The agent still reaches every tool declared here; only the moment the schema is read changes. The platform's own built-in tools stay loaded up front, so nothing you declare here delays them.

agent:
  instructions:
    - .claude/CLAUDE.md
    - docs/ARCHITECTURE.md

MCP servers are not declared here. They are a field on the environment, mcp_servers, edited in the app or through the API, and changing them never regenerates the snapshot. See Environments for the three kinds and Environments and Secrets for the API shape.

When simulators are configured, the platform auto-registers a simulator MCP server pointing to SimulatorCapture.

callbacks

Webhook event filtering. Optional. If omitted, the platform uses the WEBHOOK_URL and WEBHOOK_TOKEN environment variables (injected per-session by the platform).

The manifest callbacks section takes precedence over env vars when set.

FieldTypeRequiredDefaultDescription
urlstringno--Webhook endpoint URL. Supports ${secrets.KEY} substitution.
authstringno--Authorization header value (e.g. bearer ${secrets.WEBHOOK_TOKEN}). Supports ${secrets.KEY} substitution.
eventsarray of stringsnoallEvent types to send. If omitted, all events are sent.
callbacks:
  url: ${WEBHOOK_URL}
  auth: bearer ${WEBHOOK_TOKEN}
  events:
    - environment.ready
    - environment.failed
    - command.completed
    - command.failed

secrets

Secrets are referenced using ${secrets.KEY} syntax. References are valid in any string field anywhere in the manifest, and are substituted with their plaintext values by the platform before the manifest is delivered to your VM. See Secret references below for the full contract.

env:
  NPM_TOKEN: ${secrets.NPM_TOKEN}
  DATABASE_URL: postgres://user:${secrets.DB_PASSWORD}@localhost:5432/app
  STRIPE_KEY: ${secrets.STRIPE_SECRET_KEY}

services:
  - name: web
    run: node server.js --auth=${secrets.WEB_AUTH}
    env:
      ANALYTICS_KEY: ${secrets.ANALYTICS_KEY}
    healthcheck:
      url: http://localhost:3000/health?token=${secrets.HEALTH_TOKEN}

You do not declare secrets in the manifest itself; the platform's secret store is the source of truth. Manage secrets through the platform's secrets API (one per environment).

docker_compose

Alternative to setup/services/commands for Docker Compose-based projects. Mutually exclusive with the standard mode fields.

FieldTypeRequiredDefaultDescription
app_servicestringyes--Primary service name in docker-compose.yml.
setuparray of stringsno[]Commands to run before docker-compose up.
execarray of stringsno[]Commands to run inside app_service after services are up.
docker_compose:
  app_service: app
  setup:
    - cp .env.example .env
  exec:
    - php artisan migrate
    - php artisan db:seed

Health Check Format

Health checks verify that a setup command succeeded or a service is ready. One of command or url is required.

FieldTypeRequiredDefaultDescription
commandstringno*--Shell command that must exit 0.
urlstringno*--URL to probe. Supports http://, https://, tcp:// schemes. HTTP expects 2xx. TCP expects connection accepted.
intervaldurationno2sTime between probes.
timeoutdurationno60sTotal time before giving up.
retriesintegerno30Max probe attempts.
start_perioddurationno0sGrace period before the first probe.

Duration format: a decimal number followed by a unit. Supported units include ns, us, ms, s, m, and h. Examples: 500ms, 2s, 5m, 1.5h.

# HTTP health check
healthcheck:
  url: http://localhost:3000/health
  interval: 2s
  timeout: 30s
  retries: 15

# TCP health check (database)
healthcheck:
  url: tcp://localhost:5432

# Command health check
healthcheck:
  command: test -f /tmp/ready
  retries: 10

Port Definitions

Port declarations on services tell the platform which ports to expose publicly.

FieldTypeRequiredDefaultDescription
portintegeryes--Port number inside the VM.
namestringyes--Unique name for this port mapping. The platform uses this name to look up the external URL.
protocolstringnotcpProtocol hint: http, ws, or tcp.
ports:
  - port: 3000
    name: web
    protocol: http

When the VM boots, the platform allocates a host port and creates a proxy. The resulting public URL is available in the VM's port_mappings array, keyed by name.

Service Dependencies

The depends_on field on services creates a startup DAG (directed acyclic graph). Services start in dependency order via topological sort. Each service blocks until all of its dependencies' health checks pass.

Circular dependencies are detected and rejected at startup.

services:
  - name: database
    run: docker compose up postgres
    healthcheck:
      url: tcp://localhost:5432

  - name: redis
    run: docker compose up redis
    healthcheck:
      url: tcp://localhost:6379

  - name: api
    run: npm run start
    depends_on: [database, redis]
    healthcheck:
      url: http://localhost:4000/health

In this example, database and redis start concurrently. api waits for both to be healthy before starting.

Secret references

Anywhere a string appears in the manifest, ${secrets.KEY} is a valid reference. The platform substitutes every reference with its plaintext value before the manifest is delivered to your VM; your VM never receives a literal ${secrets.X} placeholder and never receives a copy of any secret it doesn't reference.

Where references are valid

Every string field in the manifest, including:

  • env values at the top level and inside any setup command, service, or named command
  • setup[].run, services[].run, commands.<name>.run, simulators[].build, simulators[].run
  • setup[].working_dir, services[].working_dir, commands.<name>.working_dir
  • setup[].healthcheck.command, setup[].healthcheck.url, and the same fields on services
  • callbacks.url, callbacks.auth
  • sources[].repo, sources[].branch, sources[].workspace, sources[].identity.name, sources[].identity.email
  • agent.command, agent.args[], agent.instructions[], agent.env values
  • docker_compose.app_service, docker_compose.setup[], docker_compose.exec[]

Source of values

${secrets.KEY} references resolve from the secret store on the environment. You manage secrets through the platform's secrets API; you do not declare them in the manifest. See the secrets section for usage examples.

Missing references are rejected at the API

If the manifest references a secret that is not configured on the environment, the platform rejects the request before a VM is provisioned. This happens in two places:

  1. At manifest acceptance, when you save a new manifest or update an existing one. The save fails with a 422 and an error listing every missing secret and every field that referenced one.
  2. At VM provisioning, when a session boot or VM-create call resolves the manifest to a delivery-ready form. If a referenced secret was deleted in the window between manifest save and VM-create, the VM-create fails with a 422 and the same diagnostic.

Either way, your VM never starts against a literal ${secrets.X} placeholder. A typo (${secrets.STRIP_KEY} instead of ${secrets.STRIPE_KEY}) is caught the moment you save the manifest. Forgetting to provision the secret is caught at the same point. The fix in both cases is to add the secret to the environment, or to remove the reference, then re-save.

Quoting is your responsibility

Substitution is a literal string replacement. The plaintext value lands in the manifest field exactly as stored, with no escaping applied for the surrounding context. If your secret contains shell metacharacters ($, ", ', backticks, spaces, newlines) and you reference it inside a run string, quote it the same way you would quote any other unknown input:

setup:
  - name: bad-quoting
    run: echo "Token is ${secrets.API_TOKEN}"   # breaks if the value contains a "

  - name: good-quoting
    run: |
      printf '%s' "$TOKEN" > .auth   # value flows through env, no shell parsing
    env:
      TOKEN: ${secrets.API_TOKEN}

Treating secret values as data passed via env and consumed by printf '%s', cat, or argument lists is the safe pattern. Inlining secret values into a shell-parsed command line is brittle for the same reason inlining any other untrusted string is.

Rotating a secret requires a new snapshot

The platform substitutes the manifest with the secret's current plaintext at the moment a VM is created. The captured snapshot is a frozen filesystem that may include side effects from setup commands, such as files written or packages configured with the value baked in. Rotating a secret with PATCH /v1/.../secrets/{id} updates the source of truth, but it does not retroactively change files or build artifacts already captured in existing snapshots.

If a secret is consumed by setup, regenerate the environment's base snapshot after rotation. New sessions booted from the new snapshot use the rotated value through the full lifecycle.

What Gets Auto-Injected

Wallfacer automatically modifies the manifest before execution:

BehaviorDetail
agent-api serviceIf no service named agent-api exists, one is injected as the first service with a health check, port declaration, and platform-appropriate binary path.
Memory and vCPUsNormalized from server configuration. User-provided values are overwritten.
Simulator device and runtimeForced from server configuration on iOS simulators. User-provided values are overwritten.
Map field normalizationenv and commands are ensured to be objects (not arrays) for correct parsing.
Port normalizationBare integer ports in service definitions are converted to proper port objects.

Configuration Layering

Manifests are resolved by merging three layers, where later layers override earlier ones:

1. Repo file (agent-env.yml in repo root)     -- committed by engineers
2. Environment config (stored in Wallfacer)    -- per-environment overrides via app/API
3. Session config (injected at session start)  -- per-session secrets and branch overrides

Merge semantics: Deep merge with later layers winning. For arrays (setup, services), the override layer replaces the entire array. For objects (env, agent, commands), keys are merged with override values winning.

Examples

Node.js Web App with PostgreSQL

version: 1

platform:
  os: linux/amd64

sources:
  - repo: github.com/acme/webapp
    branch: main

env:
  NODE_ENV: development
  DATABASE_URL: postgres://postgres:postgres@localhost:5432/app

setup:
  - name: Install dependencies
    run: npm install
    retries: 2
    healthcheck:
      command: test -d node_modules

services:
  - name: database
    run: docker compose up postgres
    healthcheck:
      url: tcp://localhost:5432

  - name: dev-server
    run: npm run dev
    depends_on: [database]
    ports:
      - port: 3000
        name: web
        protocol: http
    healthcheck:
      url: http://localhost:3000/health

commands:
  migrate:
    run: npm run db:migrate
    working_dir: /workspace/webapp
  seed:
    run: npm run db:seed
    working_dir: /workspace/webapp

ready: [migrate, seed]

Python Django Project

version: 1

platform:
  os: linux/amd64

sources:
  - repo: github.com/acme/django-api
    branch: main

env:
  DJANGO_SETTINGS_MODULE: config.settings.development
  DATABASE_URL: postgres://postgres:postgres@localhost:5432/django

setup:
  - name: Install Python dependencies
    run: pip install -r requirements.txt
    retries: 2
  - name: Install pre-commit hooks
    run: pre-commit install

services:
  - name: database
    run: docker compose up -d postgres
    healthcheck:
      url: tcp://localhost:5432

  - name: django
    run: python manage.py runserver 0.0.0.0:8000
    depends_on: [database]
    ports:
      - port: 8000
        name: web
        protocol: http
    healthcheck:
      url: http://localhost:8000/health/

commands:
  migrate:
    run: python manage.py migrate
    working_dir: /workspace/django-api
  seed:
    run: python manage.py loaddata fixtures/seed.json
    working_dir: /workspace/django-api

ready: [migrate]

agent:
  instructions:
    - .claude/CLAUDE.md

iOS App with Simulator

version: 1

platform:
  os: darwin/arm64

sources:
  - repo: github.com/acme/ios-app
    branch: main

setup:
  - name: Install CocoaPods dependencies
    run: pod install
    retries: 2
    healthcheck:
      command: test -d Pods

simulators:
  - platform: ios
    bundle_id: com.acme.ios-app
    build: |
      xcodebuild -workspace App.xcworkspace -scheme App \
        -destination "id=$IOS_SIMULATOR_UDID" \
        -derivedDataPath .build build
      xcrun simctl install $IOS_SIMULATOR_UDID \
        .build/Build/Products/Debug-iphonesimulator/App.app
      xcrun simctl launch $IOS_SIMULATOR_UDID com.acme.ios-app

agent:
  instructions:
    - .claude/CLAUDE.md

Multi-Repo Setup

version: 1

platform:
  os: linux/amd64

sources:
  - repo: github.com/acme/frontend
    workspace: /workspace/frontend
    branch: main
  - repo: github.com/acme/shared-lib
    workspace: /workspace/shared-lib
    branch: main

env:
  NODE_ENV: development

setup:
  - name: Install shared library
    run: cd /workspace/shared-lib && npm install && npm run build
    retries: 2
  - name: Install frontend dependencies
    run: cd /workspace/frontend && npm install
    retries: 2
  - name: Link shared library
    run: cd /workspace/frontend && npm link ../shared-lib

services:
  - name: dev-server
    run: npm run dev
    working_dir: /workspace/frontend
    ports:
      - port: 3000
        name: web
        protocol: http
    healthcheck:
      url: http://localhost:3000

agent:
  instructions:
    - .claude/CLAUDE.md