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.
| Variable | Required | Default | Description |
|---|---|---|---|
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).
| Variable | Required | Default | Description |
|---|---|---|---|
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.
| Variable | Required | Default | Description |
|---|---|---|---|
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. |
OPENCODE_API_KEY | If using OpenCode Zen | — | OpenCode Zen API key. Required if any active model is opencode/<model> (e.g. opencode/kimi-k2.6, opencode/glm-5.3, opencode/claude-sonnet-5). One prepaid key for Claude, GPT, Gemini, Kimi, GLM, DeepSeek, Qwen and MiniMax, sold at the providers' list price with no per-token markup (card top-ups carry a processing fee). Its free models may use your prompts for training. See opencode.ai/docs/zen. |
LASTLIGHT_MODEL | No | anthropic/claude-sonnet-5-5 | 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). |
models: block of config/default.yaml is a
commented-out example except review-survey and
review-triage, both pinned to
anthropic/claude-haiku-5-5. It is the model of the
review evidence pipeline's unit survey, and
the fall-through of its site investigators (models.review-site)
when you have not pinned them. Pinning it is not a cost downgrade: on
review recall Haiku 4.5 beats Sonnet 4.6 on two independent
evals. 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 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.
| Variable | Required | Default | Description |
|---|---|---|---|
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-coding → KIMI_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. |
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.
OPENCODE_* names from the
OpenCode era are still read as fallbacks for the matching
LASTLIGHT_* name — OPENCODE_MODEL →
LASTLIGHT_MODEL, OPENCODE_MODELS →
LASTLIGHT_MODELS, OPENCODE_VARIANT →
LASTLIGHT_THINKING, OPENCODE_VARIANTS →
LASTLIGHT_THINKINGS. Existing .env files keep
working; rename at your leisure.
Runtime
| Variable | Required | Default | Description |
|---|---|---|---|
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.tech → neon); 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
| Variable | Required | Default | Description |
|---|---|---|---|
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.
|
Autonomous mode — autonomy
The autonomy block drives the label-driven pipeline: an issue
labelled ready-for-agent on a repo listed in
autonomy.repos dispatches a gated build with no command typed.
It ships inert (repos: []) and is operator-only — there is no
per-repo key. The stages, their approval gates, on_merge and
the budget ceilings are all documented on
Autonomous mode.
Workflow policy — fix, gate, dependencies, review
These 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
placeholderCheck: true # also post the queued/neutral placeholder while a review waits
sweepPendingGraceMinutes: 60 # the sweep reviews pending CI only after this long
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 strictly the seeded
# obligations' discharge is checked. minimal is
# the measured default
mint: all-in-diff,registrations # extra seeding rules beyond the original
# four; "" = the pre-D2 baseline set
siteConcurrency: 6 # site investigators running at once (a ceiling,
# clamped per sandbox backend). Old name:
# surveyConcurrency, still read
siteTop: 5 # how many ranked sites get an investigator (1–8)
surveyUnitConcurrency: 16 # unit survey calls in flight (no backend clamp
# — lower it only for provider rate limits)
surveyUnitsTimeoutSeconds: 600 # whole-phase deadline on the unit calls
probes: "off" # off | static | full. NOT ATTACHED yet — the
# probe phases skip whatever this says. static
# runs them WITHOUT installing anything; full
# also installs the PR's dependencies
probeLifecycleScripts: false # full only: 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
maxProbes: 8 # at most this many hypotheses go in front of
# falsify (probe-plan ranks the owed set and cuts
# it here); null = no cap
falsifyTimeoutSeconds: 600 # the oracle's whole-phase ceiling, all rounds
maxInlineComments: 5 # the attention boundary — findings past this go
# to the review body, never dropped. Ranked by a
# severity DERIVED from the evidence, not written
# by the model
maxBodyComments: 5 # body-tier budget. N = top N by severity
# (default 5); 0 = no overflow at all;
# null = unlimited
# (`thresholds` and `internalFloor` were removed
# in 2026-09 — the boundary no longer gates on a
# finding's confidence; both keys are ignored.
# So are surveyEngine, reviewEngine,
# independentReview, adjudicate, admit, jevModel,
# jevTimeoutSeconds and surveyPasses, removed
# with the agent survey and the adjudicator)
risk: # risk tiers for the pipeline — a repo may set
rules: [] # its own in .lastlight/, matched FIRST. e.g.
# - { glob: "src/billing/**", tier: critical }
# - { glob: "*.stories.tsx", tier: low }
# built-ins apply after: docs/tests/generated low,
# migrations/schema/auth/CI high, else medium
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
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]
gate: # ONE full build/test gate command (install excluded)
timeoutSeconds: 900 # a repo may set this in .lastlight/lastlight.yml…
maxTimeoutSeconds: 1500 # …clamped to this operator-only ceiling
phaseTimeoutSeconds: 2400 # operator-only budget for a phase that RUNS a gate
# (build guardrails_gate, executor)
sandbox:
agentTimeoutSeconds: 1800 # one agent phase run with no timeout_seconds
commandTimeoutSeconds: 300 # type: bash / type: script phases
untilBashTimeoutSeconds: 30 # a generic_loop.until_bash check
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 max_iterations: { from: fix.localIterations }
and timeout_seconds: { from: gate.timeoutSeconds }.
The from: path resolves against the run's effective — already
repo-clamped — config, so a repository's own .lastlight/
value is honoured. There is no fallback number in the YAML: every timeout
lives in config/default.yaml, and a missing key fails config
(or workflow) load with an error naming it.
gate.timeoutSeconds <= gate.maxTimeoutSeconds < gate.phaseTimeoutSeconds,
and the resolved values are on every run's template context as
{{gate.timeoutSeconds}} (and the other
gate.* leaves) and
{{timeouts.agentSeconds}} /
commandSeconds / untilBashSeconds. Core passes
gate.timeoutSeconds to agentic-pi as --gate-timeout,
which tells the agent to run installs, builds and full suites under that
budget and judge them by exit code. fix.gateTimeoutSeconds is a
deprecated alias for gate.timeoutSeconds (overlay only; it
logs a warning).
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. If you would
rather nothing of Last Light's appear on a PR until its CI has settled,
set placeholderCheck: false: the check is then created only
when a review actually starts, shows it in progress, and concludes with
the verdict (or "didn't complete" if the run fails). The trade-off is that
a required last-light/review has nothing to wait on until the
review starts.
The 30-minute review sweep is the safety net for CI that never finishes
(a dead runner, a fork workflow awaiting approval), so it will review a PR
whose checks are still pending — but only once they have been pending for
sweepPendingGraceMinutes (default 60), measured from the
oldest pending check. A fresh push is left alone until its CI settles.
If a push lands while a review of the previous commit is still running,
the settled review of the new commit replaces it — the stale run is
cancelled rather than left to post a review of code that no longer exists.
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.
With the pipeline on, a review runs one path. First the pull request is cut
into small deterministic units — a changed function or method, a file's
module-level code, the pull request as a whole — and Last Light makes one
bounded model call per unit on models.review-survey, with no
tools and no sandbox, recording every defect each unit might hold. Replies
are cached on disk, so a re-review only re-asks the units whose code or
context changed, and a unit that does not answer in time is recorded as
unanswered rather than dropped. Those records are only a signal of
where to look: the stretches of code that many independent units
pointed at are ranked, one investigator goes to each of the top five
(analysis.siteTop, up to eight; models.review-site,
falling back to models.review-survey), and each must ground
one to three findings in the code or back a "nothing here" with a probe it
actually ran. Each investigator also reads the PR's title, description and
closed issues, as a claim to check rather than a reason to stop looking.
Set models.review-site-pair to a second, different model and
every site gets a second investigator on it: two models miss different
things, so the pair finds more than either alone. One selection pass
(models.review-select, falling back to
models.review) then merges duplicates, decides how important
each one is, and writes the comments. It reads the PR's earlier reviews,
threads and comments first, so a point someone already raised is recorded
rather than posted again. A nit is recorded, never posted.
Cheap investigator models measured better than Haiku here, on both recall
and precision, at a quarter to a half of the cost — pin
models.review-site in your overlay. Because the unit calls read
the review workspace directly, analysis.enabled is refused at
startup on the kubernetes sandbox backend.
The separate review pass does not run while the pipeline is
on; a re-review that triage marks light skips the pipeline and
runs it instead. If the pipeline fails and leaves no findings behind, a
deterministic floor writes them, unposted, and the review says plainly that
the change was not assessed — it is never posted as a clean review.
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 reads the
issues the PR closes and its body, extracts the acceptance criteria out of
them, and pairs each one with the files the PR actually changed; each
criterion then becomes a question the unit survey has to answer — quote the
line that implements it, or say that no changed file does.
A posted finding's severity, which is what the two posting
budgets rank on, comes from the importance the selection pass gave it —
a must-fix outranks a worth-mentioning for the inline slots — so the
budgets keep the strongest claims instead of cutting in document order.
The review's opening summary is written
only after those budgets have cut, from the findings that actually post, so
it never mentions one that was held back (pin
models.review-summary to choose the model; unset, it uses
models.default).
probes is kept but not attached yet: the probe
phases skip whatever it says, because nothing in the review reads their
output until they are wired back in. As designed, it is a
second switch under the same
block, and it has three values because it used to conflate two decisions.
It turns on the oracle — a pass that settles a claim by running
something instead of reasoning about it — and, separately, it can install
the pull request's dependencies. One boolean bought both, so the only way
to get the oracle was to pay for an install.
static runs both probe phases and never installs
anything: no package manager, no test suite, no
postinstall from a pull request head. The oracle works down a
cost-ordered ladder that needs none of it — the same input run against the
base branch and the head, a changed pure function copied out and run under
plain node, a tool that is already on disk, and a re-query of
Last Light's own deterministic analysis scoped to the claim. Anything it
genuinely cannot run is recorded as unprobed with the reason and still
reaches the review. A search or a file read that merely agrees with a
claim about what the code does is recorded as corroborated —
weaker evidence than a reproduction, and checked mechanically so a grep
cannot pass for one. That is not a degraded mode: the best published
false-positive-elimination results come from pipelines that never compile
the project at all.
full adds the install, and what that buys is the
analysis rather than the probes: a review workspace with a
node_modules in it 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. It also has a cost nothing
meters: warm review workspaces deliberately keep their
node_modules between runs, so this is a standing per-repo disk
cost on your host. A bare true reads as static, so
upgrading never buys you an install you did not ask for.
The 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 applies to full alone, and
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.
| Variable | Required | Default | Description |
|---|---|---|---|
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
| Variable | Required | Default | Description |
|---|---|---|---|
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.
| Variable | Required | Default | Description |
|---|---|---|---|
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.
| Variable | Required | Default | Description |
|---|---|---|---|
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 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.