Configuring a Beach Application

A Beach application is described by a small tree of YAML files. The loader reads them at startup, applies a per-collection cascade, and hands the application a frozen, typed object. Adopters who prefer to construct the primitives directly from TypeScript can still do so — the YAML route is what the documentation recommends, because Beach's brand promise is that the integration surface diffs in code review, not in the orchestrator's setup file.

The three principles, in order:

  1. Config is data. YAML on disk, validated by JSON Schema. Other languages read the same files.
  2. Three orthogonal axes. Functional (what the application is), environmental (where it runs and how it connects), secret (credentials only). Each axis has its own files, schemas, and review path.
  3. Defaults everywhere. Most fields take their value from a per-model or per-kind cascade, so a 200-actor application's per-actor files stay short.

The default scaffold

beach-config init lays down this layout:

config/
  beach.yaml                       # umbrella: pulls everything in
  beach.functional.yaml            # behavioural defaults
  beach.environment.yaml           # operational defaults
  beach.environment.<env>.yaml     # per-env overlays (optional)
  beach.secrets.env                # credentials only (gitignored)

  models/                          # one file per model class
    haiku.yaml
    sonnet.yaml
    opus.yaml

  actors/                          # one file per actor
  channels/                        # one file per channel
  tools/                           # one file per tool

The directory tree is the application's table of contents. Open it, read the filenames, and you have the inventory: which models the application uses, which actors run, which channels it reaches, which tools it exposes. A beach-config tree walk reads the same data and prints it as one navigable view.

The three axes

Axis Files Reviewer
Functional beach.functional.yaml, files under actors/, tools/, models/ App engineers, code review
Environmental beach.environment.yaml, beach.environment.<env>.yaml, files under channels/ Ops, deployment review
Secret beach.secrets.env Secrets owner, rotation gate

The functional axis describes what the application is. A change here is a feature change: a new actor, a new tool, a different model, a different prompt. It diffs through the same review process as the orchestrator's code, because changing it changes what the application does.

The environmental axis describes where the application runs. A change here is an operational change: a new Redis URL for production, a faster poll interval for staging, a stricter log level for prod. It diffs through deployment review.

The secret axis carries credentials only. Anything that is not a credential — a hostname, a port, a URL without embedded credentials, a model name, a feature toggle — belongs in one of the other two axes. The validator flags the leakage explicitly.

${VAR} references are permitted in environmental files (and in the per-environment overlays) for credential-bearing fields. The loader resolves them against beach.secrets.env first, then process.env, and throws if a reference resolves to nothing. References in the functional axis are rejected at validation time; the message points the operator at the correct file.

Where does this go?

Operators and engineers running into "where does this setting belong?" should consult the table:

Setting File Diffed by
Orchestrator prompt actors/<id>.yaml App engineers, code review
Tool list for an actor actors/<id>.yaml App engineers, code review
domainDataSchema for a triage actor actors/<id>.yaml App engineers, code review
Cumulative token budget strategy beach.functional.yaml App engineers, code review
Default head strategy beach.functional.yaml App engineers, code review
Cascade-suppression behaviour beach.functional.yaml App engineers, code review
Whether MCP / A2A / REST is enabled beach.functional.yaml App engineers, code review
Redis URL beach.environment.yaml Ops, deployment review
IMAP host and port beach.environment.yaml Ops, deployment review
Log level beach.environment.yaml Ops, deployment review
Poll interval beach.environment.yaml Ops, deployment review
OTLP endpoint beach.environment.yaml Ops, deployment review
ANTHROPIC_API_KEY beach.secrets.env Secrets owner, rotation gate
IMAP_PASS, SMTP_PASS beach.secrets.env Secrets owner, rotation gate

The table is the single most useful artefact in the configuration documentation. A new contributor reads it and answers "where does this go?" without further reasoning.

The cascade

Every collection — actors, channels, tools, models — follows the same three-layer cascade: instance → perKind/perModel → defaults.

# beach.functional.yaml
beach:
  version: 1
  actors:
    defaults:
      maxTokens: 4096
      temperature: 0.7
    perModel:
      claude-haiku-4-5:
        maxTokens: 1024
        temperature: 0
        inject: { maxInjectTokens: 50000 }
      claude-sonnet-4-6:
        maxTokens: 8192
        inject: { maxInjectTokens: 200000 }

A kind: llm actor whose model: is claude-haiku-4-5 inherits maxTokens: 1024 from the perModel layer; an actor whose model is claude-sonnet-4-6 inherits maxTokens: 8192. Either can override the field on its own file. The cascade closes over what the actor file does not declare.

Tools and channels use perKind instead of perModel. The shape is otherwise identical.

The cascade is what keeps a 200-actor application's per-actor files short. A typical actor file is three or four fields — model, prompt, tools — and inherits everything else.

Inline or split

Two layouts, equivalent at the loader level. Pick the one that fits the application's size:

Split (default). One file per actor, channel, tool, model. The filesystem tree carries the inventory. beach-config init writes this layout.

Inline. Everything in beach.functional.yaml's inline: blocks. Fine for prototypes; gets unwieldy past five or six entries per section.

