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.jsonThis 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
version | integer | yes | -- | Manifest schema version. Currently 1. |
platform
Declares the compute platform and resource requirements.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
os | string | no | server default | Target OS. linux/amd64 or darwin/arm64. Set this explicitly for predictable environments. |
memory | integer | no | 8192 | Memory in MB. Set automatically by the server; cannot be overridden by users. |
vcpus | integer | no | 2 | Virtual CPU count. Set automatically by the server; cannot be overridden by users. |
gpu | boolean | no | false | Reserve GPU access. Set automatically by the server; cannot be overridden by users. |
platform:
os: darwin/arm64image
Optional base image and OS tuning.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
base | string | no | platform default | Base image identifier, including a version tag. |
system.file_descriptor_limit | integer | no | base image default | Max open file descriptors per process. |
system.spotlight | boolean | no | -- | Disable Spotlight indexing. macOS only. |
system.tcc.apple_events | array of strings | no | [] | Binaries pre-authorized to send AppleEvents. macOS only. |
system.tcc.accessibility | array of strings | no | [] | Binaries pre-authorized for accessibility automation. macOS only. |
system.locale | string | no | en_US.UTF-8 | System 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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
repo | string | yes | -- | Git repository. Short form (owner/repo) or full URL (github.com/owner/repo). |
auth | string | no | token | Authentication method: token or ssh-key. Credentials are injected at session time, never stored in the manifest. |
workspace | string | no | {root}/{reponame} | Absolute path where the repo is cloned. Defaults to /workspace/{reponame} (Linux) or /Users/admin/workspace/{reponame} (macOS). |
branch | string | no | main | Default branch for snapshot builds. |
clone_strategy | string | no | shallow | shallow (--depth 1 --single-branch, fast) or full (complete history). Use full only if the agent needs git log or blame. |
identity.name | string | no | -- | Git commit author name. |
identity.email | string | no | -- | 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: developEach 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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| (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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | yes | -- | Human-readable label. |
run | string | yes | -- | Shell command (executed via bash -c). |
working_dir | string | no | first source workspace | Working directory. |
env | object | no | {} | Command-specific env vars (merged with global env). |
retries | integer | no | 2 | Retry count on failure. |
healthcheck | object | no | -- | 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 buildOn 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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | yes | -- | Unique service name. |
run | string | yes | -- | Shell command to start the service. |
working_dir | string | no | first source workspace | Working directory. |
env | object | no | {} | Service-specific env vars (merged with global env). |
depends_on | array of strings | no | [] | Service names that must be healthy before this one starts. |
ports | array of objects | no | [] | Ports to expose. See Port Definitions. |
healthcheck | object | no | -- | 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/healthcommands
Named, reusable command blocks. Commands can be invoked by the platform in these ways:
- Lifecycle -- via the
readyarray (runs after services are healthy, every boot) - HTTP API -- via the in-VM command API
- Platform tooling -- for diagnostics and infrastructure operations
Every command execution fires webhook events (command.started, command.completed, command.failed).
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| (key) | string | yes | -- | Command name (the YAML/JSON key). Must be unique. Used in the ready array and API calls. |
run | string | yes | -- | Shell command (executed via bash -c). |
working_dir | string | no | first source workspace | Working directory. |
env | object | no | {} | Command-specific env vars (merged with global env). |
timeout | duration | no | 30m | Maximum execution time. |
retries | integer | no | 1 | Retry 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: 5mready
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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
platform | string | yes | -- | Simulator platform: ios (future: android). |
device | string | no | server default | Device name (e.g. iPhone 17 Pro Max). Set by the server; users cannot override. |
runtime | string | no | latest | OS version (e.g. 26.2 for iOS 26). Set by the server; users cannot override. |
bundle_id | string | no | -- | App bundle ID. Used to verify the app launched in the simulator. Exposed as $IOS_BUNDLE_ID. |
build | string | no | -- | Compile, install, and launch script (Swift, native). Generates a build-ios command. |
run | string | no | -- | Start a dev server pointed at the simulator (Expo). Generates a run-ios command. |
What the platform does with each entry:
- Boots the simulator via
xcrun simctl - Starts SimulatorCapture as a supervised service on port 8000
- Exposes
$IOS_SIMULATOR_UDIDand$IOS_BUNDLE_IDas env vars - If
buildis set, registers abuild-ioscommand (re-triggerable mid-session) - If
runis set, registers arun-ioscommand - If
bundle_idis set, waits for the app to appear in the simulator (2-minute timeout) - 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-bundleriOS 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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
device | string | no | server default | Device model name. |
runtime | string | no | server default | iOS runtime version. |
headless | boolean | no | true | Run the simulator in headless mode. |
bundle_id | string | no | -- | App bundle identifier. |
build | string | no | -- | Build, install, and launch script. |
timeout | duration | no | 30m | Max build duration. |
ports | array | no | [] | Ports exposed by the simulator capture service. |
agent
Configures Claude Code inside the VM: the instruction files it loads.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
instructions | array of strings | no | [] | 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.mdMCP 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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
url | string | no | -- | Webhook endpoint URL. Supports ${secrets.KEY} substitution. |
auth | string | no | -- | Authorization header value (e.g. bearer ${secrets.WEBHOOK_TOKEN}). Supports ${secrets.KEY} substitution. |
events | array of strings | no | all | Event 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.failedsecrets
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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
app_service | string | yes | -- | Primary service name in docker-compose.yml. |
setup | array of strings | no | [] | Commands to run before docker-compose up. |
exec | array of strings | no | [] | 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:seedHealth Check Format
Health checks verify that a setup command succeeded or a service is ready. One of command or url is required.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
command | string | no* | -- | Shell command that must exit 0. |
url | string | no* | -- | URL to probe. Supports http://, https://, tcp:// schemes. HTTP expects 2xx. TCP expects connection accepted. |
interval | duration | no | 2s | Time between probes. |
timeout | duration | no | 60s | Total time before giving up. |
retries | integer | no | 30 | Max probe attempts. |
start_period | duration | no | 0s | Grace 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: 10Port Definitions
Port declarations on services tell the platform which ports to expose publicly.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
port | integer | yes | -- | Port number inside the VM. |
name | string | yes | -- | Unique name for this port mapping. The platform uses this name to look up the external URL. |
protocol | string | no | tcp | Protocol hint: http, ws, or tcp. |
ports:
- port: 3000
name: web
protocol: httpWhen 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/healthIn 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:
envvalues at the top level and inside any setup command, service, or named commandsetup[].run,services[].run,commands.<name>.run,simulators[].build,simulators[].runsetup[].working_dir,services[].working_dir,commands.<name>.working_dirsetup[].healthcheck.command,setup[].healthcheck.url, and the same fields on servicescallbacks.url,callbacks.authsources[].repo,sources[].branch,sources[].workspace,sources[].identity.name,sources[].identity.emailagent.command,agent.args[],agent.instructions[],agent.envvaluesdocker_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:
- At manifest acceptance, when you save a new manifest or update an existing one. The save fails with a
422and an error listing every missing secret and every field that referenced one. - 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
422and 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:
| Behavior | Detail |
|---|---|
| agent-api service | If 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 vCPUs | Normalized from server configuration. User-provided values are overwritten. |
| Simulator device and runtime | Forced from server configuration on iOS simulators. User-provided values are overwritten. |
| Map field normalization | env and commands are ensured to be objects (not arrays) for correct parsing. |
| Port normalization | Bare 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 overridesMerge 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.mdiOS 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.mdMulti-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