Spec

Configuration

Purpose

Configuration is the single source of truth for every runtime knob. Every other component reads from the typed LastLightConfig value the harness loads at boot; other spec pages cite this one rather than redocumenting env vars locally.

The config layer’s job is to parse the environment, validate the non-negotiable bits (the GitHub App PEM, if present, must exist and parse), apply defaults, and expose a typed object the rest of the process consumes. Malformed JSON inputs (LASTLIGHT_MODELS, etc.) log a warning and fall back — they don’t crash boot.

Schema

interface LastLightConfig {
  port: number;
  webhookSecret: string;
  botLogin: string;
  dbPath: string;
  workflowDir: string;
  stateDir: string;
  sandboxDir: string;
  sessionsDir: string;
  model: string;                          // provider/model, e.g. "anthropic/claude-sonnet-4-6"
  models: ModelConfig;                    // { default: string; [taskType: string]: string }
  variants: VariantConfig;                // { default?: string; [taskType: string]: string | undefined }
  maxTurns: number;
  sandbox: "gondolin" | "docker" | "none";
  githubApp?: {
    appId: string;
    privateKeyPath: string;
    installationId: string;
  };
  slack?: SlackConfig;
  approval?: Record<string, boolean>;     // gate-name → enabled
  bootstrapLabel: string;
  exploreDefaultRepo?: string;
  publicUrl?: string;
  reviewPostsCheck: boolean;
}

interface SlackConfig {
  botToken: string;
  appToken: string;
  allowedUsers: string[];
  deliveryChannel?: string;
}

Defined in src/config.ts:74–143. Loaded once at boot, never mutated. A re-implementation should treat this object as effectively Readonly — any per-task overrides are layered over the base config at dispatch time, not back into it.

Env vars, by group

The defaults below are what the harness produces if the var is unset. Required vars are fatal only if the feature they gate is needed — missing GITHUB_APP_ID is fine for a chat-only deployment.

GitHub App

VarRequired forDefault
GITHUB_APP_IDGitHub integration
GITHUB_APP_INSTALLATION_IDGitHub integration
GITHUB_APP_PRIVATE_KEY_PATHGitHub integration./secrets/app.pem
WEBHOOK_SECRETwebhook signature verificationempty (verification disabled)
BOT_LOGINself-event filteringlast-light[bot]

The PEM is validated at boot: must exist and parse as PEM (src/index.ts:42–51). Missing or malformed PEM exits 78.

Slack

VarRequired forDefault
SLACK_BOT_TOKENSlack at all
SLACK_APP_TOKENrequired if bot token set (Socket Mode)
SLACK_ALLOWED_USERSallowlist (comma-separated user IDs)empty = all allowed
SLACK_DELIVERY_CHANNEL / SLACK_HOME_CHANNELcron report destinationnone
SLACK_OAUTH_CLIENT_ID / SLACK_OAUTH_CLIENT_SECRET / SLACK_OAUTH_REDIRECT_URI”Login with Slack” for dashboardnone
SLACK_ALLOWED_WORKSPACErestrict OAuth to one teamnone

Presence of SLACK_BOT_TOKEN gates the slack config sub-object. Without it, the Slack connector never registers.

Models and reasoning

VarPurposeDefault
LASTLIGHT_MODEL / OPENCODE_MODELbase model for all phasesanthropic/claude-sonnet-4-6
LASTLIGHT_MODELS / OPENCODE_MODELSper-phase model overrides (JSON){}
LASTLIGHT_THINKING / OPENCODE_VARIANTbase reasoning-effort(provider default)
LASTLIGHT_THINKINGS / OPENCODE_VARIANTSper-phase reasoning overrides (JSON){}
ANTHROPIC_API_KEYprovider auth
OPENAI_API_KEYprovider auth
OPENROUTER_API_KEYprovider auth

OPENCODE_* names are kept as legacy aliases — the runtime is now agentic-pi / pi-ai, but production deployments may still set the old names and we don’t want to break them. New deployments should prefer LASTLIGHT_*.