my.cnf-style include directives mix and match: a team can keep common actors in actors/, declare a few specialist actors inline, and pull in a third group from actors/specialists/ through an includeGlob directive. The loader assembles them all into one inventory.

beach-config split <section> and beach-config merge <section> move a section between the two layouts without changing anything the application sees.

Models

Three model files ship with the scaffold: haiku.yaml, sonnet.yaml, opus.yaml. Each declares what Beach uses to drive its behaviour — context window, default maxTokens and temperature, the inject budget — and the matches: array of model versions the file applies to.

# config/models/haiku.yaml
familyAlias: haiku
matches:
  - claude-haiku-4-5

contextWindow: 200000
maxTokens: 1024
temperature: 0
inject:
  maxInjectTokens: 50000
  headStrategy: first-n

The model files are user-editable; beach-config update-models is the opt-in command that refreshes them when Anthropic ships a new version. The default refresh strategy appends new entries to matches[] and leaves the team's other customisations alone.

Beach maintains no cost or pricing data. A team that wants cost reporting writes its own analyser; the file format is stable.

Prompts

The actor schema accepts three forms of prompt declaration:

  • systemPromptInline: — inline in YAML. For prompts a team is happy editing as a YAML scalar.
  • systemPromptFile: — a path to a markdown file. For prompts maintained outside YAML by a different toolchain.
  • Neither field — the application supplies the prompt at construction time. For applications that compose prompts from data, fragments, templates, or anything else.

Beach validates that one of the three resolves to a string by the time runTurn is called, and is silent on prompt management beyond that. No directory imposed, no format imposed.

Application-side functional config

Beach is silent on the shape of an application's own functional config, but the config tree is the right place to keep it. Three reserved namespaces give consumers room without forcing Beach to validate anything it has no opinion about:

  • Top-level app: on the functional file — application-wide settings. The application's startup code reads config.functional.app.<your-field>.
  • Per-instance app: on actor / channel / tool files — settings scoped to that one thing (a per-channel A2UI catalogue list, a per-actor system-prompt fragment, a per-tool budget).
  • app.collections.<name> on the functional file — consumer-defined collection-shaped data using the same inline | include | includeGlob | includeDir | includeOptional vocabulary Beach's own collections use. The loader walks the named directories and surfaces the assembled instances at config.functional.app.collections.<name>.instances.

Recommendation: prefer app.collections.<name> for any list of similar-shaped things — branding profiles, feature flag sets, catalogue declarations, registry entries. Why: every adopter who hand-rolls a parallel config tree alongside Beach's loses the same loader, the same beach-config tree view, and the same auditability that Beach's own collections enjoy.

# beach.functional.yaml — a branding-collection example
beach:
  version: 1
  app:
    collections:
      branding:
        includeDir: [branding/]
# branding/light.yaml — application-shaped, contents governed by the
# application's own JSON Schema (passed to --with-app-schema).
primary: "#0066cc"
background: white

Beach validates the shape of each entry (it is an object) and is otherwise silent. To bring app: blocks under per-file checking, ship a JSON Schema with the application and run:

beach-config validate --with-app-schema ./schemas/app.schema.json

Recommendation: ship one of these with every Beach application that has any consumer-defined app: content. Why: it brings consumer fields under the same per-file checking Beach gives its own fields, and a single command answers "is this config tree internally consistent?" across both axes.

YAML or JSON

Beach reads both. The scaffold writes YAML; per-thing files default to YAML; the convention walk picks up .yaml, .yml, and .json indifferently.

Recommendation: hand-edited config files use YAML. Why: YAML supports comments and tolerates trailing whitespace, which humans rely on when reasoning about per-thing files.

Recommendation: machine-edited config files use JSON. Why: JSON has a single canonical serialiser and round-trips cleanly through tooling without having to preserve comments. An admin page in the application that lets an operator edit a feature-flag set or a branding palette is the natural case — the page reads the file, mutates the object, and writes JSON back without worrying about indent levels or string folding.

A directory may mix YAML and JSON during a migration; Beach treats both formats as equivalent at the loader level. Recommendation nonetheless: settle on one format per directory once the migration is complete, because mixed directories are harder for humans to scan at a glance.

What ships in v1, what does not

In v1:

  • The umbrella loader, the validator, and the four CLI inspection commands (init, validate, tree, explain).
  • The split / merge round-trip between layouts.
  • The update-models refresher.
  • fromConfig factories on EventRouter, ManifestRegistry, and SessionTurnManager.

Out of v1:

  • Cost reporting, pricing data, --cost flags. The shipped model files carry no cost fields.
  • Automatic model deprecation. Old versions stay in matches[] until someone removes them.
  • Remote includes (HTTPS URLs, OCI artefacts). Local files only.
  • A runtime config service. Files on disk, read once at startup.

Migration from direct construction

Existing applications that construct primitives directly continue to work — EventRouter.fromConfig and friends are additive. The documentation recommends loadBeachConfig-driven construction for new applications, because it is what makes the integration surface auditable in code review. Existing applications can migrate at their own pace, one primitive at a time.

Related