Docs

Configuration

Last Light layers configuration at startup: the packaged config/default.yaml (non-secret defaults), then an optional $LASTLIGHT_OVERLAY_DIR/config.yaml overlay, then environment variables (via .env in local dev, or instance/secrets/.env in production). A fourth layer — a managed repo's own .lastlight/ — is applied per run, on top of all three; see Per-repo config. The authoritative source is apps/server/src/config/config.ts in the repo; this page mirrors it.

config/default.yaml  →  instance/config.yaml  →  env  →  <repo>/.lastlight/lastlight.yml
     (packaged)            (your overlay)        (env)          (per-repo, bounded)

Config files & overlay

Non-secret config — managed repos, routes, models, variants, approvals, and disabled.* — lives in YAML. The public config/default.yaml ships safe defaults (and an empty managedRepos list). Your deployment overrides go in an overlay selected by LASTLIGHT_OVERLAY_DIR — the docker-compose stack mounts an instance/ folder there as /app/instance.

VariableRequiredDefaultDescription
LASTLIGHT_OVERLAY_DIR No Trusted overlay root. Layers config.yaml over the defaults and overlays assets under workflows/, workflows/prompts/, skills/, agent-context/ (overlay wins by logical name). Secrets are read from its secrets/ subdir. Read at startup — docker compose restart agent to apply.

Merge rules: maps (models, variants, routes, approval) deep-merge over the defaults; arrays (managedRepos, disabled.*) replace; environment variables override both. Secrets (GitHub App key, provider API keys, Slack tokens, admin secret) stay env-only and never appear in the dashboard Config tab, which shows the Default / Overlay / Merged non-secret config.

Per-repo config

A managed repo can commit a .lastlight/ directory that overrides a bounded subset of the above — models, reasoning effort, approval gates, cron participation, prompts, skills, agent context — for runs against that repo only. It's always read from the repo's default branch, so a pull request can never reconfigure the agent reviewing it, and it's bounded by a repoConfig: block you control (ship it disabled with repoConfig.enabled: false if you'd rather keep every knob central). Out of the box it's inert: nothing changes until a repo actually commits one.

Full contract, worked example and the lastlight repo commands: Per-repo config.

GitHub App

Required if you want to manage repos (not required for messaging-only mode).

VariableRequiredDefaultDescription
GITHUB_APP_ID Yes Numeric GitHub App ID from the app settings page.
GITHUB_APP_PRIVATE_KEY_PATH Yes Path to the .pem file you downloaded when creating the app.
GITHUB_APP_INSTALLATION_ID No Optional. Last Light discovers every installation of your app from its own credentials and picks the right one per repository owner, so an app installed on several accounts (your user plus one or more orgs) works with nothing set here. Set it only as a fallback if outbound lookups are blocked.
WEBHOOK_SECRET Yes Matches the webhook secret configured in your GitHub App. Used to verify webhook signatures.
BOT_LOGIN No last-light[bot] Bot login used to filter out the harness's own events from its own event stream.

Models (agentic-pi)

The agent runtime is agentic-pi (workflow phases) plus @earendil-works/pi-ai (in-process chat). It runs on any model the pi coding agent supports — Claude, GPT, Gemini, Llama, and more, as openai/…, anthropic/… or openrouter/<vendor>/<model> strings. Set whichever provider API keys match your selected models — one OpenRouter key covers most of them if you'd rather have a single billing surface.