JSON parse failures on *_MODELS / *_VARIANTS log a warning and use {} — they do not crash boot.

Models / variants override JSON

LASTLIGHT_MODELS={
  "default":   "anthropic/claude-sonnet-4-6",
  "architect": "anthropic/claude-opus-4-7",
  "chat":      "anthropic/claude-haiku-4-5",
  "triage":    "openai/gpt-4-turbo"
}

LASTLIGHT_THINKINGS={
  "default":   "low",
  "architect": "high",
  "reviewer":  "high",
  "triage":    "minimal"
}

Keys are phase names from YAML workflows (e.g. architect, reviewer) or skill types (e.g. chat, triage). default is the catch-all. Resolution at dispatch (src/config.ts:296): per-type if present, else default, else the base LASTLIGHT_MODEL. Thinking values are pi-ai’s ThinkingLevel: off | minimal | low | medium | high | xhigh.

Sandbox

VarPurposeDefault
LASTLIGHT_SANDBOXbackend: gondolin / docker / nonegondolin
MAX_TURNSagent loop budget per session200
SANDBOX_MEMORY_LIMITdocker only2g
SANDBOX_DATA_VOLUMEdocker only — named volume or bind-mount pathlastlight_agent-data
LASTLIGHT_SANDBOX_NETWORKdocker onlylastlight_sandbox-egress

Unknown LASTLIGHT_SANDBOX values log a warning and fall back to gondolin. none is for local dev only — no isolation.

State and paths

VarPurposeDefault
STATE_DIRroot for all persistent state./data
DB_PATHSQLite file$STATE_DIR/lastlight.db
LASTLIGHT_SESSIONS_DIRJSONL session envelopes (dashboard reads here)$STATE_DIR/agent-sessions
WORKFLOW_DIRYAML workflow definitions./workflows
WEBHOOK_PORT / PORTwebhook listener port8644

Approval gates

VarFormat
APPROVAL_GATEScomma-separated gate names, e.g. post_architect,post_triage

Parsed into Record<string, boolean> (src/config.ts:242–248). A phase declaring approval_gate: post_architect only pauses if post_architect appears in the map. Missing names are implicitly disabled — there is no “enable all” mode.

Dashboard

VarPurposeDefault
ADMIN_PASSWORDgate dashboard loginempty (no auth)
ADMIN_SECRETHMAC secret for session cookieslastlight-dev-secret
PUBLIC_URLabsolute base URL for outbound linksderived from DOMAIN or unset
DOMAINTLS domain, used to derive PUBLIC_URL as https://<DOMAIN>unset

ADMIN_SECRET’s default is unsafe in production — it must be replaced.

Web search (opt-in per phase)

VarProvider
TAVILY_API_KEYTavily
EXA_API_KEYExa
BRAVE_SEARCH_API_KEYBrave

These are forwarded into the sandbox env only when the dispatching phase declared web_search: true in its YAML (src/engine/agent-executor.ts:116–123). Auto-detection precedence: Tavily > Exa > Brave. Provider API keys (Anthropic / OpenAI / OpenRouter) are forwarded unconditionally.

Misc

VarPurposeDefault
BOOTSTRAP_LABELlabel for issues that set up missing guardrailslastlight:bootstrap
EXPLORE_DEFAULT_REPOowner/name — destination for Slack-initiated explore publishunset (must be set or run fails at publish phase)
REVIEW_POSTS_CHECKpost a Check Run on PR head SHA after pr-reviewfalse
LASTLIGHT_GIT_CREDENTIALSinline credentials for private repos without App accessunset
LASTLIGHT_WRITE_GLOBAL_GITwhen "1", configure git globally not just per-repo0

CLI client

The npm run cli thin client (src/cli.ts) reads its own env:

VarPurposeDefault
LASTLIGHT_URLserver URLhttp://localhost:8644
LASTLIGHT_TOKENauth token (checked against ADMIN_PASSWORD)empty

Secrets layout

The GitHub App PEM is the only secret with a non-env home. Layout inside the harness process:

