Skip to content

Run configuration

Tell PDT how to run and test your project, and any branch can start on its own port with one click: a dev server for the ticket an agent just finished, next to the one on main, without them getting in each other’s way.

A project’s run configuration lists its runs (dev servers, apps), its tests, its areas (the parts of the code that belong together) and, optionally, its forge and CI. To start a run, see Runs, dev servers and tests in Workspaces and sessions.

Let an agent write it

Open Settings, the project’s page, and under Runs on this host click Set up with an agent. Pick an agent: it starts a session in the project that looks at how the project is built and run, and writes the configuration for you.

Where it lives

The configuration comes from two places, put together:

  • .pdt/project.toml in the checkout being run. Each branch can have its own, so a branch can change how it runs. Commit it when everyone working on the repository uses PDT.
  • Host overrides, in the project’s Settings under Runs on this host: the same format, kept on this machine only. Use them for a repository that isn’t yours, or for what shouldn’t be committed (a local path, a secret).

An override with the same name as an entry in the file changes only the fields it sets (env tables are combined); a new name adds an entry. Overrides can also be the whole configuration on their own. Repository configuration on the same page shows what the file says.

A file with a mistake (an unknown field in an entry, two entries with the same name, an entry with no command, an environment variable name that isn’t letters, digits and _) doesn’t run at all, and the project’s Settings say what’s wrong. A whole section PDT doesn’t know (one added by a newer PDT) is skipped and noted in Settings, so the rest still runs.

Approving commands

The configuration names shell commands, and anyone who can change the file can change them: the repository you cloned, a branch, an agent working in a ticket’s folder. So PDT runs a command only once you’ve approved it:

  • Starting a run or test whose command PDT hasn’t run before shows the command first. Approve and run starts it; PDT doesn’t ask again for that command.
  • CI and regeneration rules wait instead, and the Inbox says what’s waiting. Approve the command in the project’s Settings, under Runs on this host › Commands, which lists every command with what uses it. CI starts on its own once you approve it.
  • A changed command is a new command. Editing a run’s command, its setup or its env asks again, wherever the change comes from.
  • Commands you write in the host overrides count as approved when you save them.

Projects you’d added before PDT asked keep their main checkout’s commands approved. A project you add now starts with nothing approved. You approve commands in PDT’s own window, and agents aren’t given a way to approve one for you.

Runs

[[run]]
name = "web"
setup = "pnpm install"
command = "pnpm dev --port $PORT --host 127.0.0.1"
web = true
data = true
env = { DATABASE_URL = "sqlite://$PDT_RUN_DATA/app.db" }
  • name (required): what the run is called in the + menu. It is also a folder name, so no / \ : < > | " ? *.
  • command (required): what starts it, in the checkout’s folder.
  • setup: a step before every start, such as installing dependencies. It runs each time, so keep it quick when nothing changed.
  • web = true: it’s a web server. PDT picks a free port (nothing listens on it on 127.0.0.1 or ::1), passes it as $PORT, and shows the run’s link once something answers on it.
  • data = true: PDT gives the run a folder of its own as $PDT_RUN_DATA, kept per branch. Reset data… on the run empties it.
  • env: environment variables for the run.
  • detach = true: keeps it running when PDT restarts or updates (below).

A project can have as many runs as it needs: a web app and its API, say, each with its own port.

What a run gets

Each step runs in the checkout’s folder with your machine’s environment (the same tools as your terminal), through sh -c on macOS and Linux and a .cmd file on Windows. Its output shows in the run’s tab.

VariableValue
$PORTthe run’s port (web runs only), from this machine’s range (8400–8499 unless changed in Settings › This host › Runs)
$PDT_RUN_DATAthe run’s data folder (with data = true)
$PDT_RUN_URLthe run’s link
$PDT_RUN_HOSTthe address it’s reached at: your machine’s name on your tailnet when remote access is on, else 127.0.0.1. A run (not a test) that uses it or $PDT_RUN_URL restarts when remote access is turned on or off
$PDT_RUN_BRANCHthe branch
$PDT_OWNER_LOGINyour Tailscale login, when remote access has one

PDT fills in these variables (as $NAME or ${NAME}) in setup, command and env; anything else, such as $HOME, is left to the shell. A value is never run as part of the command: on macOS and Linux, one with characters the shell would act on (a branch named fix;rm, a folder with a space) is left for the shell to read from the environment, so quote it as usual ("$PDT_RUN_DATA/db"). On Windows, a run fails to start, saying why, when a value it uses has & | < > ^ % ! " in it; rename the branch, or read the variable in your program instead. A web run is ready once something answers on its port (on 127.0.0.1 or ::1); another run as soon as its command starts.

Runs that outlive PDT

A run normally stops when PDT restarts, which includes its automatic updates. For something you keep open all day (a desktop app you use from the main checkout, say), set detach = true:

  • setup runs as usual; the command starts as a background process, outside any terminal.
  • Its output goes to the run’s Output tab, read-only (it takes no typing), and is cleared on each start.
  • The run counts as running while any process it started is, so an app that restarts itself is still tracked.
  • After a restart PDT finds it again instead of starting it twice. Stop, Restart, a deleted branch and removing the project end every process it started.

A detached run keeps going through updates until you stop it, so use it only where you need it.

Tests

[[test]]
name = "unit"
command = "pnpm test"

A test passes when its command exits with 0. Tests take setup and env like runs, but not web or detach. Start one from + in the tab bar, or Run… on a ticket’s Inbox item.

Areas

Areas tell PDT which parts of the code belong together, so it can warn you before two tickets change the same thing.

