Appearance
Development Guide
Build Commands
All commands run from the relay-daemon/ directory:
bash
cd relay-daemon
make # build the relay binary
make test # build and run all tests
make clean # remove build/ and relay binaryThe production binary is output to relay-daemon/relay.
Build System
The Makefile uses a wildcard rule — all .c files in src/ are automatically compiled. Adding a new source file does not require any Makefile changes:
makefile
SRC = $(wildcard src/*.c) # automatically finds all src/*.cCompiler flags: -Wall -Wextra -Werror -pedantic -std=c11
Warnings are errors
All warnings are treated as errors (-Werror). Fix every warning before committing.
TDD Workflow
All new code must be test-driven. Write the failing test first, then implement.
1. Write the failing test
Create or edit relay-daemon/tests/test_<module>.c:
c
#include "Unity/unity.h"
#include "mocks.h" /* always include */
#include "mymodule.h"
static void test_my_feature_happy_path(void)
{
/* arrange */
mock_fs_reset();
mock_fs_set("/some/path", "content");
/* act */
int rc = my_function(&g_mock_fs, "/some/path");
/* assert */
TEST_ASSERT_EQUAL_INT(RELAY_OK, rc);
}
static void test_my_feature_missing_file(void)
{
mock_fs_reset(); /* no files registered = file_exists returns 0 */
int rc = my_function(&g_mock_fs, "/missing");
TEST_ASSERT_EQUAL_INT(RELAY_ERR_NOTFOUND, rc);
}
void test_mymodule_suite(void)
{
RUN_TEST(test_my_feature_happy_path);
RUN_TEST(test_my_feature_missing_file);
}2. Register the suite
If it's a new module, declare and call the suite in tests/test_runner.c:
c
// Declaration:
void test_mymodule_suite(void);
// In main():
test_mymodule_suite();For an existing module, just add RUN_TEST(...) calls inside the existing suite function.
3. Run — confirm red
bash
make test # must fail before you write any implementation4. Implement, then confirm green
bash
make test # all tests must pass (zero failures)Definition of done: make test passes with zero failures. Every new public function has a happy-path test + at least one error/edge-case test.
Dependency Injection Pattern
All external dependencies — HTTP, filesystem, process spawning, clock — are injected via structs defined in relay.h. No module calls system functions directly.
c
/* Wrong — not testable */
void my_func(const char *path) {
FILE *f = fopen(path, "r"); // direct syscall — cannot mock
}
/* Right — testable */
void my_func(relay_fs_t *fs, const char *path) {
if (!fs->file_exists(path)) return;
char *content = fs->read_file(path);
}When a function can't be tested without mocks, it must accept its dependencies via the DI structs.
Available Mocks
All mocks are in tests/mocks.h.
g_mock_clock — time control
c
g_mock_time = 1708070400; // set the current unix timestamp
// g_mock_clock.now() and g_mock_clock.localtime_r() use this valueg_mock_fs — in-memory filesystem
c
mock_fs_reset(); // clear all files
mock_fs_set("/path/to/file", "content"); // create/overwrite a file
// .read_file, .write_file, .file_exists, .append_file, .delete_file
// all operate on the in-memory store — no disk I/O
// .list_dir(dir, suffix, names, max) — returns matching filenames (used by session discovery)g_mock_http — HTTP responses
c
mock_http_set_response("{\"ok\":true}", 0); // 0 = success, non-zero = error
// g_mock_http_last_url — last URL that was called
// g_mock_http_last_body — last request body sentg_mock_proc — process spawning
c
mock_proc_set_output("LLM response text", 0); // set subprocess "output"
// g_mock_proc.spawn_streaming() returns this outputKey C Gotchas
Variables must be declared before goto labels
-Werror turns this into a build failure:
c
/* WRONG */
int my_func(void)
{
if (bad) goto cleanup;
int x = 5; // declared AFTER a goto — compiler error
cleanup:
return -1;
}
/* RIGHT — declare all variables at the top of the function */
int my_func(void)
{
int x;
if (bad) goto cleanup;
x = 5;
cleanup:
return -1;
}main.c and event_loop.c are excluded from test builds
These are excluded from TESTABLE_SRC in the Makefile. Code that needs to be unit-tested must live in separate modules.
RELAY_ERR_NOTFOUND vs RELAY_ERR
RELAY_ERR_NOTFOUND (-2) means "nothing here right now" — not a fatal error. The agent bus uses this to signal EAGAIN. Check for it explicitly:
c
int rc = agent_bus_accept_message(...);
if (rc == RELAY_ERR_NOTFOUND) continue; /* no message waiting — normal */
if (rc != RELAY_OK) { /* actual error */ }Adding a New Module
- Create
relay-daemon/src/mymodule.candrelay-daemon/src/mymodule.h - Create
relay-daemon/tests/test_mymodule.cwithtest_mymodule_suite() - Declare and call
test_mymodule_suite()intests/test_runner.c - Run
make test— confirm red (no implementation yet) - Implement until
make testpasses green
No Makefile changes needed — src/mymodule.c is auto-discovered.
For Telegram command handlers: Follow the same pattern but also register the command in the commands[] array in telegram.c and add dispatch logic in event_loop.c. See cmd_workspace.c and cmd_sessions.c for examples.
Adding a New LLM Provider
- Create
relay-daemon/src/newprovider.candrelay-daemon/src/newprovider.h - Add the backend enum and routing in
llm_provider.c - Add config keys (e.g.
newprovider_binary) totemplates/config/relay.conf.template - Write tests in
tests/test_newprovider.c
Documentation Deployment
The docs site at rawphp.github.io/relay/ is built with VitePress and deployed automatically via GitHub Actions.
How it works: On every push to main that changes files in docs/, the .github/workflows/deploy-docs.yml workflow builds the VitePress site and deploys it to GitHub Pages.
One-time setup (required): In the GitHub repo settings, go to Settings > Pages and set the Source to GitHub Actions (not "Deploy from a branch"). Without this, the workflow will run but the site won't be published.
Local preview:
bash
npm run docs:dev # dev server at localhost:5173
npm run docs:build # production build to docs/.vitepress/dist/
npm run docs:preview # preview the production buildAgent Bus Modules
The agent bus system spans several modules. Here's the developer-facing guide:
Source Files
| File | Test File | Purpose |
|---|---|---|
agent_bus.c | test_agent_bus.c | Socket protocol (init, accept, send, log) |
agent_bus_rate.c | (in test_agent_bus.c) | Connection rate limiter |
agent_advertise.c | test_agent_advertise.c | Write/remove ~/.relay.d/{name}.json |
peer_registry.c | test_peer_registry.c | Scan ads, PID liveness, LLM context block |
bus_directive.c | test_bus_directive.c | Parse [AGENT_BUS_SEND] from LLM output |
bus_dead_drop.c | test_bus_dead_drop.c | Offline message persistence |
Testing Patterns
Advertisement tests — write JSON files to a temp directory, scan with peer_registry_init:
c
static void write_ad(const char *name, const char *socket, int pid) {
// Write {ad_dir}/{name}.json with cJSON
}
// Use getpid() for PID so liveness check passes in tests
write_ad("ash", "/tmp/ash.sock", (int)getpid());
peer_registry_init(ad_dir, "kai"); // excludes "kai", finds "ash"Dead drop tests — save/load/clear with temp directories:
c
agent_bus_message_t msg = make_msg("kai", "Hello Ash");
bus_dead_drop_save(ad_dir, "ash", &msg);
int count = bus_dead_drop_load(ad_dir, "ash", out, sizeof(out));
// count == 1, out contains formatted catch-up blockBus directive tests — set up peer registry, then call bus_directive_process:
c
peer_registry_init(ad_dir, "kai");
int count = bus_directive_process(
"[AGENT_BUS_SEND to=ash] Hello!", out, sizeof(out), "kai", NULL);
// count == 1, out has directive stripped, status note appendedCLAUDE.md Agent Bus Section
Each deployed agent's CLAUDE.md includes an "Agent Bus" section that tells the LLM:
- It's connected to the agent bus
- How to use
[AGENT_BUS_SEND to=<name>]directives - That the daemon strips directives and routes messages
- That the bus is live-only (no persistent mailbox for the LLM — the dead drop is daemon-level)
This is set via templates/CLAUDE.md.template. The daemon also injects a "Peer Agents on the Bus" block into the --system-prompt listing online/offline peers.
Bus Prompt Design
When the daemon processes an inbound bus message, the LLM prompt says:
[Agent bus message from Ash]: Hey Kai, how are you?
Reply directly. Do NOT use [AGENT_BUS_SEND] — the daemon routes your reply automatically.The "Do NOT use AGENT_BUS_SEND" instruction prevents the LLM from wrapping its reply in another directive, which would create a recursion loop. The daemon handles reply routing via agent_bus_send(msg.from_socket, ...).
Adding a New Message Platform
- Create
relay-daemon/src/newplatform.c - Add
newplatform_get_updates()andnewplatform_send_message()calls inevent_loop.c - Add any new config keys to
relay.conf.template