Configuration
Every environment variable that matters, with the traps marked.
Application
APP_NAME="Félagi"
APP_URL=http://felagi.test
APP_TIMEZONE=UTC
Store in UTC and let the interface do the converting. APP_TIMEZONE is the
application's own clock, not a user preference — autopilot schedules carry their
own timezone precisely so a shared default does not have to be anyone's.
APP_URL appears in password reset links, verification links, signed URLs and the setup command
shown when connecting a machine. Pointing it at the wrong host breaks all of them silently.
The session cookie trap
SESSION_COOKIE=felagi_session
Laravel derives the session cookie name from APP_NAME with Str::snake(), which does not
transliterate. Félagi yields félagi_session, and RFC 6265 requires cookie names to be ASCII
tokens. Browsers drop the Set-Cookie header entirely, the session never persists, and every POST
fails CSRF verification with 419 Page Expired — pointing at CSRF, while the cause is the name
of the application.
config/session.php now derives an ASCII-safe name with Str::slug() and ignores an unusable
SESSION_COOKIE, so the variable is belt-and-braces rather than load-bearing.
curl accepts the invalid name happily. Only a real browser reveals the bug.
Database
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=felagi_db
MySQL 8, and only MySQL 8. Not a preference — the task queue is a database table, and a
worker claims a row with SELECT … FOR UPDATE SKIP LOCKED. SQLite has no such clause and no
row-level locking to build one from. Point Félagi at SQLite and two daemons will claim the same
task, run the same agent twice against the same repository, and push twice.
SQLite is a test target, never a deployment one
The suite runs on SQLite in memory because it is fast and needs no service. That is the whole extent of it. Running a workspace on SQLite is not slower or smaller — it is wrong, and the symptom is duplicated agent runs rather than an error anybody would notice.
| MySQL 8 | SQLite | |
|---|---|---|
| Production | Required | Never |
| Test suite | The CI lane that must not skip anything | The default, in memory |
| Task claiming | FOR UPDATE SKIP LOCKED |
No equivalent; those tests skip themselves |
MySQL also refuses literal defaults on JSON columns, which SQLite accepts. Model-level
$attributes carry those defaults instead.
SQLite accepts an ORDER BY on a column that does not exist and sorts by nothing;
MySQL refuses it. That one reached production: a rename left one line pointing at the
old column name, the suite passed locally on SQLite, and the reports index answered an
error page for everybody until the MySQL lane caught it. The lesson is not "run the
MySQL lane" -- it already ran and it already failed -- but that a green local run is
not evidence about a query, because the engine it ran on is more forgiving than the one
that serves it. The divergences are catalogued in
Testing — they are the reason both lanes exist, and none of them is
an argument that either engine is interchangeable with the other.
Registration
FELAGI_OPEN_REGISTRATION=false
Closed by default. An open door is the wrong default for a project tracker: anybody who found the URL would get an account, land on "you are not in a workspace yet", and create one — which on a self-hosted installation means a stranger with a workspace inside your Félagi.
Closing it does not close the paths that are deliberate:
| An invitation | Somebody asked by name. This is what "by invitation" means, and it stays open |
| A platform administrator creating an account | Deliberate, logged, and the password must be replaced on first sign-in |
felagi:admin |
Shell access proves rather more than an invitation would, and there has to be a first account |
| Single sign-on | A standing invitation for one domain rather than for one person |
With it closed, /register answers with a page rather than a 404 — whoever arrived
followed a link, and "not found" says the application is broken. The landing page, the
sign-in page, the header and the footer stop offering it: "Start free" on an
installation that does not let people sign up is true about the product and false about
that copy of it.
Posting the form is refused as well as hidden. A form that is not rendered is not a form that cannot be posted.
Meetings
FELAGI_MEETINGS=true
# Drafting minutes. Empty means off, and off is the default.
FELAGI_MINUTES_PROVIDER=
FELAGI_MINUTES_MODEL=
FELAGI_MINUTES_TIMEOUT=120
FELAGI_MEETINGS=false removes the feature from the installation, including from the
administrator's settings page. A workspace switch that could override it would not be
a kill switch.
FELAGI_MINUTES_PROVIDER is one of two settings in Félagi that cause the server to
call a model; the other is FELAGI_TIDY_PROVIDER below. Neither has a default, on
purpose: a sensible fallback would make the outbound call the thing that happens when
nobody decided anything. Provider keys come from the AI SDK's own variables
(ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
See Meetings for what is sent and why these are the exceptions.
Tidying up prose
# Empty means off, and off is the default.
FELAGI_TIDY_PROVIDER=
FELAGI_TIDY_MODEL=claude-sonnet-5
FELAGI_TIDY_TIMEOUT=120
The other setting that makes the server call a model. One switch for every surface that offers it — a knowledge base article that is not published, and minutes that have not been circulated — because an operator is deciding one question and asking it twice is how the two answers end up different.
What is sent is the document's text and its title. The result changes nothing until the author accepts it, and accepting puts it in an editor rather than saving it.
The provider is half of it. Naming one turns the button on; the provider still needs its
own credential, which is the AI SDK's variable and not ours — ANTHROPIC_API_KEY,
OPENAI_API_KEY, and so on. Set only the first and the button appears and fails when
somebody presses it.
That state is deliberately reported rather than hidden. Administration → Workspace says which of the three you are in, and a failed attempt names the missing key rather than whatever the HTTP client threw.
Check it there, not with env(). Production caches its configuration, and env()
outside a config file returns null once it has — so env('ANTHROPIC_API_KEY') on a
production console reports an empty key on an installation that is working perfectly.
config('ai.providers.<name>.key') is the one that reads the baked value.
Hiding the button instead would be worse in two directions. An operator who set the
variable and deployed would find nothing, which looks like the deploy failed. And ollama
ships with no key on purpose — it runs against localhost and wants none — so a rule of "no
key, no button" would switch off the one provider that makes no outbound call at all.
Unlike minutes, the model has a default. Naming a provider is the decision; picking a model afterwards is not, and an operator who has decided to allow the call should not have to look up a model string to get a working feature. Override it with any model the chosen provider serves.
See Knowledge and Meetings for what it will and will not change.
Images
IMAGE_DRIVER=gd
Félagi makes smaller copies of images when somebody looks at one, so a list does not
download a nine-megabyte phone photo to fill a 256-pixel box. gd or imagick; gd is
present in almost every PHP build.
It is not a requirement. With neither extension the original is served and everything
works, slower. A derivative is a cache of the original — never backed up, regenerable,
and invisible to felagi:check. See Attachments.
Sessions, cache and queues
SESSION_DRIVER=redis
CACHE_STORE=redis
QUEUE_CONNECTION=redis
Redis for sessions means migrate:fresh no longer signs everyone out — which matters when the
schema changes several times a day.
Redis for the queue rather than database, because Redis is already required here and a database
queue polls a table that the board is also reading. Redis blocks on a pop instead, which is the
difference between a live update arriving now and arriving on the next poll.
Give the queue a Redis database with maxmemory-policy noeviction. Under allkeys-lru — a
perfectly ordinary cache setting — Redis is free to evict queued jobs when memory runs short, and
those jobs are work the application believes it has already handed off.
The worker is not optional
Broadcasts ride their own queue so a slow job cannot delay the interface:
php artisan queue:work --queue=broadcasts,default
composer dev runs one. In production it needs a supervisor.
With sync, forgetting the worker cost nothing. With a real queue it means the interface stops
updating and nothing says why — so the dashboard checks the backlog and says so itself.
Broadcasting
See Broadcasting.
Daemon protocol
FELAGI_MIN_DAEMON_VERSION=0.2.0
FELAGI_DAEMON_DOWNLOAD_URL=https://github.com/kwhorne/Felagi/releases
Raising this is a breaking change for whoever runs the daemons. They are
refused at the door with a 426 and the download URL, which is the right
failure — a clear message before any work starts, rather than a refusal halfway
through a run. But they will do nothing until they are updated, so it belongs in
a release note rather than a patch.
Raising the minimum version locks out older daemons with a 426 that tells them where to get a
newer one. Both are read from config/felagi.php, along with the heartbeat and poll intervals the
server advertises.
Knowledge base
| Variable | Default | What it does |
|---|---|---|
FELAGI_KNOWLEDGEBASE_PATH |
storage/knowledgebase |
Where article documents are written, one HTML file per article |
The database row is metadata — title, parent, position — and the document is a file. Point this at a shared volume if more than one machine serves the application, or every article will be readable on exactly one of them.
The directory must be writable by the web user and is never served directly: articles go through the application, which knows who is asking and which workspace they are in.
Full behaviour in Knowledge base.
Outgoing webhooks
| Variable | Default | What it does |
|---|---|---|
FELAGI_WEBHOOK_ALLOW_PRIVATE |
false |
Permit endpoints inside the network Félagi runs on. Link-local is refused regardless. |
FELAGI_WEBHOOK_TIMEOUT |
10 |
Seconds before a delivery is abandoned |
FELAGI_WEBHOOK_FAILURES |
12 |
Consecutive failures before a webhook is stopped |
FELAGI_WEBHOOK_KEEP_DAYS |
7 |
How long delivery logs are kept |
A queue worker must handle the webhooks lane or nothing is ever delivered.
Full behaviour in Webhooks.
The API
| Variable | Default | What it does |
|---|---|---|
FELAGI_API_THROTTLE |
300 |
Requests a minute, per token |
Full contract in The API.
The sandbox
Not configured here — it lives in ~/.felagi/config.json on each runtime, because
only the machine knows what its kernel can enforce.
{ "sandbox": "confined" }
confined (default), limits, or off. The effective policy is reported at
registration and shown in Administration → Runtimes. Full behaviour in
the daemon's README.
Deliveries
| Variable | Default | What it does |
|---|---|---|
FELAGI_DELIVERY_SECRET |
unset | The token in the return-path webhook URL. Unset means the endpoint is closed, and answers 404 to everything. |
It moves issues, so it fails closed. See Delivering work.
Daemon rate limits
| Variable | Default | Real traffic |
|---|---|---|
FELAGI_DAEMON_THROTTLE_POLL |
120 |
20/min — a claim every 3 seconds |
FELAGI_DAEMON_THROTTLE_BEAT |
30 |
4/min — a heartbeat every 15 seconds |
FELAGI_DAEMON_THROTTLE_WORK |
600 |
~80/min per running task |
FELAGI_DAEMON_THROTTLE_REGISTER |
20 |
1, at startup |
Per token, per minute. Raising one for a busy installation does not open the door for anybody else. See the protocol.
Two-factor authentication
| Variable | Default | What it does |
|---|---|---|
FELAGI_2FA_REQUIRED |
unset | Unset means required in production, nowhere else. Set it to override that in either direction. |
FELAGI_2FA_GRACE_DAYS |
5 |
Days from when an account begins before it is demanded. 0 demands it immediately. |
TRUSTED_PROXIES |
unset | Required before using the IP allowlist behind a proxy. Without it the address Félagi sees is the proxy's, and allowlisting that exempts the internet. |
The nullable default is the point. (bool) env('FELAGI_2FA_REQUIRED', true) would
make an unset variable mean on, and a developer who then switched it off locally
would be one .env copy away from carrying that into production. Absent means
"decide from the environment", which is the safe thing to forget about.
Full behaviour in Two-factor authentication.
Security headers
App\Http\Middleware\SecurityHeaders sets the policy on every response. Two parts need attention
when things change:
connect-src includes the Reverb origin, derived from configuration. The websocket runs on a
different port and is therefore a different origin — 'self' alone blocks it. Run output arrives
over that socket, so blocking it leaves the pane silent rather than broken.
Cache-Control: no-store is set on every HTML response. Laravel's default of no-cache, private still permits the browser to store the page, so a restored tab can resurrect a form
whose CSRF token belongs to a session that no longer exists. no-store is the only value that
rules that out.
HSTS is only sent in production, so local development over plain HTTP is unaffected.
Local environment (Grove)
The project is developed against Grove, which serves *.test
domains and bundles MySQL and Redis.
grove status # daemon, runtimes, services, and the site for this directory
grove env # a .env snippet wired to Grove's services
One thing to know:
public/hot outlives npm run dev. If the interface suddenly renders unstyled, that file is
pointing at a Vite server that is no longer running. Delete it.