[[area]]
name = "migrations"
paths = ["db/migrations/**"]
exclusive = true
[[area]]
name = "web"
paths = ["web/**"]
  • name: 1–60 characters, no commas.
  • paths: patterns from the repository’s root. * matches within one folder, ** across folders.
  • exclusive = true: only one ticket in progress may touch it at a time. Sharing it is a conflict, not just a warning.

While a ticket is in progress, another in the same area shows an overlap, and Start warns you. PDT also flags files that two active branches both change. Areas are read from the main checkout (and the host overrides), not from the branch being worked on.

Tickets get their areas from you, the Refiner or the PM. To keep auto-start from starting tickets that would collide, pick the project’s Hot areas in its Settings, next to auto-start.

Forge

The forge is where a project’s pull requests, CI, reviews and issues live. Without a forge line, PDT goes by where the repository’s origin is: GitHub for github.com and each GitHub Enterprise server you’ve signed in to, GitLab (with its plugin on) for gitlab.com and each GitLab server you’ve signed in to, and none otherwise.

forge = "github" # or "gitlab", "local" (below), or "none"

forge = "none" turns off pull requests, CI and issues for the project; merges happen on your machine. The forge is read from the main checkout (and the host overrides, which win). A forge whose plugin is off or missing leaves the choice to origin.

Without GitHub: the local forge

PDT can be the forge itself, for a repository with no remote or one on a plain git server:

forge = "local"
[ci]
command = "cargo test" # optional
  • Change requests: a started ticket’s branch gets one (Change #1) once it has commits, shown wherever a pull request would be.
  • CI: on each new commit, PDT runs the [ci] command in the ticket’s folder (with CI=true); exit 0 passes. A newer commit stops the run on the older one, and a run longer than two hours fails. Failures and conflicts reach the agent as they would on GitHub.
  • Merging: Merge… merges the branch into its base on your machine once CI passes (a fast-forward when the base hasn’t moved, else a merge commit). A conflict goes back to the agent. Approval and review rules apply as usual.
  • There is no merge queue, issues or forge reviews (PDT’s own review still works), and agents are told to commit but not push.

To push each merge to a remote too (any git server, a bare repository on a share):

[push]
remote = "origin"
branches = true # optional: push ticket branches as they move, as a backup
  • PDT merges, pushes the merge to the remote’s base, and moves the local base only once the remote has taken it.
  • When the remote has moved on, PDT brings its base into the ticket’s branch, runs CI again and retries the merge. A conflict goes back to the agent.
  • What PDT can’t fix (the two bases have diverged, uncommitted changes in the worktree, no access) is a notice in the Inbox.
  • PDT pushes with your own git credentials (ssh keys, a credential helper) and can’t answer a password prompt. Runs on this host shows where merges go.

CI providers

A project’s CI can come from its forge (GitHub Actions, GitLab CI, the local forge’s [ci] command) and from CI plugins set up in its settings (Buildkite). PDT reads all of them unless you pick, by id, in order:

[ci]
providers = ["github_actions", "buildkite"] # github_actions, gitlab_ci, local, buildkite

The pull request’s CI badge rolls up every listed provider, each one’s failures reach the agent, and an automated merge waits until each has passed. An empty list means no CI; the host overrides’ list replaces the file’s.

Storage

Which folders of a checkout are build output, for cleaning worktrees (Settings › This host › Storage):

[storage]
build_output = ["target", "**/node_modules", "web/dist"]

Each entry is a path inside the checkout, or **/<name> for a folder of that name at any depth. Without the section PDT detects the usual ones from the files at the checkout’s root: target for Cargo and Maven, node_modules, dist, .next and .turbo for npm, .venv and __pycache__ for Python, build for Gradle and CMake. Cleaning only removes what git ignores and holds no tracked files, so it never touches your code or your uncommitted work. The host overrides’ list replaces the file’s.

Generated files at merge time

When PDT brings a branch up to date with its base at merge time (see Conflicts in Review and merge), a conflict only in generated files needs no agent if you say how to regenerate them:

[[merge.regenerate]]
paths = ["src/generated/**"]
run = "cargo test"
[[merge.regenerate]]
paths = ["Cargo.lock"]
run = "cargo update -w"
[[merge.regenerate]]
paths = ["pnpm-lock.yaml"]
run = "pnpm install"
  • paths: the files the rule covers, as patterns like an area’s.
  • run: the command that regenerates them, run in the ticket’s worktree with your login shell’s PATH.

The rules come from the base branch’s committed file (main’s), never from the ticket’s own folder, so a branch can’t add a rule for itself. A rule runs only once you’ve approved its command (see Approving commands).

When every conflicting file matches a rule, PDT takes the base’s version of each, runs each matching rule’s command, and commits what changed under its paths with the merge. If a command fails, or other files conflict too, the agent gets the conflicts and the commands to run once they’re resolved. The host overrides’ list replaces the file’s.

Pitfalls

  • Use $PORT. A web server on a fixed port never shows as ready, and two branches would clash. Pass the port by flag or environment variable, whichever your framework reads.
  • Let the run’s address in. With remote access on, the link is https://$PDT_RUN_HOST:$PORT/. Dev servers that check the address they’re called by (Vite’s server.allowedHosts, Django’s ALLOWED_HOSTS, webpack’s allowedHosts) must accept $PDT_RUN_HOST, or the page loads on this machine but not on your phone.
  • Unset variables stay as written. env = { X = "$SOMETHING" } passes the text $SOMETHING when PDT doesn’t set it. Leave such a variable out instead.
  • No secrets in the file. Put them in the host overrides, or have the app read them from your environment.
  • Keep state in $PDT_RUN_DATA, so branches don’t share a database and Reset data… works.
  • Windows: && between commands works, but ||, ; and | don’t; % is expanded twice; prefer a command on your PATH (cargo run -p app) over a path to a program.