Skip to main content

Project Config: .boss-project.json

A repository's boss setup (which checkout each daemon uses, the factory crons, the cloud triggers and the session webhooks) normally lives only as rows in the daemon's and the orchestrator's databases. .boss-project.json writes it down at the repository root so it is reviewable in a pull request and replayed on another machine.

The file is not required: every command behaves exactly as before when it is absent. boss project export writes it, boss project diff and boss project apply converge the local daemon onto it (with --cloud, also your cloud triggers and the organization's session webhooks), and boss init applies it when it is present.

Editors validate against the JSON Schema (draft 2020-12). For editor validation, use the raw JSON URL in your file:

{
"$schema": "https://raw.githubusercontent.com/bossanova-dev/bossanova/main/lib/bossalib/projectconfig/schema/boss-project.schema.json"
}

Boss itself decodes and validates the file without using the schema.


Exporting​

boss project export # print to stdout
boss project export --cloud # include triggers and session webhooks (needs boss login)
boss project export --write # write <main checkout>/.boss-project.json
boss project export --write --force # overwrite an existing file
  • The checkout (--dir, default .) must be registered with the local daemon; otherwise export fails and points at boss init --register. --remote and --host are refused.
  • The daemons entry is the local daemon: its display name, its routing daemonId (read from the daemon's own startup record, trusted only when that record is bound to the socket this CLI talks to) and this checkout's path, with the home directory written as ~/. When the id is unknown the entry is name-only and export warns.
  • --cloud reads the organization from --org, else the repository's organization mapping, else your only organization. Triggers are your triggers on this repository's origin; webhooks are the organization's.
  • The project slug is --project, else the existing file's project, else the repository name from the origin URL.
  • Warnings go to stderr, so stdout is pipeable. --write warns when git ignores the file.
  • Export refuses to print a file that would not validate (for example two cron jobs with the same name).

Diff and apply​

boss project diff # print the plan for this daemon (read-only)
boss project diff --exit-code # exit 1 when not converged, 2 on error, 0 otherwise
boss project apply # converge this daemon onto the file
boss project apply --dry-run # print the plan, write nothing
boss project apply --prune # delete managed crons the file dropped
boss project apply --yes # apply cron prompt and gate changes without asking
boss project apply --allow-branch # apply from a checkout off the default branch
boss project diff --cloud # also plan cloud triggers, webhooks and other daemons' crons (needs boss login)
boss project apply --cloud # also converge cloud triggers, webhooks and other daemons' crons
boss project apply --cloud --rotate-secrets # also rotate the project's signing secrets

Both read <top level of --dir>/.boss-project.json and refuse --remote and --host. --json prints the plan (and, for apply, the result: applied, failed and not-reached changes). The plan lists one line per change that is not unchanged, then the warnings.

This daemon​

The daemon's routing id and display name come from the startup record bossd writes, trusted only when it names the socket this CLI talks to. An entry pinned by daemonId matches by id; otherwise a unique unpinned entry whose name is the display name matches. When the record is missing or stale, matching falls back to the display name with a warning, and pinned entries never match until bossd restarts.

  • Matched: the repo lives at the entry's path (~/ expanded), and the crons that name the entry (or every cron, when there is exactly one entry) run here.
  • Unmatched: the repo lives at the checkout being applied, every cron is skip (daemon-unmatched), and a warning prints the daemons entry to add.
  • Several entries match: one conflict (ambiguous) naming them; crons skip, while repo settings and secrets still converge at the checkout being applied.

Actions​

ActionMeaning
createThe daemon lacks the row; apply creates it (a cron with the project marker)
updateAn own row (or the repo) differs in the fields named
adoptAn unmarked row is taken over: it gains the marker, plus any field updates
disableAn own cron the file no longer runs here is disabled
deleteSame, under --prune
rotateReplace an owned HTTP trigger or webhook signing secret (--rotate-secrets)
unchangedNothing to do
conflictCannot be applied as written (another project's row, two candidates, a bad gate, …)
skipDeliberately not applied; the reason says why

diff --exit-code exits 0 only when every change is unchanged.

The repo​

Apply never re-registers. The path must be a checkout whose origin is the file's originUrl; otherwise the repo is a conflict (repo-path or origin-mismatch) and the crons skip. A registration at that path is the repo; the same origin registered at another path is a conflict (registered-elsewhere); otherwise apply registers it. Each stated displayName, setupScript, sentryOrg and settings key that differs is an update; absent keys are left alone. defaultBaseBranch is set only when registering, so a difference on an existing repo is a skip (registration-only).

Secrets are read from their BOSS_* variables only. A set variable that differs from the stored value is an update rendered as <secret>; equal is unchanged; unset with a stored value is unchanged with a warning; unset with nothing stored is a skip. Apply only ever sets a secret: it never clears one and never prints a value.

Crons​

A cron is found by name. A factory cron with no row of its name also finds an unmarked row whose prompt starts with the stage skill (/boss-build or $boss-build): exactly one is adopted and renamed to the file's name; several are a conflict. Rows already carrying this project's marker are never adopted under another name.

Live rowAction
nonecreate
this project's markerupdate or unchanged
no markeradopt
another project's markerconflict (managed-by-other)
own row the file no longer runs heredisable, or delete with --prune
unmarked row the file does not namenever touched, even with --prune

Only fields the file states are compared; a factory cron always compares its prompt, gate and agent, and its omitted schedule is the stage default on create only. An omitted enabled leaves the live state alone (a disabled row stays disabled, with a warning) and creates the row enabled. A factory cron whose gate preflight fails is created disabled, and an update to an existing one is a skip (preflight-failed). An unknown stage is a skip. A gate compares by the command it executes, so a quoting, $HOME or ~ respelling of the same command (for example an absolute, unquoted path to the stage's own gate script) is unchanged.

A plain cron's gateCommand is a repo-relative script path: ./<path> (run without a shell) or node <path>; a bare <path> is stored as ./<path>. Absolute paths, .. segments and characters outside [A-Za-z0-9._/-] are a conflict (gate-path).

Writes run repo, then secrets, then crons (create, update and adopt, then disable, then delete, each by name). Each class stops at its first failed write and the next class still runs; a failed registration leaves the crons unreached. A bossd too old to store the ownership marker echoes rows without it: that cron is reported daemon-too-old and the cron class stops.

Trust​

Before any write the checkout must be on the repo's default branch (the file's repo.defaultBaseBranch, else the registered default, else origin HEAD), or pass --allow-branch (a detached HEAD counts as off-branch). A plan that creates a cron or changes a cron's prompt or gate (or, with --cloud, creates a trigger or webhook, changes a trigger's prompt or gate, or rotates a secret) is shown and confirmed on a terminal; elsewhere it needs --yes, and without it nothing is written on any leg and the command exits non-zero. Repo settings, secrets, schedules and disables need no confirmation. diff and --dry-run skip both checks.

Cloud triggers and webhooks​

--cloud adds a leg to the plan rather than replacing the local one: the same run plans this daemon, your cloud triggers, the organization's session webhooks and the crons the file places on your other daemons, passes one trust gate, and prints one report. Writes run local first, then triggers, then webhooks, then your other daemons' crons; each class stops at its first failed write and the next class still runs. A file without a cloud section, or without a triggers or webhooks array, leaves that class alone (see Crons on your other daemons).

Organization. cloud.organization, else --org, else the repository's organization mapping, else your only organization. --org disagreeing with cloud.organization is an error before any request, and so is --cloud without boss login.

Paid plan first. The first cloud request lists your triggers, which runs the paid-plan check. If it fails, the command exits non-zero having written nothing on any leg, the local daemon included.

Permissions. Existing permissions apply:

  • Triggers are yours alone. Only your own triggers in the organization are read and converged; another member's trigger is never read or edited. A trigger is adopted only when it is unmarked and watches this repository's origin.
  • Session webhooks need an active owner of the organization. For anyone else every webhook is a skip (owner-required) while triggers and the local leg still converge, and the command exits non-zero.

Identity; comparison. A trigger is found by name, a webhook by url. The action table for crons applies (create, update, adopt, managed-by-other conflict, disable or --prune delete of own rows the file dropped; unmarked and foreign rows are never touched). Fields compare through the same conversion export uses, with absent fields read as the server's defaults, so an exported file applies and then diffs clean. An omitted enabled leaves the live state alone, as for crons. A trigger's type cannot change in place: that is a conflict (type-change). A webhook bosso marks is_retire_only, an endpoint whose creator is no longer an owner, which allows only disabling or deleting while the organization does not share sessions, comes without its URL and so has no identity to match: apply leaves it alone (warning when it carries this project's marker), --prune never deletes it, and export leaves it out with a warning.

ReasonMeaning
owner-requiredWebhooks need an active organization owner
managed-by-otherThe trigger or webhook carries another project's marker; both projects see the conflict
managed-by-other-memberAnother member already has a trigger with this project's marker and name, or a webhook with its marker and URL that you cannot see (found on write)
daemon-unmatchedA specific-daemon placement names an entry none of your daemons matches
ambiguousTwo of your daemons match the placement's entry, or two rows match one identity
secret-unresolvedThe webhook's secret names an unset BOSS_* variable; it is not created without it
create-via-cliThe write would mint a secret and the caller cannot show it (MCP)
refusedbosso refused the write (for example the organization's webhook cap); the detail says why
server-too-oldbosso accepted a write without storing the marker; that class stops

Placement. {"mode": "specific-daemon", "daemon": "<name>"} resolves through your own daemons: an entry pinned by daemonId matches that daemon, otherwise a daemon whose hostname is the entry's name.

Secrets. bosso mints a secret when it creates an HTTP trigger, when it creates a webhook whose entry has no secret, and on a rotation without a resolvable reference. Each is printed to stderr the moment it arrives, before the next write, and never appears on stdout, in --json or in a log: store it then, because it is not shown again. A webhook secret reference is resolved only on create. --rotate-secrets rotates every HTTP trigger and webhook the file keeps that already carries this project's marker (a webhook to its resolved reference, else a minted one).

Trigger creates, changes to a trigger's launch prompt or gate, webhook creates and every rotation need confirmation, under the same rules as cron prompts below.

Crons on your other daemons​

With --cloud, the crons the file places on a daemons[] entry other than this daemon's are converged on that machine through the cloud, aimed at its daemon id. This daemon's own crons are always written over its socket by the local leg and never through the cloud. Once the file has a crons key, every other entry is classified from your organization's daemon list before any write, including one that no longer runs a cron, so the own-marker rows it dropped are still disabled (or deleted under --prune); a failed daemon-list read aborts the apply:

Entry matched byResult
this daemonnot in the cloud leg (the local leg applies it)
one of your daemons, online, serving the repoits crons are planned with the cron rules
one of your daemons that does not serve the reposkip (repo-not-registered)
one of your daemons, offlineskip (daemon-offline), nothing sent
two or more of your daemonsconflict (ambiguous)
only another member's daemonskip (not-your-daemon)
nothing connected to your account in the organizationskip (daemon-unmatched)

Matching is the same as for this daemon: an entry pinned by daemonId matches by id, otherwise a unique unpinned entry whose name is the daemon's hostname. The cron rules are the local ones (adopt, managed-by-other conflict, disable or --prune delete of own rows), applied to that daemon's rows for the repo. Factory crons resolve from the stage table without any host path: a factory cron with no row there is a skip (needs-local-apply; run boss project apply on that machine, since its gate embeds that host's paths), and an existing row converges every field except the gate command, which stays as that host set it.

When this daemon's routing id is unknown (see This daemon), it cannot be told apart from the rest of the fleet, so every cron on another entry is a skip (local-daemon-unidentified) until bossd restarts.

Writes run daemon by daemon in entry-name order, after triggers and webhooks. Each daemon stops at its first failed write and the next daemon still runs: a daemon that turns out to be unreachable is reported daemon-offline, and one too old to store the ownership marker daemon-too-old. Repo registration and repo settings are never written on another daemon; run boss project apply there for those. Any skip or conflict keeps diff --cloud --exit-code at 1 and makes apply --cloud exit non-zero.

boss init​

With the file present, boss init loads and validates it and runs the branch check before writing anything, then applies it last (after the config write and the factory cron step) through the same engine, never pruning. --cron/--agent/--update-crons take the crons, --no-register the registration and the credential flags the secrets for that run, each with a warning. A refusal leaves init's other steps standing, and the stopped report names the command that finishes the job. Without the file, boss init is unchanged.

Over MCP​

export_project_config, diff_project_config and apply_project_config run the same engine for an agent, with the file's contents passed as the config argument (decoded and validated like the file, before any request). The server picks the scope: the local bin/mcp server plans this daemon (repo, settings, crons; working_dir is the checkout used when no daemons[] entry matches), and the hosted gateway plans the cloud section and the crons on your daemons (organization_id, else cloud.organization). The CLI's trust rules hold except for the prompt: apply_project_config writes only with "dry_run": false, which stands in for --yes, and locally needs the applied checkout on the default branch unless "allow_branch": true. MCP never prunes, never resolves a BOSS_* reference and never mints a signing secret, so those changes are skips (secret-unresolved, create-via-cli) left for boss project apply; so is a factory cron with no live row, whose gate embeds this host's paths. The result's plan is the --json plan. See MCP project config tools.


Format​

Keys are camelCase. Encoding is canonical: fields in the order below, two-space indent, one trailing newline, arrays sorted by identity (daemons, crons and triggers by name, webhooks by url; eventTypes, payloadFields, trigger filters and each cron's daemons sorted too). Decoding then re-encoding an exported file is byte-identical.

Decode rules​

  • An unknown key at any level is a warning naming its JSON path (crons[1].retries), and is dropped; the file still loads.
  • version is required and must be 1. A newer version is an error naming the supported one: upgrade boss.
  • An unrecognised enum value (factoryStage, cron or trigger concurrencyPolicy, mergeStrategy, trigger type, placement mode) is a warning and marks that row unsupported: later tooling skips it rather than failing the whole file.
  • Validation reports every problem at once, each prefixed by its JSON path.

Absent versus empty​

An absent daemons, crons, cloud, cloud.triggers or cloud.webhooks means the file does not manage that class of resource. An explicit [] means it does, and the desired set is empty.

For rows the file owns (crons, triggers, webhooks), an absent field means the server default, except enabled, where absent means enablement is not managed. For the shared repo row, every absent setting means "leave the live value alone". Export therefore writes every observed repo setting, and only non-default row fields (enabled only as false).

Secrets​

The file never contains a secret value. A secret is a reference to an environment variable:

{ "env": "BOSS_LINEAR_API_KEY" }

A string literal, an extra key, or a name not matching ^BOSS_[A-Z0-9_]+$ is a decode error, and the error never repeats the value. Export writes BOSS_LINEAR_API_KEY and BOSS_SENTRY_AUTH_TOKEN references only when the live credential is set. Note that boss init reads LINEAR_API_KEY, while the file references BOSS_LINEAR_API_KEY. Webhook signing secrets are never exported; triggers carry none.

Ownership marker​

Rows a project config owns are marked boss-project:<project>, where <project> matches ^[a-z0-9][a-z0-9-]{0,62}$. Cron jobs carry it in bossd; triggers and session webhooks carry it in the orchestrator, where one organization holds at most one trigger per marker and name (across members) and one webhook per marker and URL.


Fields​

Top level​

KeyRequiredMeaning
$schemanoSchema reference for editors; preserved
versionyes1
projectyesProject slug (see the ownership marker)
repoyesThe repository row
daemonsnoDaemons that run crons, and their checkout paths
cronsnoScheduled jobs
cloudnoOrganization, triggers and session webhooks

repo​

KeyMeaning
originUrlRequired; compared in canonical https://<host>/<owner>/<repo> form
displayNameDisplay name
defaultBaseBranchBase branch
setupScriptSetup script
sentryOrgSentry organization slug
settingscanAutoMerge, canAutoMergeDependabot, mergeStrategy (merge, rebase, squash), canAutoRepair, shouldArchiveSessionsAfterMerge, canAutoDeleteBranches, shouldKeepBranchesCurrent
secretslinearApiKey, sentryAuthToken, each a secret reference

daemons[]​

KeyMeaning
nameRequired, unique; matched to a daemon's display name
daemonIdPins the entry to one daemon's routing id; unique when set
pathCheckout path on that daemon: absolute or ~/-relative

A cron must name a daemon entry that has a path.

crons[]​

KeyMeaning
nameRequired, unique
factoryStageplan, build, verify or retro: a symbolic factory cron
agentAgent runner; for a factory cron, absent lets apply pick one as boss init does
modelModel id
promptRequired for a plain cron; forbidden on a factory cron
schedule5-field cron expression or descriptor; required for a plain cron
timezoneIANA time zone; absent means daemon-local
gateCommandPlain crons only: ./<path> or node <path>, with a repo-relative path
runSetupCommandRun the setup script first (default true)
zeroOutputZero-output job (default false)
concurrencyPolicyskip (default), cancel-in-progress or allow-concurrent
enabledAbsent: not managed
daemonsdaemons[] names that run it; is omittable only when exactly one entry exists

A factory cron is symbolic: the stage defines its prompt and gate, so the same job exported on two machines (different home directories, different node paths) yields the same entry. Export classifies a job as factory only when its prompt is exactly the stage's prompt for its agent; anything else (for example /boss-build BOS-1) exports as a plain cron, and a gate command that is not portable is dropped with a warning that it might embed host paths. A plain row without gateCommand leaves the live gate alone, so applying the file on the daemon it came from adopts the job and keeps that gate; only a daemon that creates the row from the file runs it ungated.

cloud​

KeyMeaning
organizationOrganization id
triggersInbound triggers on this repository
webhooksThe organization's session webhooks

cloud.triggers[]​

KeyMeaning
nameRequired, unique
typeRequired: http or github
http / githubThe type's config (protojson HTTPTriggerConfig / GitHubTriggerConfig); only the matching arm
launchRequired: protojson TriggerLaunchSettings
filtersprotojson TriggerFilter list; all must match
payloadFieldsPayload fields copied into the launch context
gateprotojson TriggerGate; scriptPath is a clean relative path
placementRequired (below)
concurrencyPolicyskip-if-running (default), allow-parallel or cancel-in-progress
cooldownSecondsMinimum gap between launches, >= 0
enabledAbsent: not managed (created enabled)

The protojson objects follow the trigger API messages. Placement is one of:

{ "mode": "first-available" }
{ "mode": "specific-daemon", "daemon": "<daemons[].name>" }
{ "mode": "specific-daemon", "daemonId": "<daemon id>" }

Export names the daemons[] entry when a trigger is pinned to the local daemon, and keeps the raw id otherwise. Apply resolves a named entry through your own daemons (see Cloud triggers and webhooks).

cloud.webhooks[]​

KeyMeaning
urlRequired, absolute, unique
descriptionDescription
eventTypesRequired, at least one
enabledAbsent: not managed (created enabled)
secretA secret reference, used only on create

A webhook's payload format is not part of v1; export warns when a webhook uses a format other than raw.


Example​

An export of a repository with two factory crons, one plain cron, three triggers and two webhooks:

{
"version": 1,
"project": "example",
"repo": {
"originUrl": "https://github.com/acme/example",
"displayName": "Example",
"defaultBaseBranch": "main",
"setupScript": "make setup",
"settings": {
"canAutoMerge": true,
"canAutoMergeDependabot": false,
"mergeStrategy": "squash",
"canAutoRepair": true,
"shouldArchiveSessionsAfterMerge": true,
"canAutoDeleteBranches": true,
"shouldKeepBranchesCurrent": false
}
},
"daemons": [
{
"name": "laptop",
"daemonId": "d-laptop",
"path": "~/code/example"
}
],
"crons": [
{
"name": "factory-build",
"factoryStage": "build",
"agent": "claude",
"schedule": "*/15 * * * *"
},
{
"name": "factory-plan",
"factoryStage": "plan",
"agent": "claude",
"schedule": "*/15 * * * *"
},
{
"name": "nightly-report",
"agent": "codex",
"model": "gpt-5",
"prompt": "Summarise yesterday's merges.",
"schedule": "0 6 * * *",
"timezone": "Europe/London",
"gateCommand": "node scripts/has-work.mjs",
"runSetupCommand": false,
"zeroOutput": true,
"concurrencyPolicy": "allow-concurrent",
"enabled": false
}
],
"cloud": {
"organization": "org-1",
"triggers": [
{
"name": "anywhere",
"type": "github",
"github": {
"eventTypes": [
"push"
]
},
"launch": {
"isZeroOutput": false,
"promptTemplate": "Review it.",
"shouldRunSetupCommand": true
},
"placement": {
"mode": "first-available"
}
},
{
"name": "local-pin",
"type": "github",
"github": {
"eventTypes": [
"pull_request.opened"
]
},
"launch": {
"isZeroOutput": false,
"promptTemplate": "Review it.",
"shouldRunSetupCommand": true
},
"placement": {
"mode": "specific-daemon",
"daemon": "laptop"
}
},
{
"name": "remote-pin",
"type": "http",
"http": {},
"launch": {
"isZeroOutput": false,
"promptTemplate": "Review it.",
"shouldRunSetupCommand": true
},
"filters": [
{
"field": "body.env",
"operator": "TRIGGER_FILTER_OPERATOR_EQUALS",
"values": [
"prod"
]
}
],
"payloadFields": [
"body.ref"
],
"gate": {
"scriptPath": "scripts/gate.sh"
},
"placement": {
"mode": "specific-daemon",
"daemonId": "d-other"
},
"concurrencyPolicy": "cancel-in-progress",
"cooldownSeconds": 30,
"enabled": false
}
],
"webhooks": [
{
"url": "https://hooks.example.com/a",
"description": "Team",
"eventTypes": [
"session.merged"
],
"enabled": false
},
{
"url": "https://hooks.example.com/b",
"eventTypes": [
"session.failed",
"session.passing"
]
}
]
}
}