secrets/app.pem                     ← original (mode 600)
$STATE_DIR/secrets/app.pem          ← copy populated by deploy/entrypoint.sh
                                      so sandboxes on the shared volume can
                                      reach it, but only when allowed

The PEM is read by the harness itself to mint installation tokens (src/engine/git-auth.ts). Sandboxes receive the minted token (GIT_TOKEN env), not the PEM. The PEM only reaches a sandbox when the access profile sets allowMcpAppAuth: true (currently only the repo-write profile for the build cycle), and even then via the shared secrets volume — never inlined in env or sandbox args.

Low-trust sandboxes get GITHUB_APP_PRIVATE_KEY_PATH="" explicitly to short-circuit any inadvertent PEM reads (src/engine/agent-executor.ts:80–82).

STATE_DIR tree

Created at boot (src/index.ts:78):

$STATE_DIR/
├── lastlight.db           SQLite — see §10
├── logs/                  structured harness logs
├── sandboxes/             cloned repos, one dir per taskId
├── secrets/
│   └── app.pem            mode-600 copy of the GitHub App PEM
├── agent-sessions/        JSONL envelopes, one file per agent session.
│                          Dashboard reads from here.
└── proxy/                 generated egress firewall configs
    ├── nginx-strict.conf
    ├── nginx-open.conf
    ├── Corefile.strict
    └── Corefile.open

proxy/ is regenerated on every harness boot from the allowlist in src/sandbox/egress-allowlist.ts — bind-mounted read-only into the firewall containers.

Invariants

  • PEM never reaches a sandbox by default. Only the repo-write profile gets it, and only via the shared secrets volume — never via env, args, or stdin.
  • Empty WEBHOOK_SECRET is permitted but logs a warning. In production this is dangerous; in dev it’s necessary for ngrok-style setups. The choice is on the operator.
  • Defaults are dev-safe, not prod-safe. ADMIN_SECRET is the most obvious example — its default explicitly contains dev. A production config validator (out of scope for the harness) is the right place to refuse boot on dev defaults.
  • JSON config never fails-closed. Both LASTLIGHT_MODELS and LASTLIGHT_THINKINGS log on parse error and use {}. The cost is a silent fall-back to the default model — acceptable because the alternative would refuse to boot a working harness over a typo.
  • APPROVAL_GATES is positive enable, never negative disable. There is no APPROVAL_GATES=* shortcut. A re-implementation that wants one-line “enable everything” should add an explicit token like all, not silently treat missing as enabled.
  • OPENCODE_* aliases stay. They are the legacy names from when the runtime was OpenCode; they will keep working. New env should use LASTLIGHT_* for clarity.

Current implementation

Single file: src/config.ts. Schema at 74–143. JSON parsers for models/variants at 265–281 and 313–327. Approval-gate parser at 242–248. Public URL resolution at 229–234. Sandbox backend selection at 206–214.

Per-task resolvers — resolveModel(models, taskType), resolveVariant() — sit alongside the schema (296–297, 336–340) and are called from the runner and dispatch closure, not from the config loader itself.

Rebuild notes

  • Layered config, not flattened. Keep base + per-task-override separate. Flattening them at load time means future per-task knobs require a config schema change instead of a JSON-blob update.
  • Validate at boundary, not at use. The harness’s pre-flight check is the right place for fatal validation. Once LastLightConfig is built, downstream code should not have to re-check field shapes.
  • Type the variant level. Even if you load it from a string env var, parse to a typed enum at the boundary so thinking: "wat" fails fast instead of silently degrading to a provider default.
  • Pick semantic exit codes. A re-implementation in Go / Rust / etc. should still distinguish “this won’t work no matter how many times you restart” (use 78 EX_CONFIG) from “I crashed” (any other code).
  • Secrets layout is enforceable. A re-implementation can go further and refuse to read the PEM unless it’s mode-600 and owned by the process user. Last Light’s current check is structural (the file exists and parses); a hardened version should check the FS metadata too.
  • Forward per-provider keys conservatively. Provider API keys reach the sandbox; web-search keys reach it only when the phase opts in. A new key category should default to not forwarded — opt-in is the safe default.