Contributing to FERAL
FERAL is building toward an agent-native operating system. That is a large mission and we need help from many kinds of engineers — backend, frontend, ML, hardware, Nix/packaging, and documentation.
Development Setup
For the web UI (in a separate terminal):
Or use Make targets from the repo root:
Code Style
Python
- Type hints on all function signatures.
- Ruff for linting, Black-compatible formatting.
- Keep functions focused and short. Prefer composition over inheritance.
- Avoid broad
except: blocks — catch specific exceptions.
TypeScript / React
- Strict mode enabled.
- Prettier for formatting.
- Functional components with hooks.
General
- No commented-out code in PRs.
- Comments explain why, not what.
- New features should include tests.
Testing
Test files live in feral-core/tests/. Use pytest-asyncio for async tests — the asyncio_mode = "auto" config handles the event loop.
The settings reader gate
If you add a key to DEFAULT_SETTINGS in feral-core/config/loader.py, a test
will fail unless something actually reads it.
feral-core/tests/test_settings_keys_have_readers.py walks every leaf of
DEFAULT_SETTINGS and asserts each one has a reader somewhere in feral-core.
It exists because FERAL has shipped the same bug four separate times: a key is
written by an operator surface (the setup wizard, the WebUI Settings page, the
phone Settings panel), persisted to settings.json, and then read by nothing.
The operator sees a switch, flips it, the value survives a restart, and the
runtime ignores it forever. Confirmed instances: vision.provider /
vision.model (an operator who picked free local Ollama vision was still billed
for every frame on the shared paid chat model), voice.chained.* (a Groq
Whisper plus ElevenLabs pick still ran Deepgram), security.autonomy_mode
(picking “strict” silently ran “hybrid”), and audio.realtime_providers (still
dead, see below). Each was found by hand, months apart. The test turns that
audit into CI.
A key counts as read when either holds:
- Direct read. Some non-test module under
feral-core accesses the leaf
name in a read-shaped position (.get("leaf"), block["leaf"],
helper("leaf", default), get_setting("section", "leaf")) with the key’s
parent path segments nearby. Write-shaped constructs are blanked out first,
because a key that is only ever written is exactly the bug.
- Env-var read. Several subsystems resolve their config only from the
variables
ConfigLoader.export_as_env emits. The path-to-variable map is not
hardcoded in the test; it is probed by mutating one leaf at a time and
diffing the export_as_env() output, so it cannot drift away from the loader.
Deliberate scoping: readers must live in feral-core (a JS client reading a key
back purely to render its own toggle is the write path round-tripping, not a
consumer); tests are not readers; and config/loader.py is a reader, but its
own DEFAULT_SETTINGS literal is masked out so a key cannot vouch for itself.
If the gate flags your key, the fix is to wire a reader or delete the key.
Only if it genuinely should have no reader do you add it to
UNREAD_KEY_ALLOWLIST, and the allowlist is kept honest by three further tests:
- The reason string must be at least 60 characters after stripping.
- It may not contain any of the filler phrases
todo, fixme, tbd,
for now, later, unknown, not sure, temporary, wip, xxx.
A substring match anywhere in the reason fails, so write the sentence you
actually mean rather than a placeholder.
- An entry whose key has since acquired a reader, or which no longer exists in
DEFAULT_SETTINGS, fails as stale and must be removed.
Two entries are on the allowlist today: version (a schema stamp, not a
behaviour switch) and audio.realtime_providers (genuinely unread; the router
resolves a single provider from the scalar audio.realtime_primary, and the
ordered list is written by the WebUI Settings page and consumed by nobody).
The analysis is a heuristic that errs toward calling things read, so a flagged
key is worth believing. If it flags something that really does have a reader,
widen _read_patterns rather than burying the key in the allowlist.
Known limit 1: the gate walks DEFAULT_SETTINGS only. A key that is written
straight to settings.json by the setup wizard or by a client, without a
corresponding default in the loader, is invisible to it. audio.chained_providers
is a live example: the WebUI Settings page persists it, no Python reads it, and
the gate cannot see it because it is not in DEFAULT_SETTINGS. If you add an
operator-facing key, add its default to DEFAULT_SETTINGS so the gate covers
it.Known limit 2: a read inside dead code still counts as a read. The analysis
is textual, so it cannot tell that the function containing the read is never
called. coding.tool_call_context passes the gate on the
os.environ.get("FERAL_TOOL_CALL_CONTEXT", "on") inside
skills/call_context.py::context_enabled(), which has no call sites anywhere in
the tree, so the setting is inert in exactly the way the gate exists to catch.
Pull Request Process
-
Fork the repository and create a feature branch:
-
Write tests for new functionality.
-
Run the test suite and ensure it passes:
-
Open a PR against
main with a clear title and description:
- What changed and why.
- How to test the change.
- Screenshots for UI changes.
-
One approval from a maintainer is required to merge.
Contributor Lanes
Pick the area that matches your skills:
Architecture Docs
Before diving in, read these to understand the system:
Reporting Issues
Open an issue at github.com/FERAL-AI/FERAL-AI/issues with:
- Steps to reproduce.
- Expected vs actual behavior.
- Python version, OS, and
feral --version output.
Alpay Kasal — info@feral.io