`policy_config` is a JSON object you can send on `POST /runs`. It sets how one run works. Leave out a key and the run uses its default. An unknown key fails with `INVALID_POLICY` (HTTP 400).

You can also save defaults so you don't send the same policy on every run. See [Saved defaults](#saved-defaults).

## Top-level fields

| field | type | default | what it sets |
|---|---|---|---|
| `validation` | object | see below | how the run checks its change |
| `reviews` | object | see below | which review gates the run must pass |
| `budget` | object | see below | repair tries and the spend limit |
| `egress` | object | `managed_default` | network access for the sandbox |
| `artifacts` | object | `standard` | how long evidence files are kept |
| `harness` | object | none | which coding agent and model run the change |
| `subscription_usage` | object | see below | use of a subscription past its usage limit |
| `usage_failover` | object | see below | what runs when your subscriptions are used up |
| `tools` | object | none | built-in tools and your own tools for the run |
| `autonomy` | enum | `auto` | what happens once the change passes its checks |
| `spec_review` | enum | none | if the run waits for you to approve its plan |
| `unattended` | bool | `false` | never pause the run to ask a question |
| `auth_mode` | enum | `api_key` | who pays for the AI: credits or a subscription |
| `subscription_credential_id` | UUID | none | the subscription a run uses |
| `subscription_provider` | enum | none | `claude_max` or `chatgpt` |

## `validation`

