Project runtime lives in .warpforge/workspace.yaml. Workflow pipelines live beside it in .warpforge/workflows/ and have their own reference. The legacy .warpforge.yaml, .wf.yaml, and .workspace.yaml names (at the project root) are also supported — Warpforge checks them in that order and uses the first one it finds.
name: my-app
services:
db:
command: docker compose up postgres
port: 5432
readyPattern: "database system is ready to accept connections"
app:
command: npm run dev
port: 3000
dependsOn: [db]
env:
DATABASE_URL: postgres://localhost:${db.port}/myapp
healthcheck:
url: http://localhost:${app.port}/api/health
interval: 5s
ports:
range: "4200-4299"
portforwards:
- name: staging-db
namespace: postgres
pod: postgres-cluster-pooler
localPort: 5432
remotePort: 5432
worktree:
copy: [".env*", "config/local.json"]
setup: bun install
agentTemplates:
custom:
command: my-acp-agent
description: Custom project agentname
Required, string. The project’s display name.
services
A map of service name → service config. Services start in dependency order, and each waits for its dependencies to be ready (see Readiness).
| Field | Type | Required | Description |
|---|---|---|---|
command |
string | yes | Run via sh -c "<command>", so pipes, &&, and cd all work. The command receives the service’s port as $PORT, and ${<service>.port} is replaced with that service’s port before it runs — so --port ${web.port} works. Tools that ignore $PORT (astro dev, vite) need it passed explicitly: astro dev --port $PORT. If the service is up but nothing answers on its port, Runtime shows a warning. |
port |
number | no | If the project declares a ports.range, this is the exact port the service must bind — taken or out-of-range, the service fails instead of moving (opt out per service with portFallback: auto). Without a declared range, the number is ignored: Warpforge picks the first free port in the project’s range, sets PORT, and makes ${<service>.port} available to other services’ env. See How ports work. |
env |
map<string, string> | no | Environment variables. Values may reference ${<service>.port} for any service in the same config. |
readyPattern |
string | no | A substring to watch for in the service’s stdout/stderr before marking it ready. See Readiness. |
dependsOn |
string[] | no | Services (or named port-forwards) that must be ready before this one starts. See Readiness. |
healthcheck |
object | no | { url, interval } — an HTTP endpoint polled after the process starts; any 2xx or 3xx response marks the service ready. url may use ${<service>.port}. interval is optional (default 1s). The request never goes through an HTTP proxy, and an https URL on localhost, 127.0.0.1, or ::1 may use a self-signed certificate. |
readyTimeout |
string | no | How long the service has to become ready before it is marked failed. Default 5m. Set it lower or higher per service. |
portFallback |
"auto" |
no | Only meaningful when the project declares a range. By default a pinned port is a hard constraint: taken or out-of-range, the service fails. Set to "auto" to shift to the first free port in the range instead. auto is the only accepted value. |
Durations (interval, readyTimeout) accept "100ms", "5s", "2m" (or "2min"), and "1h". A value that doesn’t parse falls back to the default.
Readiness
A service shows as starting until it proves it is up. It becomes running on the first signal it has:
- With a
healthcheck, only the healthcheck counts: Warpforge requestsurleveryinterval, and any 2xx or 3xx response marks the service running. Log lines and the port are ignored. - Without one, the service is running once its port accepts connections, or once a log line contains
readyPatternor a common “server started” message. - With no port, healthcheck, or
readyPattern, there is nothing to wait for, so the service counts as running as soon as its process starts.
If none of that happens within readyTimeout (default 5m, and you can set it lower or higher per service), the service is marked failed, and its log says why, for example did not become ready within 5m: http://localhost:4210/api/health returned 503. The process keeps running, so its logs stay available, and Warpforge keeps checking it every few seconds. If it comes up late, it moves back to running and logs [service ready] after …. Stop or restart it as usual. Starting all services, or opening the project, leaves a service that is still coming up alone; starting that one service restarts it, and its log says so.
A service with dependsOn starts only after every dependency is ready. Until then it shows as starting, and its log says what it is waiting for. If a dependency fails, times out, or is stopped, the dependent does not start: it is marked failed with a reason naming the dependency. If that dependency becomes ready later, for example a slow first build that finishes after its timeout, the dependent starts then. Starting a single service also starts any dependencies that are not already up. If your setup differs from the team’s — say, you reach a dependency through a port-forward, or you’d rather start everything yourself — change dependsOn for yourself in a personal override instead of the shared file; dependsOn: [] there turns automatic dependency starts off for that service. readyTimeout counts from when the process starts, so time spent waiting on dependencies does not use it up. A dependsOn cycle fails every service in the cycle.
A dependency can also be a named port-forward. Starting the service starts that forward, unless something on your machine already answers on the forward’s localPort — a local ClickHouse or Postgres, say, or a kubectl port-forward you ran yourself. Then Warpforge uses it: the forward stays stopped, the service starts right away, and its log says [dependency <name>: port <port> is already served locally — using it instead of starting the forward]. If the port is taken by something that doesn’t accept connections, the forward can’t start, and the service fails with a reason that names the port, for example port 8123 is in use by another process. Starting the forward yourself from Runtime always tries to start it.
ports
Optional project-level port configuration. Only one key today:
ports.range
Optional, string. The project’s committed port range, shared by the whole team. Two accepted forms:
"4200-4299"— an explicit inclusive range."4200"— a bare start; means the 100-port block from that start (4200-4299).
The start must be 1024 or above, the range must be at least one port wide (a bare start that would overflow past 65535 is rejected), and the lower bound must not exceed the upper. Declaring a range makes it binding: every service port: inside it is pinned to exactly that port, and a pinned port that is taken fails instead of shifting (see portFallback for the opt-out). Two projects declaring the same range conflict — one of them refuses to start services until a machine-local override resolves it; the committed config is never edited to fix that.
How ranges are resolved against machine-local overrides, and what a service’s port: value means in each case: How ports work.
ports:
range: "4200-4299"portforwards
A list of Kubernetes port-forwards to keep alive alongside local services. Requires kubectl on PATH, pointed at the right cluster via your current kubeconfig context — Warpforge doesn’t select or switch contexts for you. Each entry:
| Field | Type | Required | Description |
|---|---|---|---|
namespace |
string | yes | Kubernetes namespace. |
pod |
string | yes | Pod name or prefix — Warpforge finds the first matching pod. |
localPort |
number | yes | Local port to bind. |
remotePort |
number | yes | Remote port on the pod. |
name |
string | no | Human-readable label shown in the UI. |
Port-forwards are watched and retried with backoff when they drop. A service can list a named forward in dependsOn; if something local already serves the forward’s localPort, Warpforge uses that instead of starting the forward. If you start a forward by hand while its localPort is held by anything other than a kubectl port-forward of that port, it fails right away with port <port> is already served by another process (not a port-forward); stop that process or change localPort. A leftover kubectl port-forward of the same port is reclaimed and the forward retried.
worktree
Optional. What to carry into, and run in, every new task worktree, including the ones automation runs create — the things git does not check out for you, like a git-ignored .env, and the install step a fresh checkout needs. Unknown keys are rejected.
| Field | Type | Required | Description |
|---|---|---|---|
copy |
string[] | no | Glob patterns, relative to the project root, of files to copy into each new worktree at the same relative path. |
setup |
string | no | A command run in the new worktree, through sh -c, after the files are copied. |
worktree:
copy: [".env*", "apps/*/.env.local"]
setup: bun installHow copy behaves
- Patterns must be relative: no leading
/, no.., and no:. A pattern that breaks this, an invalid glob, or a blanksetupmakes the whole config fail to load, with the reason. *matches within one folder; use**to reach across folders.- Only regular files are copied. Symlinks are not followed or copied.
- A file that already exists in the worktree is never overwritten, so tracked files keep their checked-out contents.
.git,node_modules,target,dist,.next, and other worktrees are skipped, however broad the pattern.
How setup behaves
- It has 10 minutes. Its output is written to a log you can read from the project’s Worktrees tab.
- If copying or setup fails, the task does not start its agent. It stops in Needs you with the reason, including the last lines of the command’s output, and the worktree is kept. Send a message to work in the worktree anyway, or delete the task. A workflow that hits the failure ends with the error.
agentTemplates
A map of template name → agent template, for adding a custom ACP-compatible agent beyond the built-in ones — globally or per project.
| Field | Type | Required | Description |
|---|---|---|---|
command |
string | yes | The command Warpforge spawns to speak ACP over stdio. |
description |
string | no | Shown in the agent picker. |
Personal overrides: workspace.local.yaml
Keep your own variations — a dependency pointed at a port-forward instead of a local service, a different port, an extra service only you run — in .warpforge/workspace.local.yaml, beside the shared config. It is yours alone: Warpforge adds it to git’s ignore list the first time it sees it, so it never shows up as an untracked file and is never committed. Projects still on a legacy root config use a sibling name instead: .warpforge.local.yaml, .wf.local.yaml, or .workspace.local.yaml.
The local file uses the same schema as the shared one and is laid over it, not swapped for it — write only what differs. Editing it reloads the project like editing the shared config does.
| Rule | Behaviour |
|---|---|
| Fields | A field you set replaces the shared value. Everything you leave out stays as shared. |
Maps (services, env, ports, worktree, agentTemplates) |
Merged key by key, to any depth. A service present only in your file is added. |
Lists (dependsOn, worktree.copy, …) |
Replaced as a whole — list everything you want, not just the additions. |
null |
Removes a key. db: null under services removes that service; MODE: null under env unsets that variable. |
portforwards |
Entries are matched by name: matching ones are merged field by field, new ones are appended. To remove a shared forward, list it with remove: true. |
Merging happens before validation, so the combined config is checked as a whole. If it is invalid, the local file is ignored and the project runs on the shared config alone — Runtime shows a warning naming the file and the error until you fix or remove it.
# .warpforge/workspace.local.yaml
services:
analytics:
# Shared config: dependsOn: [db, api]. Here the database is the staging
# port-forward, so only wait for the forward and the API.
dependsOn: [staging-db, api]
env:
LOG_LEVEL: debug
mailhog:
command: mailhog
port: 8025
worker: null
portforwards:
- name: staging-db
localPort: 5544
- name: legacy-cache
remove: trueRuntime marks every service or forward your file added or changed with a small local badge; hover it to see which fields came from your file. A service that runs from the project checkout — including when a Factory task runs there — picks the file up automatically. Task worktrees do not get a copy unless you list it in worktree.copy.