VariableRequiredDefaultDescription
OPENAI_API_KEY If using OpenAI OpenAI API key. Required if LASTLIGHT_MODEL (or any entry in LASTLIGHT_MODELS) resolves to an OpenAI model.
ANTHROPIC_API_KEY If using Anthropic Anthropic API key. Required if any active model is anthropic/….
OPENROUTER_API_KEY If using OpenRouter OpenRouter API key (sk-or-…). Required if any active model is openrouter/<vendor>/<model>. One key gives you Claude, GPT, Gemini, Llama, Mistral, DeepSeek, etc. through a single endpoint; OpenRouter takes a small per-token markup over going direct. Sign up at openrouter.ai/keys.
LASTLIGHT_MODEL No anthropic/claude-sonnet-4-6 Default model used when no per-task override matches. Must be a provider/model string pi-ai recognises.
LASTLIGHT_MODELS No Per-task-type model overrides as JSON. Keys match phase names or skill types. Do not override chat with a small model — small models tend to refuse tool calls. Example: {"chat":"openai/gpt-5.1-mini","architect":"openai/gpt-5.5"}
LASTLIGHT_THINKING No Catch-all reasoning-effort default. pi-ai translates it to the right per-provider knob (OpenAI reasoning_effort, Anthropic thinking budget, etc.). Allowed values: off | minimal | low | medium | high | xhigh.
LASTLIGHT_THINKINGS No Per-task thinking-level overrides as JSON, same key scheme as LASTLIGHT_MODELS. Example: {"architect":"high","reviewer":"high","triage":"minimal"}.
LASTLIGHT_SANDBOX No gondolin Workflow sandbox backend. gondolin (default) runs each phase in a QEMU micro-VM (HVF on macOS, KVM on Linux — no Docker needed). docker runs each phase in a sibling Docker container (requires the lastlight-sandbox:latest image). smol (experimental) runs each phase in a smolvm micro-VM via the smolvm CLI — stronger isolation, native per-host egress; local-only, needs a host hypervisor. none runs in-process with no isolation — dev only.
MAX_TURNS No 200 Maximum agent turns per invocation. Reserved (kept for API stability).
Two per-task model keys ship set. Every entry in the models: block of config/default.yaml is a commented-out example except review-survey and review-triage, both pinned to anthropic/claude-haiku-4-5-20251001. It is the key the five survey branches of the review evidence pipeline declare, and — having no key of its own — its falsify phase too, so six phases resolve through it. Pinning it is not a cost downgrade: on review recall Haiku 4.5 beats Sonnet 4.6 on two independent evals, and its price is merely what makes a five-way fan-out affordable. The consequence for you: if your models.default points at a non-Anthropic provider, override models.review-survey in your overlay config.yaml as well, or those six phases reach for a provider you have no key for. Nothing happens either way until review.analysis.enabled is on — those phases skip while it is off. review-triage is pinned for a different reason and is not gated on that switch: it is the pass that decides how much review a re-review is owed, so falling through to your main review model would put the expensive model on the one phase whose whole value is being cheap. Override it too on a non-Anthropic deployment.

Provider endpoints — your own gateway

Every provider ships with the vendor's endpoint baked in. Point one somewhere else — a self-hosted gateway, a corporate proxy, an Azure/Bedrock-fronted endpoint — with a providers: block in your overlay config.yaml. That is how a deployment gets central spend accounting, key custody, rate limiting and audit, and it is the only way to reach a model that isn't one of the ~18 registry entries.

providers:
  # A gateway that speaks Anthropic's dialect: only the URL moves. The models,
  # the request shape and ANTHROPIC_API_KEY are all inherited.
  anthropic:
    baseUrl: https://gateway.internal/anthropic

  # A provider Last Light has never heard of. baseUrl is required; api defaults
  # to openai-completions and envKey to ACME_API_KEY, derived from the prefix.
  acme:
    baseUrl: https://llm.corp.example/v1
    api: openai-completions        # or anthropic-messages
    envKey: ACME_API_KEY

