Elyra
Elyra The coding agent eTerm The terminal that knows where each command ends Starf An activity monitor for Apple silicon that never invents a number e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Start here
Concepts
Release notes
What's new
Elyra

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.