| field | type | default | what it does |
|---|---|---|---|
| `profile` | enum | `auto` | A stack hint: `auto`, `python`, `nextjs`, `vite`, `flutter`, `react_native`, `native_ios` or `native_android`. Syndai still detects stacks from your files. A hint only adds to what it finds. |
| `level` | enum | `standard` | `smoke`, `standard` or `strict`. See [Validation](/docs/api/validation#levels). |
| `base_freshness` | enum | `as_of_base` | `latest_main` counts CI only when the pull request sits on the current tip of the base branch. With `as_of_base`, CI still counts after the base branch moves, as long as it did not move in a file the change touches. |
| `bootstrap_when_toolless` | bool | `false` | `true`: when your repo has no checks Syndai can run, the agent adds a small test setup so the change can be checked. `false`: the run stops for lack of checks. |
| `template_trees` | list of strings | `[]` | Folders that hold templates your repo ships, not code it builds. Syndai never builds or tests inside them. Each entry is a plain path like `tools/scaffold`. No leading or trailing slash, no `.` or `..`, at most 50. |
| `inputs` | map of string to list | `{}` | What each command folder reads, as path prefixes: `{"api": ["api/", "shared/"]}`. `.` is the whole repo. A check is skipped when the change touches none of its inputs. A passing check is reused when its inputs, command and setup did not change. No globs. See [syndai.yaml](/docs/api/syndai-yaml). |
| `timeout_seconds` | int | `600` | Time limit for a check that runs on GitHub Actions and has no limit of its own. More than zero. |
| `github_poll_attempts` | int | `60` | How many times Syndai polls a GitHub Actions check. More than zero. |
| `github_poll_interval_seconds` | number | `5.0` | Seconds between those polls. At least `2.0`. |

You never send commands. Syndai finds them in your repo. These keys are refused with `INVALID_POLICY`: `commands`, `execution_environment`, `github_workflow_id`, `github_result_artifact_name` and `artifact_upload`.

## `reviews`

| field | type | default | what it does |
|---|---|---|---|
| `required_gates` | list | `[]` | Gates the run must pass. Allowed: `planning_review`, `adversarial_review`, `security_review` and `preflight`. |
| `hitl_on_exhaustion` | bool | `true` | When the run uses up its repair tries, it waits for you instead of failing. A run with no one to answer never waits. `false` turns this off. |

Every run passes `security_review`, `preflight` and `planning_review`, whatever you send. So `adversarial_review` is the one gate you can add. Names that act, such as `merge`, `push`, `deploy` or `create_pr`, are refused. See [Review gates](/docs/api/review-gates).

## `budget`

| field | type | default | what it does |
|---|---|---|---|
| `retry_budget` | int | `3` | How many times the run may fix a failed check before it stops. `0` means no repair tries. |
| `max_budget_usd` | number | none | The most this run may spend, in US dollars of usage (the portal shows it in credits; `5` is the 20-credit minimum). At least `5`. The portal shows this limit in credits. |
| `branch_strategy` | enum | `branch_only_never_merge` | `branch_only_never_merge` or `branch_then_pr`. Neither one lands a change. Landing is set by `autonomy`. |

## `egress`

| field | type | default | what it does |
|---|---|---|---|
| `profile` | enum | `managed_default` | `managed_default` gives the sandbox normal network access. `offline` blocks all outbound traffic. |

An `offline` run can't call your own MCP servers, since they need the network.

## `artifacts`

| field | type | default | what it does |
|---|---|---|---|
| `retention_profile` | enum | `standard` | How long evidence files (screenshots, logs, reports) are kept. `standard` is up to 30 days and `extended` is up to 90. `pinned` keeps them until you delete them. |

## `harness`

| field | type | default | what it does |
|---|---|---|---|
| `kind` | enum | none | The coding agent: `pi`, `opencode`, `claude_code` or `codex_cli`. |
| `model` | string | none | The model for that agent. It must be on the agent's allowed list. The error lists the allowed models. |
| `effort` | enum | none | `low`, `medium`, `high`, `xhigh` or `max`. |

`model` needs `kind`. On credits (`auth_mode: api_key`) you can pick `pi`, `opencode` or `claude_code`. On a subscription you can pick `claude_code` (Claude Max) or `codex_cli` (ChatGPT). Leave out `auth_mode` and Syndai picks it for you. `claude_code` uses your Claude subscription when you connected one, or credits when you did not. `codex_cli` needs a connected ChatGPT subscription.

## `subscription_usage`

| field | type | default | what it does |
|---|---|---|---|
| `allow_extra_usage` | bool | `false` | `true` lets a run use your subscription even when its usage is near the limit or can't be read. Your provider may then charge for extra usage. |

## `usage_failover`

| field | type | default | what it does |
|---|---|---|---|
| `backup_chain` | list or null | `null` | What runs when every subscription you connected is used up. `null` uses the default: your other subscription if it has room, then `pi`. `[]` means no backup: the run waits and goes on when a usage window resets. A list is the agents to try in order, from `pi`, `opencode`, `claude_code` and `codex_cli`. At most 3, no repeats. |
| `api_funding` | enum or null | `null` | Who pays when a subscription run falls back to an API agent. `credits` pays from workspace credits, never from your own OpenRouter key. `null` and `workspace_default` use the workspace's normal lane. |

## `tools`

| field | type | default | what it does |
|---|---|---|---|
| `builtins` | enum | `all` | `all` gives the run Syndai's built-in tools. `none` gives it none of them. Your own tools still work. |
| `disabled_builtin_capabilities` | list | `[]` | Built-in tools to take away from `all`. Ignored with `none`. At most 100, no repeats. |
| `custom_tool_ids` | list or null | `null` | Your own tools (skills and MCP servers) for this run, as UUIDs. `null` gives the run every tool you turned on. A list, even an empty one, gives it only those. At most 50, no repeats. |

Leave out `tools` and the run gets all built-in tools and every tool you turned on. The run calls your MCP servers through Syndai. Your server's URL and secret never enter the sandbox.

## `autonomy`

What happens once the change passes its checks. Values: `auto`, `gated`, `merge` and `push`.

- `auto` opens the pull request.
- `gated` stops before the pull request. You see the change and pick **Approve** to open it, or **Request changes** to send notes for another try.
- `merge` opens the pull request, then squash merges it when every gate is green. It holds if a gate fails. The security review and preflight must pass. The base branch must not have moved in a file the change touches. CI must pass too.
- `push` runs the same gates. Then it moves the default branch up to the checked commit. If the base branch moved, it squash merges the pull request. `push` is for the API only.

A held change waits on the run with **Merge anyway**, **Retry** and **Close**. Only a person signed in at app.syndai.ai can pick **Merge anyway**. A key gets `HUMAN_APPROVAL_REQUIRED`. A per-run `merge` or `push` counts only when it comes from an API key, an owner or admin, or a user's own run. Some runs come from a request someone else wrote, like a site visitor, a member or a Sentry alert. Those always hold before they land.

## `spec_review`

If the run waits for you to approve its plan before it builds. Values: `auto`, `timed` and `hold`.

- Not set: the run builds at once. A large plan waits like `timed`.
- `auto` never waits, even for a large plan.
- `timed` waits for you, then builds on its own if you don't answer in time.
- `hold` waits until you approve. It never builds on its own. If no one answers, the run ends without building.

A run with no one to answer never waits. That covers scheduled runs and `unattended` runs.

## `unattended`

`true` means no one will answer questions. When the agent is unsure, it goes on with its best guess and says so in the pull request. It does not pause. Pauses for a sign-in or a usage limit still happen.

## Turning off web search

The agent may search the web while it plans and builds. To turn off its web tools, set `tools.disabled_builtin_capabilities: [coding.read.web_search, coding.read.web_fetch]`. That turns off Syndai's web search and fetch and the agent's own built-in web search and fetch.

That alone does not keep task text off the internet. Under the default `managed_default` egress the sandbox still has normal network access, so a shell command could still send a request out. To keep task text inside the sandbox, also set `egress.profile: offline`, which blocks all outbound traffic.

`context_research_enabled` is retired and does nothing. A run that still sends `true` or `false` is accepted, and the value is ignored.

## Subscriptions

- `auth_mode`: `api_key` (the default) or `subscription`.
- `subscription_credential_id`: the subscription you connected.
- `subscription_provider`: `claude_max` or `chatgpt`. It must match the harness: `claude_max` goes with `claude_code`, and `chatgpt` goes with `codex_cli`.

Send `subscription_credential_id` and `subscription_provider` only with `auth_mode: subscription`. A subscription agent needs one of the two.

## Rules

A policy that breaks one of these fails with `INVALID_POLICY` (HTTP 400). `detail` names the field.

1. No unknown keys, at any depth.
2. These keys are refused by name: `webhook_url`, `mobile_validation`, `web_validation`, `backend_validation`, `policy_config_override`, `internet_access`, `network_allow_list`, `allow_hosts` and `allow_cidrs`. Set up webhooks with the [webhooks API](/docs/api/webhooks) instead.
3. `timeout_seconds` and `github_poll_attempts` are more than zero.
4. `github_poll_interval_seconds` is at least `2.0`.
5. `retry_budget` is zero or more.
6. `max_budget_usd` is at least `5`.
7. The harness, model and subscription fields agree, as above.

A `null` value clears the key back to its default.

## Saved defaults

Save a policy once and every new run starts from it:

- `GET /policy/schema` returns this schema as JSON Schema.
- `PUT /policy/defaults` saves a default. Add `coding_repository_id` to save one for a single project.
- `GET /policy/defaults` reads the current default, and `GET /policy/defaults/history` lists past ones.

Layers apply in this order, and the last one wins for each key:

1. the workspace default
2. the project default
3. the repo's [syndai.yaml](/docs/api/syndai-yaml)
4. the run's own `policy_config`

`syndai.yaml` is read after the run's sandbox starts. So it can't change `egress.profile`, `tools.builtins`, `tools.disabled_builtin_capabilities`, `budget.branch_strategy`, `budget.max_budget_usd` or `autonomy`. Set those in a saved default or on the run.

A new workspace starts with this default: `spec_review: auto`, `autonomy: merge`, `unattended: true`, and `usage_failover` set to `backup_chain: null` and `api_funding: credits`. It is an ordinary saved default, so you can change it.