On a provider Last Light already knows you may also re-point envKey — that is how a gateway keeps custody of its own credential (GATEWAY_API_KEY rather than the vendor's key). Leave it alone and pi keeps resolving the credential itself, which is what lets an OAuth subscription login keep working behind a moved endpoint. You may not change a known provider's api: its dialect is fixed, and a gateway that speaks a different one is a provider of your own — give it its own prefix, as acme above.

The endpoint reaches every model call — the cheap screener/classifier helpers, the sandboxed workflow phases, and in-process chat — and the sandbox egress firewall follows it automatically, so no separate allowlist edit is needed. lastlight server setup offers this as a provider choice ("Self-hosted / gateway endpoint") and writes the block for you.

VariableRequiredDefaultDescription
ANTHROPIC_BASE_URL, OPENAI_BASE_URL, … No the vendor endpoint Move one known provider's endpoint from the environment. The name is derived from the model-spec prefix (kimi-codingKIMI_CODING_BASE_URL), so the two you are most likely to want match the names the vendor SDKs already use.
LASTLIGHT_PROVIDERS No The same overrides as JSON, and the only env route that can declare a provider the registry has never heard of. Merges per prefix with config.yaml. Example: {"acme":{"baseUrl":"https://llm.corp.example/v1","envKey":"ACME_API_KEY"}}
LASTLIGHT_ALLOW_INSECURE_PROVIDER_URLS No off Permit a plaintext http:// endpoint on a non-loopback host. https:// is otherwise required, because a base URL carries every prompt and every API key; http://localhost:… needs no opt-in.
A gateway URL is not a secret — but a bad one is fatal at boot. The URL is deployment routing, so it belongs in the version-controlled overlay config.yaml (unlike DATABASE_URL); the key it authenticates with stays in secrets/.env. An unusable override — a malformed URL, a custom provider with no baseUrl, plaintext HTTP off-loopback — refuses to boot rather than warning, because quietly ignoring it would send your prompts and your key to the vendor instead. One caveat: a gateway on localhost or a private IP is only reachable from the in-process sandbox backends (gondolin, none), where the model call runs on the host — a container sandbox has its own loopback.
Legacy env vars. All OPENCODE_* names from the OpenCode era are still read as fallbacks for the matching LASTLIGHT_* name — OPENCODE_MODELLASTLIGHT_MODEL, OPENCODE_MODELSLASTLIGHT_MODELS, OPENCODE_VARIANTLASTLIGHT_THINKING, OPENCODE_VARIANTSLASTLIGHT_THINKINGS. Existing .env files keep working; rename at your leisure.

Runtime

VariableRequiredDefaultDescription
PORT / WEBHOOK_PORT No 8644 Port the webhook listener binds to.
STATE_DIR No ./data Base directory for persistent state (DB, sessions, sandboxes, logs). Mount as a Docker volume in production.
DB_PATH No $STATE_DIR/lastlight.db Override the SQLite database path. Unused when DATABASE_URL names a postgres:// server.
DATABASE_URL No unset The state database — a libsql-style URL (file:/app/data/lastlight.db, :memory:) or a postgres://user:pass@host:5432/db URL for an external/managed Postgres. Wins over DB_PATH; also settable as database.url in config.yaml. Unset means file: + the DB_PATH path above — the historical behaviour, so existing deployments need change nothing, and SQLite stays the default. Put a postgres:// URL here, not in config.yaml: the overlay is version-controlled and pushed to a GitHub remote, and redaction happens at render time, which cannot un-commit anything.
DATABASE_DRIVER No auto How a postgres:// URL is carried: pg (node-postgres, a TCP pool — self-hosted, RDS, Cloud SQL, Supabase) or neon (@neondatabase/serverless, a WebSocket pool). Unset auto-detects from the host (*.neon.techneon); set it explicitly only for Neon behind a custom domain.
DATABASE_POOL_MAX No 10 Postgres connection-pool ceiling (database.poolMax). Last Light runs a single instance — Postgres is a storage choice, not multi-instance HA.
WORKFLOW_DIR No ./workflows Directory containing YAML workflow definitions.
LASTLIGHT_SESSIONS_DIR No $STATE_DIR/agent-sessions Directory the dashboard reads sandbox + chat session JSONLs from. event-shim.ts writes Claude-SDK-style envelope jsonl under projects/<slug>/.
SANDBOX_DATA_VOLUME No lastlight_agent-data Used only when LASTLIGHT_SANDBOX=docker. Either a Docker named volume or a host path (starts with /, ./, ../, ~) bind-mounted as /data in each sandbox.
SMOLVM_BIN No smolvm Used only when LASTLIGHT_SANDBOX=smol. Path to the smolvm CLI.
SMOLVM_IMAGE No lastlight-sandbox:latest Used only when LASTLIGHT_SANDBOX=smol. An OCI image reference, or a local docker save archive (./img.tar) / rootfs dir — the archive form loads offline (no registry), so the locally-built sandbox image works under the strict egress allowlist.
LASTLIGHT_LOCAL_DEV No Set to 1 to prevent git-auth.ts from writing to ~/.gitconfig. Used by scripts/dev-local.sh.
BOOTSTRAP_LABEL No lastlight:bootstrap Label applied to issues that exist solely to set up missing guardrails in a target repo.
LASTLIGHT_HOLD_LABEL No lastlight-ignore The hold label. Apply it to any issue or pull request and Last Light stops acting on that subject entirely — no triage, no review, no fix, no merge, no comment. It outranks everything except "we couldn't read the pull request at all", including an explicit @last-light … request, which gets exactly one reply naming the label. Remove it and the bot resumes, with no record to clear. Distinct from requires-human, which the bot writes as a notification and nothing reads. Overlay key: hold.label.
MAX_CONCURRENT_WORKFLOWS No 4 Global cap on how many sandboxed workflow runs execute at once. Excess triggers are persisted as queued in workflow_runs and admitted FIFO as slots free — keeping a burst of events from swamping the host. Overlay key: concurrency.maxWorkflows.
MAX_QUEUE_WAIT_MS No 3600000 (1 hr) How long a queued run may wait before it's dropped (cancelled with a "waited too long" notice). Overlay key: concurrency.maxQueueWaitMs.
LASTLIGHT_HOME No ~/lastlight CLI-side only. Working directory for the host-local lastlight server lifecycle commands (checkout + instance/ overlay + override symlink). Resolves from --home → this env → saved serverHome → default.
LASTLIGHT_GIT_SHA / LASTLIGHT_BUILD_DATE No empty → "unknown" Core git SHA + build date baked into the agent image (Dockerfile ARGs; lastlight server update stamps them). Surfaced by GET /admin/api/server/info for the dashboard "update available" banner.
LASTLIGHT_CORE_VERSION No overlay deploy.version; unset = track main Override the overlay's core-version pin (a git tag/ref). lastlight server update/setup checks core out at it and the drift banner compares against it. main/latest/unset tracks main. See Production.

Approval gates

VariableRequiredDefaultDescription
APPROVAL_GATES No Comma-separated list of gate names to enable. Gate names match approval_gate: fields declared in workflow YAML (e.g. post_architect, post_reviewer). A gate only pauses the run if its name appears here, so you can ship workflows with gates pre-declared and enable them per environment.

Workflow policy — fix, dependencies, review

Three blocks have no environment variable at all: they're budgets and blast-radius dials, and those belong in a file you can review and diff rather than in a shell. They live in config/default.yaml with the values below, and you override them in your instance/config.yaml.

# instance/config.yaml

review:
  postsCheck: false            # post the `last-light/review` Check Run
  trigger: after-checks        # eager | after-checks | on-request
  requestLabel: null           # the label that asks for a review in `on-request`
  skipDraft: true              # skip draft PRs
  generatedPaths:              # a push touching only these earns no re-review
    - "*.lock"
    - package-lock.json
    - pnpm-lock.yaml
    - go.sum
    - "*.min.js"
    - "*.generated.*"
    - "**/__generated__/**"
  skipUnchangedDiff: true      # skip a re-review whose own diff did not move
  triage:                      # how much review a RE-review is owed
    enabled: true
    timeoutSeconds: 300
  analysis:
    enabled: false             # the review evidence pipeline — off, and inert
    maxSpecObligations: 40     # SAFETY BOUND for the `spec` family — should never bind
    maxObligations: 48         # TOTAL backstop over the five facts-derived
                               # families, which are capped PER FAMILY (contract
                               # 12, enforcement 12, state 8, security 8, tests
                               # 8). 48 is their sum, so it cannot bind unless
                               # you raise one of those ceilings
    obligationContract: minimal  # minimal | full — how much each survey records
                               # per obligation. minimal is the measured default
    mint: all-in-diff,registrations  # extra seeding rules beyond the original
                               # four; "" = the pre-D2 baseline set
    surveyPasses: 6            # RECORDED, not consumed — the fan-out branch list
                               # is the authority
    surveyConcurrency: 6       # survey families running at once (a ceiling,
                               # clamped per sandbox backend)
    probes: false              # install deps so a probe can be RUN. Its own switch
    probeLifecycleScripts: false  # let the PR's own postinstall run. Off: it is the
                               # author's code, on your machine
    probeTypecheck: false      # the repo's own tsc --noEmit, for per-line diagnostics
    probeCoverage: false       # the one step that runs a test suite
    prepareTimeoutSeconds: 300
    coverageTimeoutSeconds: 900
    probeRounds: 2
    maxInlineComments: 10      # the attention boundary — findings past this go
                               # to the review body, never dropped
    maxBodyComments: 5         # body-tier budget. N = top N by severity ×
                               # confidence (default 5); 0 = no overflow at all;
                               # null = unlimited
    thresholds:                # per-family confidence bar for an INLINE comment;
      contract: 0.35           # below it, the body
      enforcement: 0.35
      security: 0.30
      state: 0.50
      tests: 0.60
      spec: 0.45
    internalFloor: 0.15        # below this, recorded in findings.json, not posted

fix:                           # every PR-fixing workflow: pr-fix, dependabot-ci-fix
  maxAttempts: 3               # attempts ACROSS runs for one PR, then a human is asked
  localIterations: 2           # gate-loop iterations WITHIN one attempt
  gateTimeoutSeconds: 900      # budget for the repo's own build/test gate
  escalateModelAfterAttempt: 1 # attempts above this use models["pr-fix-retry"]
  maxCostUsd: 5.0              # cumulative ceiling for ONE PR; null = no ceiling
  maxFlakyDeferrals: 2         # "flaky" verdicts before one is taken at face value
  retryableClasses: [reproducible, env-mismatch]

dependencies:
  autoMergeMaxImpact: medium   # none | low | medium | high — ceiling for a MAJOR bump
  requireSettledChecks: true   # no merge decision until the head SHA's checks settle
  minSettledChecks: 1          # …and there must be at least this many
  auditComment: true           # post the evidence comment when auto-merging a major
How the two loop budgets reach the run. fix.localIterations and fix.gateTimeoutSeconds are read by the fix phase itself, which declares max_iterations: { from: fix.localIterations, default: 2 } and timeout_seconds: { from: fix.gateTimeoutSeconds, default: 900 }. The from: path resolves against the run's effective — already repo-clamped — fix block, so a repository that lowers its own budget in .lastlight/ is honoured; the number in the YAML is only the fallback for a run whose context carries no fix: at all.

trigger: after-checks means "once the head SHA's checks settle — either colour". The review can then read and cite the CI result instead of guessing at it, and a push storm collapses to one review per settled SHA. There is deliberately no settled-and-passing mode: a PR whose CI never goes green would then never be reviewed, which is exactly the PR most worth a look.

on-request means the review runs only when somebody asks. Three ways to ask: apply the requestLabel — yours, or one the target repo named in its own .lastlight/, both of which are honoured on the pull_request.labeled webhook — comment @last-light review, or — with postsCheck on — press Re-run on the last-light/review check, which is posted as a neutral conclusion so it never blocks a merge. (GitHub's reviewer picker doesn't offer App bot users, so "request a review from the bot" is handled opportunistically but is not the mechanism to rely on.) Under after-checks with postsCheck on, the check is posted queued while CI runs, so branch protection can require it without racing.

generatedPaths is the answer to a re-review with nothing new to say. Before it, the only thing suppressing a second review was "we already reviewed this exact head SHA" — and a lock file re-derivation is a new head SHA, so a dependency bump could earn two formal reviews minutes apart. When every path changed since the review we posted matches one of these patterns, the re-review is skipped.

It is deliberately hard to trip. It never suppresses a first review, never suppresses an explicit @last-light review or the request label, and never fires when the push also touched a hand-written file — one non-generated path and the review runs. Any read it can't trust (a compare that failed, a diff GitHub truncated) dispatches. Set it to [] to turn it off. Patterns: * stops at a /, ** crosses one, and a pattern with no / matches a basename anywhere in the tree — so pnpm-lock.yaml means the lock file in every package of a workspace.

With postsCheck on, this skip is the one that still leaves a check: the new head SHA gets a completed last-light/review repeating the verdict of the review that still stands. Every other skip leaves the PR alone, but this one would otherwise strand a required check on a head nobody reviewed. Its conclusion mirrors that earlier review, so a CHANGES_REQUESTED carried forward still blocks the merge.

skipUnchangedDiff answers the case no list of paths can. Merging main into your branch changes no line you wrote, but it is a new head SHA — and the delta since the last review is then every file main brought in, which is exactly what generatedPaths must refuse to suppress. One 73-file pull request was reviewed, had a merge commit land eleven minutes later, and paid for the whole review again. So this gate asks the question directly: is the diff of your branch against its base byte-identical to the one we already reviewed? It covers a merge from the base, a rebase that preserves the tree, and an empty force-push — and it does not fire when the base touched a file you also touched, because the merged patch then really is different.

It is as hard to trip as the gate above, and fails the same way. A first review, an explicit request and the check's Re-run button all override it, and any read it cannot trust — a compare GitHub truncated, a binary file it gave no patch for — dispatches a full review. Set it to false to turn it off. It leaves the same carried-over check.

triage is the middle tier, and it ships on. Between "nothing moved at all" and "this is a substantial new push" sits a real but small delta, and re-running the full review over it costs about what the first one cost. So a re-review starts with one cheap pass that reads only what changed since the last review and decides whether the rest is worth running. On light the analysis phases skip and the reviewer makes a single focused pass over the delta; on full, on a first review, or with this off, you get exactly the review you get today. It errs hard toward full: an unclear answer, an unrecognised one and no answer at all are all read as "review everything".

analysis is the review evidence pipeline, and it ships off. enabled: false reproduces the review you get today byte for byte — not "the feature is on but finds nothing": the variables it governs are absent from the agent's context entirely, and the extra GitHub reads it needs are never issued. Switch it on per deployment, once you want it.

What it adds today is a spec axis. Every item in the reviewer's rubric — correctness, contracts, edge cases, security, test coverage — is a standards check, and none of them asks whether the change does what was asked. Turning analysis on hands the reviewer the PR's body and the issues it closes (which it had never been shown), extracts the acceptance criteria out of them, and pairs each one with the files the PR actually changed. The reviewer then has to quote the line that implements a criterion, or say that no changed file does. It also lets the review carry a verdict per axis: a change that is clean by every standards check but doesn't do what the issue asked can no longer be approved on the strength of the half that passed.

probes is a second switch under the same block, and it is separate because it buys something different: a review workspace with a node_modules in it. That one fact is what lets a tsconfig that extends a package resolve — without it the analysis quietly drops a tier and the contract checks find nothing — and it is the only route to a coverage report, without which "no untested changed lines" means nobody measured rather than well tested. The three sub-switches under it are priced separately on purpose: probeTypecheck is seconds and gives per-line diagnostics (it is not a CI re-run — Last Light already reads your CI result); probeCoverage is the one step that runs your test suite, and it never guesses a command, only one your package.json already names.

probeLifecycleScripts is off as a security default rather than a performance one. The install happens at a pull request head, so a postinstall there is code the PR author wrote, running on your infrastructure — and nothing probes exists for needs it.

This one is operator-only — a repo's own .lastlight/ can't turn it on or off. It buys analysis on your budget, and there is no "more conservative" direction for a repo to move it in.

skipDraft matches what the review sweep has always done. A draft PR is skipped on the webhook path too. Marking it Ready for review lifts that skip, but under the packaged after-checks it does not produce a review on its own: it is a PR-attention event, and attention events are deferred in that mode. What actually runs the review is the head SHA's checks settling — or, if no settle event arrives, the 30-minute review sweep. Under trigger: eager, Ready for review is the event that un-defers it.

The fix budgets bound a loop that diagnoses a CI failure before it retries it. maxAttempts and maxCostUsd both bound the current problem, not the PR's lifetime: a push from anyone but the bot is a fresh problem, so it re-arms the attempt counter and the cost window together and clears the escalation with no label to remove by hand. A diagnosis class outside retryableClasses escalates straight away rather than spending budget on a retry that can't help — its members are checked against the five diagnosis classes, so a typo narrows the set with a warning instead of silently making everything terminal. And maxCostUsd ships on: an unbounded agent loop against a genuinely broken PR is the one failure mode that costs real money.

For dependencies, impact rather than semver magnitude is what gates a major bump — a @types/* major is not a framework rewrite. Set autoMergeMaxImpact: none to keep every major in front of a human.

Know which half of that block is enforced where. requireSettledChecks and minSettledChecks are enforced in code: the merge verdict is computed from the head SHA's resolved check state before the run starts and handed to the agent as a decided answer it is told not to re-derive, and requireSettledChecks additionally makes the dispatch gate refuse a PR whose checks are still running. autoMergeMaxImpact is an instruction to the agent: the impact tier is the model's own judgement, reported in the run's completion marker, and the ceiling reaches the run as prompt text — nothing parses the tier or withholds the merge tools from a run that reported one above the ceiling. Lowering the ceiling narrows what the agent is told it may land; it is not a hard stop.

All three are repo-settable, one-way: a repo's .lastlight/ can only ever make itself more conservative than you are. See Per-repo config for the per-key clamps and the three keys a repo can't touch at all.

Build assets

The per-phase build handoff docs — architect-plan.md, status.md, executor-summary.md, reviewer-verdict.md, guardrails-report.md, and the explore-* docs — can live in one of two places, selected by buildAssets.location in YAML (or the LASTLIGHT_BUILD_ASSETS env var). With repo (the default) they're committed into the target repo under .lastlight/<issueKey>/ and ride the working branch — the historical behaviour. With server they're externalized to the Last Light host instead, never committed into the target repo: injected into each phase from outside the repo and harvested back after, PR-body links point at the dashboard's Artifacts view rather than GitHub blob URLs, and the admin API serves them read-only at /admin/api/artifacts.

VariableRequiredDefaultDescription
LASTLIGHT_BUILD_ASSETS No repo Where build handoff docs live: repo commits them into the target repo under .lastlight/<issueKey>/ on the working branch; server externalizes them to the Last Light host and never commits them into the target repo. Equivalent to the buildAssets.location config key.
BUILD_ASSETS_DIR No $STATE_DIR/build-assets Server-mode store root. Docs are written under <owner>/<repo>/<issueKey>/*.md. Only used when LASTLIGHT_BUILD_ASSETS=server.

Admin dashboard

VariableRequiredDefaultDescription
ADMIN_PASSWORD No If set, the dashboard requires password login.
ADMIN_SECRET No random HMAC secret used to sign session tokens. Set this to a stable value in production so sessions survive restarts.

Slack (optional)

Two independent feature groups — see Slack integration for setup.

VariableRequiredDefaultDescription
SLACK_BOT_TOKEN No Bot User OAuth Token (xoxb-...). Presence of this var enables the Slack connector.
SLACK_MODE No auto Receive transport: webhook (HTTP Events API) or socket (Socket Mode). Auto-detected when unset: webhook if SLACK_SIGNING_SECRET is set, else socket.
SLACK_SIGNING_SECRET Yes (webhook mode) Events API signing secret. Slack POSTs events to /webhooks/slack; delivery is retried (at-least-once).
SLACK_APP_TOKEN Yes (socket mode) App-Level Token (xapp-...) for the Socket Mode dev fallback.
SLACK_ALLOWED_USERS No Comma-separated Slack user IDs allowed to interact with the bot.
SLACK_DELIVERY_CHANNEL No Fallback channel for the weekly repo digest, used when neither the repo's own notifications.slack.channel nor the operator's slack.repoChannels map names one.
SLACK_OAUTH_CLIENT_ID No Enables "Login with Slack" on the dashboard.
SLACK_OAUTH_CLIENT_SECRET Yes (if client id set) OAuth client secret.
SLACK_OAUTH_REDIRECT_URI Yes (if client id set) Must exactly match a redirect URL configured on the Slack app, typically https://your-host/admin/api/oauth/slack/callback.
SLACK_ALLOWED_WORKSPACE No Restrict OAuth login to a single Slack workspace (team ID or domain).

OpenTelemetry (optional)

Telemetry is disabled by default. Set LASTLIGHT_OTEL_ENABLED=true and point the standard OTEL_* exporter vars at your collector. See Observability for what's exported, content redaction, and how sandbox telemetry is routed per backend.

VariableRequiredDefaultDescription
LASTLIGHT_OTEL_ENABLED No false Master switch. Standard OTEL_* env vars alone do not enable telemetry — this must be true.
LASTLIGHT_OTEL_SERVICE_NAME No lastlight OTEL service name. Falls back to OTEL_SERVICE_NAME if unset.
LASTLIGHT_OTEL_INCLUDE_CONTENT No false Include prompt / message / tool-result content in spans (truncated). Sensitive — only enable with a trusted collector. Default exports metadata only.
LASTLIGHT_OTEL_FORWARD_TO_SANDBOX No true Emit telemetry from inside workflow sandboxes too. On the docker backend this routes through an in-network collector; on gondolin/none it forwards OTEL_* env directly. Set false to keep telemetry harness-only.
LASTLIGHT_OTEL_STRICT No false Throw on OTEL init/export-setup failure instead of warning and continuing without telemetry.
LASTLIGHT_OTEL_METRICS_ENABLED No true Export OTLP metrics. Set false for a traces-only backend that rejects the metrics signal (e.g. Arize Phoenix) — the metric reader is never started, so nothing hits a metrics endpoint that would 404/415. Traces still flow. Overlay key: otel.metrics.
LASTLIGHT_OTEL_COLLECTOR_HOSTS No Comma-separated collector hostnames added to the strict sandbox egress allowlist. Used only by the gondolin backend — the docker backend reaches its collector internally and ignores this.
OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_*_ENDPOINT No Standard OTLP/HTTP collector endpoint(s). Used by the harness directly, and as the re-export target for the in-network collector on the docker backend.
OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_*_HEADERS No Standard OTLP headers (e.g. an auth token). Secret/env-only — never shown in the dashboard Config tab, and never forwarded into a sandbox on the docker backend.
OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES No Standard OTEL resource configuration (e.g. deployment.environment=prod).

Feedback signals (optional)

A 👍 or 👎 on something Last Light wrote becomes an eval signal against the workflow run that produced it. Slack works out of the box (it sends a real reaction_added event); the GitHub half has to be polled — GitHub delivers no webhook for reactions at all — so it ships off:

feedback:
  enabled: true              # Slack signals, live
  github: false              # opt into the GitHub reaction poller
  pollSchedule: "*/30 * * * *"
  windowDays: 14             # how long a bot comment stays pollable
  maxAnchorsPerTick: 500     # = 5 GraphQL requests per tick
  retentionDays: 90
  otel: true                 # export each signal onto the run's trace

Env overrides: LASTLIGHT_FEEDBACK_ENABLED, LASTLIGHT_FEEDBACK_GITHUB, LASTLIGHT_FEEDBACK_OTEL, LASTLIGHT_FEEDBACK_WINDOW_DAYS. Operator-only — a target repo cannot set these. See Feedback signals for the emoji vocabulary, the Slack scope you need to add, and how the polling budget stays bounded.

The "my repos" filter (optional)

By default every admin who logs into the dashboard sees every managed repo's runs and sessions. In an org with a lot of repos that is mostly noise. Turn this on and each GitHub-authenticated admin gets a header toggle that filters every list down to the repos their GitHub org teams own, plus the ones their own account owns:

teamVisibility:
  enabled: false             # operator switch — needs the App's `Members: read`
  ttlMinutes: 60             # how long a resolved answer is reused
  maxTeamsPerUser: 50        # more teams than this ⇒ show everything
  maxPagesPerTeam: 20        # 100 repos/page; a bigger grant ⇒ show everything
  maxRequestsPerResolve: 60  # ceiling for one cache miss
The filter is opt-in, and that is deliberate. GitHub team grants describe involvement, not access — an org owner can reach every repo without belonging to a team that grants any of them. Applied as a default, this would hide people's own projects. Applied as a filter someone switched on for themselves, the same narrowing is exactly the decluttering they wanted, and one click undoes it. Nobody's view changes until they choose; the choice is remembered per browser.

This is a view preference, not access control. The filter travels as an ordinary ?repos= query parameter the dashboard asks for; omit it and the server returns global data exactly as before, and a hand-crafted API call still sees everything. That is what lets every budget above fail open: when a team is too big to enumerate, or someone is in too many teams, or GitHub refuses the query, you see more than strictly necessary rather than a partial list that quietly hides work you own.

Setup. Grant the GitHub App the organization Members: read permission, subscribe it to the team, membership and organization webhook events, and re-consent the App on each installation. Until then the resolver fails open and nothing is filtered for anyone — the toggle still appears once enabled is on, and says why it has nothing to offer, so an outstanding setup step reads as an outstanding setup step rather than as a feature that silently did nothing.

Just created a team? A resolved answer is cached for ttlMinutes (60 by default) and served stale-while-revalidate, so the first load after it expires still returns the old answer and refreshes behind you — it takes a second load to see a change. The webhooks above make that instant by invalidating the cache the moment a membership or a team's repo grant changes. Without them, click the toggle while it reads my repos (none) to re-check immediately.

Nothing is crawled up front. A person's teams are resolved the first time they load the dashboard — one GraphQL query per org plus a page or two per team — and cached in the state database, so the cost tracks their team count, not the org's repo count. The webhooks above invalidate the cache when membership or a team's repo grant changes; POST /admin/api/me/repos/resync is the manual fallback. Operator-only — a target repo cannot set these.