Contributing to Trimus¶
Thanks for your interest in contributing to Trimus. This guide covers getting a local environment running, the conventions the codebase enforces (some by linter, some by review), and how to land a change.
What is Trimus?¶
Trimus is a Go + HTMX multi-tenant AI-agent control plane: a server-rendered web dashboard plus a JSON API for governing and executing autonomous AI agents — their issues, routines, approvals, skills, cost tracking, plugins, and federation. The design goal is a fast, operationally simple, single-binary server with no JavaScript build step. Tenant data is isolated at the database layer with PostgreSQL Row-Level Security, and secrets are encrypted at rest with a KEK/DEK vault.
For the deeper system picture, read ARCHITECTURE.md; for the runtime surface, API.md.
Prerequisites¶
| Tool | Version | Notes |
|---|---|---|
| Go | 1.26+ | Module path: github.com/trimus-ai/trimus |
| PostgreSQL | 16 + pgvector | docker-compose.yml ships pgvector/pgvector:pg16 |
| Docker | Any recent | For the local Postgres container, integration tests, sandboxed adapters, and plugins |
| Git | Any recent |
pgvector is required, not optional. Migration 103 runs
CREATE EXTENSION IF NOT EXISTS vector(for note embeddings); the stockpostgres:16image fails it and blocks every later migration. Use thepgvector/pgvector:pg16image — that's whatdocker-compose.ymland CI both use.
Getting started¶
1. Clone and start Postgres¶
git clone https://github.com/trimus-ai/trimus.git
cd trimus
printf trimus > .pgpassword # one-time: dev Postgres password (gitignored)
docker compose up -d # pgvector/pgvector:pg16 on :5432
(make devel-up does the .pgpassword step for you, then starts Postgres + the server.)
The compose database user / password / name is trimus / trimus / trimus_dev.
2. Set DATABASE_URL¶
The only required environment variable is DATABASE_URL; everything
else has a default (see the environment reference).
For any feature that writes encrypted data (provider keys, secrets, signed webhook triggers, backups), also set an encryption key — otherwise those writes are refused at runtime (the server still boots):
3. Build and run¶
make build # builds bin/{server,worker,seed,trimus-cli}
make run # builds, then runs the server on :3100
Migrations run automatically on startup — there's no separate migrate
step for the server. (make migrate / make seed run the seeder, which
also applies migrations, for a fresh DB.)
4. Seed sample data (optional)¶
5. Local dev loop¶
make devel-up brings up Postgres and runs the server in the background
(PID + log under .devel/); make devel-down tears it back down.
For a fully containerized stack — Trimus and Postgres in one compose,
with the server built from the repo Dockerfile — use make devel-stack
(make devel-stack-down to stop). It opens on http://localhost:3100
and seeds admin@local.test / trimus-dev. Handy for testing the built
image end-to-end; devel-up is faster for the edit/rebuild loop since it
runs the server straight from go run.
The binaries (cmd/)¶
make build produces four; goreleaser ships those four plus
project-module under namespaced names. Three more are build-on-demand
tools.
| Path | Binary | Purpose |
|---|---|---|
cmd/server |
trimus-server |
The main server: migrations, pool, vault, ~25 background workers, chi router, HTTP. |
cmd/worker |
trimus-worker |
Optional remote worker — claims tasks over HTTP and runs them locally. |
cmd/seed |
trimus-seed |
Runs goose up + seeds a demo company. |
cmd/trimus-cli |
trimus-cli |
Operator CLI: key rotation, secrets, backups, pairing, costs, flags, chat REPL. |
cmd/project-module |
trimus-project-module |
Standalone DB-free project file service (the external_module file backend); shipped by goreleaser. |
cmd/trimus-license |
(dev/ops) | Offline Ed25519 license minting & inspection — holds the private signing key, never shipped. make build-trimus-license. |
cmd/sandbox-fixtures |
(dev) | Seeds fixtures for the plugin-sandbox validation runbook. make build-sandbox-fixtures. |
cmd/slack-probe |
(dev) | Socket Mode connectivity probe for debugging Slack DMs. make build-slack-probe. |
Project structure¶
trimus/
├── cmd/ # entry points (see table above)
├── internal/
│ ├── auth/ # actor model, API-key issue/verify (SHA-256), OAuth config
│ ├── a2a/ # agent-to-agent federation (cards, JSON-RPC, matcher, cascade)
│ ├── alerts/ # recent-error ring buffer for worker drill-downs
│ ├── attach/ # attachment text extraction (PDF/text)
│ ├── backup/ # pg_dump/restore runner, S3 sink/source, backup health
│ ├── budget/ # cost-threshold evaluation + incident creation
│ ├── channels/{slack,telegram,pairing,notify,health}/ # chat adapters + DM pairing
│ ├── compiledknowledge/ # compile-time knowledge articles injected into prompts (opt-in)
│ ├── config/ # env-var loader (config.go) — the server config source of truth
│ ├── crypto/ # KEK/DEK vault, envelope encrypt/decrypt, streaming backup crypto
│ ├── db/
│ │ ├── migrations/ # goose SQL migrations (001..176), embedded via go:embed
│ │ ├── query/ # hand-written pgx queries, one file per resource (no ORM)
│ │ ├── scope/ # WithTenantScope / WithBypass — the RLS entry points
│ │ ├── migrate.go # goose runner
│ │ └── pool.go # pgxpool creation
│ ├── engine/ # heartbeat execution, action-verb parser, adapters, prompt builder
│ │ ├── adapters/ # concrete adapters, provider HTTP clients, egress DNS
│ │ └── typedtools/ # typed-tool registry (MCP server + agent /mcp: verb)
│ ├── evolution/ # self-evolution trace scoring (GEPA substrate)
│ ├── fsskills/ # filesystem skills (SKILL.md/skill.yaml, slug, materialize, merge)
│ ├── handler/ # all HTTP handlers (UI + JSON API + health + a2a + mcp); embeds web assets
│ ├── home/ # TRIMUS_HOME layout, project workspaces, trash
│ ├── knowledgegit/ # knowledge-base ingestion from git repositories
│ ├── knowledgeslack/ # knowledge-base ingestion from Slack channels
│ ├── license/ # offline Ed25519 license verification, trial/read-only gating
│ ├── mcp/ # Model Context Protocol client + protocol types
│ ├── middleware/ # auth, role gates, CSRF, rate limit, body-size cap, redaction, OTel
│ ├── plugins/ # sandboxed plugin host (Docker sandbox, capability allowlist, approval)
│ ├── projectfs/ # project-file storage abstraction (local FS, S3/MinIO, external module)
│ ├── projectknowledge/ # project-scoped knowledge ingestion from project files
│ ├── projectstore/ # resolves a project's file-storage backend into a projectfs store
│ ├── providerrouter/ # resolve a company's configured LLM provider into an HTTP client
│ ├── roles/ # embedded default role prompts (data/<role>.md)
│ ├── skills/ # DB/instance skills + SeedBuiltinSkills (data/<slug>/)
│ ├── sse/ # Server-Sent Events hub (per-company pub/sub)
│ ├── ssebridge/ # split-mode SSE relay over Postgres LISTEN/NOTIFY
│ ├── ssrf/ # SSRF guard for outbound HTTP egress
│ ├── web/ # static/ + templates/, embedded into the handler package
│ ├── worker/ # worker pool + ~24 background workers
│ └── (plus small utility packages: health/, httpclient/, testsupport/, version/)
├── tests/integration/ # testcontainers-go integration tests (real PostgreSQL 16)
├── scripts/ # install/uninstall, smoke-test, systemd/launchd units
├── docs/user/ # the published mkdocs-material site source
├── docker-compose.yml # local dev Postgres (pgvector/pgvector:pg16)
├── Makefile # build, test, lint, docs, release targets
└── go.mod / go.sum # Go 1.26 module
Architecture overview¶
Router & middleware¶
Trimus uses chi/v5. Global middleware
(every request, in cmd/server/main.go): RequestID →
OtelMiddleware (a span per request; no-op if OTel isn't configured) →
StructuredLogger (one JSON line per request). Routes are registered in
cmd/server/routes.go and grouped, with each group layering additional
middleware (rate limit, CSRF, body-size cap, UIAuth/BearerAuth, and
role gates like RequireRole, RequireCompanyAccess,
RequireInstanceAdmin).
Authentication¶
- API keys are issued and stored as SHA-256 hashes (never raw).
BearerAuthreadsAuthorization: Bearer <token>, hashes it, looks it up, and puts anauth.Actoron the request context.UIAuthturns a?key=query param or session cookie into a Bearer header for browser sessions.- Role middleware gates who can reach a route (
viewer/member/adminper company, plus instance-admin).
Database¶
- Driver: pgx/v5 via
pgxpool. No ORM — every query is hand-written SQL ininternal/db/query/, one file per resource, each function takingcontext.Context+ the pool (or apgx.Tx). - Migrations: SQL-first, goose/v3,
embedded with
go:embed migrations/*.sql, run automatically on startup.
HTMX UI¶
Server-rendered HTML with HTMX. Templates and static
assets live in internal/web/ and are embedded into the handler
package (handler.StaticFS). There is no JavaScript build toolchain.
Background workers¶
A worker pool (internal/worker/pool.go) polls a tasks table with
SELECT FOR UPDATE SKIP LOCKED, alongside ~24 dedicated background
workers (heartbeat timer, routine scheduler, retention, watchdogs, A2A
refresh/routing/push, backups, GEPA, MCP sync, cost alerter, …). All are
started from cmd/server/main.go.
Logging¶
log/slog with a JSON handler, wrapped by a RedactingHandler
(internal/middleware/redact.go) that strips token-shaped values from log
output. Don't log request bodies that may carry secrets.
Tenant isolation (RLS) — read this before touching the DB¶
Multi-tenancy is enforced at the database layer, not just in
application code. The mechanism lives in internal/db/scope:
WithTenantScope(ctx, pool, companyID, fn)opens a transaction, runsSET LOCAL ROLE staple_app; SET LOCAL app.current_company_id = '<uuid>', and runsfninside it.staple_appis a NOLOGIN, NOBYPASSRLS role (created by migration 079); the RLS policies added in migrations 080+ filter every query to the scoped company. At COMMIT theSET LOCALsettings revert, so the connection returns to the pool clean.WithBypass(ctx, pool, fn)runs in a transaction that does not engage RLS (the pool's role typically has BYPASSRLS). Use it only for surfaces that legitimately span tenants: login/signup, instance-admin pages, the/companiespicker, migrations, seeding, bulk-rotation CLI. A directpool.Query/pool.Execalso bypasses RLS —WithBypassjust makes the intent explicit.
The no-raw-DB linter (rls:allow)¶
internal/handler/no_raw_db_test.go is an AST linter that runs as part
of go test ./internal/handler/.... It walks every non-test .go file
in the handler package and fails the build if it finds a direct DB
call on a *pgxpool.Pool receiver — pool.Query, pool.Exec,
pool.QueryRow, pool.Begin, pool.BeginTx, or pool.SendBatch.
Handlers must route DB access through query.* helpers (which engage
scope internally) or through explicit scope.WithTenantScope /
scope.WithBypass blocks.
Legitimate exceptions (e.g. a pre-RLS company-id lookup) must carry an
// rls:allow <reason> comment on the same or immediately preceding line.
The reason is for humans; the linter only checks the marker. You can
grep -r rls:allow to audit the allowlist. The linter is scoped to the
handler package on purpose — the query layer is where raw pool
access is supposed to live, and the engine layer has its own scoping
rules.
When adding a handler that reads or writes tenant data, the safe default is: call a
query.*function. If you must touch the pool directly, wrap it inscope.WithTenantScope(orWithBypassfor a cross-tenant surface) and be ready to justify it in review.
Secrets & encryption¶
Provider API keys, per-company secrets, and signed webhook trigger secrets
are encrypted at rest with a KEK/DEK vault (internal/crypto), envelope
format enc:v2:<dek_id>:<base64-aes-gcm>. The KEK comes from
TRIMUS_ENCRYPTION_KEY. If that's unset, the vault isn't initialised and
new encrypted writes fail (the server still boots and read paths work) —
so set it for any work that touches those features. Key rotation is
operator tooling in trimus-cli (rotate-kek, rotate-dek,
rewrap-all); see the key-rotation guide.
Database conventions¶
Raw SQL queries¶
All DB access goes through typed functions in internal/db/query/. Each
takes a context and pool (or tx), runs a single statement, and returns
typed Go values. Always parameterise — never concatenate user input into
SQL.
// internal/db/query/issues.go
func GetIssueByID(ctx context.Context, pool *pgxpool.Pool, id string) (*Issue, error) {
row := pool.QueryRow(ctx, `SELECT id, title, status, ... FROM issues WHERE id = $1`, id)
var iss Issue
if err := row.Scan(&iss.ID, &iss.Title, &iss.Status /* ... */); err != nil {
return nil, fmt.Errorf("getting issue: %w", err)
}
return &iss, nil
}
Migrations¶
- Sequential numbering:
001_*.sql…173_*.sql(use the next number — checkls internal/db/migrations | sort | tail -1for the current highest; 176 as of v5.0.2). - goose format, with
-- +goose Upand-- +goose Downmarkers. - Embedded via
go:embed; run automatically on startup. - Scope new tenant-owned tables to
company_idand add the RLS policy in the same migration era (follow the pattern in migrations 079–104): acompany_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE, an index on it, and a policy keyed onapp.current_company_id. A new tenant table without an RLS policy is a review blocker.
-- +goose Up
CREATE TABLE widgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_widgets_company ON widgets(company_id);
ALTER TABLE widgets ENABLE ROW LEVEL SECURITY;
CREATE POLICY widgets_tenant_isolation ON widgets
USING (company_id = current_setting('app.current_company_id')::uuid);
-- +goose Down
DROP TABLE IF EXISTS widgets;
Adding a feature, end to end¶
- Migration —
internal/db/migrations/NNN_widgets.sqlwith schema, index, RLS policy, and a-- +goose Down. - Query layer —
internal/db/query/widgets.gowith typed functions scoped tocompany_id. - Handler —
internal/handler/widgets.go. Closure pattern (deps in,http.HandlerFuncout); route DB access throughquery.*(the linter will catch raw pool calls). Use theWriteJSONhelper for JSON. - Templates (if UI) — under
internal/web/templates/, following the existing two-pass render pattern. - Routes — register in
cmd/server/routes.goin the right group with the right role gate. - Tests — unit tests beside the code; integration tests in
tests/integration/for query/SQL correctness against a real DB. - Docs — update the relevant page under
docs/user/(and API.md / ARCHITECTURE.md if you changed the surface or design).
The handler closure + JSON-response shape:
func ListWidgets(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
companyID := r.PathValue("companyId")
widgets, err := query.ListWidgets(r.Context(), pool, companyID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list widgets"})
return
}
WriteJSON(w, http.StatusOK, widgets)
}
}
Code style¶
- Formatting is non-negotiable:
gofmt+goimports. Run them before you commit (a PostToolUse hook or editor-on-save is ideal). - Accept interfaces, return structs; keep interfaces small.
context.Contextis the first parameter for anything that touches the DB or the network.- Wrap errors with context:
fmt.Errorf("doing X: %w", err). Return them up the stack — don't log-and-swallow. In handlers, map errors to the right status (404 / 400 / 500). - Keep functions focused (<~50 lines) and files cohesive. Extract helpers when a handler grows.
log/slogwith structured fields (slog.String("key", v)). Don't log secrets or raw request bodies.
See golang-patterns / golang-testing for deeper idioms.
Testing¶
Unit tests¶
make test # all unit tests (excludes tests/integration)
make test-coverage # with a coverage profile + total line
Test files live beside the code. The project uses the standard testing
package plus testify for assertions, and favours table-driven tests.
Many handler/query tests are DB-backed: they connect via DATABASE_URL
and self-skip when no database is reachable, so make test works
without one — but you'll get far more coverage with a migrated, seeded DB
up (make devel-up + make seed).
func TestSlugify(t *testing.T) {
tests := []struct {
name, in, want string
}{
{"spaces to hyphens", "My Skill", "my-skill"},
{"collapse hyphens", "a -- b", "a-b"},
{"trim edges", "-x-", "x"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Slugify(tt.in); got != tt.want {
t.Errorf("Slugify(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
Integration tests¶
These use testcontainers-go
to spin up a real pgvector/pgvector:pg16, run migrations, and execute
against it.
Smoke tests¶
Exercises public endpoints, auth rejection, and (with keys) authenticated flows against a running, seeded server. No Docker needed.
Coverage expectations¶
CI runs unit tests with a coverage profile and prints the total line
(uploaded as an artifact); there's no hard fail-below-threshold gate in
CI, but new code should ship with tests — query functions need
integration coverage for SQL correctness, and handlers need unit coverage
for valid input, missing required fields, invalid bodies, and
authorization. Several packages already sit near or above ~80%; keep them
there.
Static analysis & vulnerabilities¶
make vet # go vet ./...
make lint # golangci-lint if installed (skips cleanly if not)
make vuln # govulncheck (pinned to the CI version)
CI pipeline¶
CI (.github/workflows/ci.yml) runs the reusable test suite defined in
.github/workflows/test.yml on every push and PR to main;
release.yaml runs the same suite before a tag can publish, so the CI
gate and release gate cannot drift. The suite runs against a
pgvector/pgvector:pg16 service:
go build ./...go vet ./...- Migrate + seed the test database (
go run ./cmd/seed) - Unit tests with coverage —
go test ./internal/... -p 1(the-p 1serialises package binaries because the DB-backed tests share one database), then print the coveragetotal - Integration tests —
go test ./tests/integration/... - CLI + cmd tests — on a separate fresh database (the CLI's vault tests need their own empty vault to avoid a KEK-fingerprint clash with the unit step's crypto tests)
- Upload the coverage artifact
govulncheck ./...(SEC-027)
All steps must pass before a PR merges.
Plugins¶
Trimus has a sandboxed plugin system: plugins are standalone OS processes
that speak newline-delimited JSON over stdin/stdout and run inside a
Docker sandbox (read-only root, no network, resource caps), behind an
allow/ban approval state machine with SHA-256 binary verification. Start
from the reference implementation at examples/plugins/hello-world/ and
read PLUGIN.md for the protocol. The sandbox-validation
runbook is wired through make sandbox-validate /
make sandbox-validate-fresh.
Docs¶
The published site is mkdocs-material, built from docs/user/. Preview
locally:
make docs-deps # one-time: pip install -r requirements-docs.txt
make docs-serve # stage + serve at http://localhost:8000
make docs-stage copies docs/user/ and the top-level reference
markdowns (this file, API.md, ARCHITECTURE.md, …) into docs/_build/ and
rewrites cross-tree links. Edit docs/user/ and the top-level
markdowns — never docs/_build/, which is a generated, gitignored copy.
Keeping docs in sync (policy)¶
Documentation must describe the current system — docs drift is a bug. Concretely:
- A PR that changes behavior updates the affected docs in the same PR.
New/changed HTTP routes →
API.md(source of truth:cmd/server/routes.go). New/changed env vars → the docs that list them (source of truth:internal/config/config.go). Architecture-level changes →ARCHITECTURE.md, which carries a "current as of vX.Y.Z" stamp — bump it whenever it is re-verified. Worker-protocol changes →REMOTE_WORKERS.md. Plugin-surface changes →PLUGIN.md. Operator- or user-visible changes → the matching page underdocs/user/. - Before tagging a release, sweep the living docs for the previous version stamps and for claims invalidated since the last tag (migration count, env-var tables, endpoint lists) and fix them on the release branch.
- Historical records are frozen — past
CHANGELOG.mdentries,.planning/,docs/prd/, anddocs/superpowers/are records of what was true at the time. Never rewrite them; if one confuses readers, add a dated "historical document" banner at the top instead. docs/ROADMAP.mdreflects the open issue tracker; when the backlog shifts meaningfully, refresh its snapshot rather than letting it age into fiction.
Adding an onboarding company template¶
The /get-started catalog is content-driven (#341): adding a template is one
JSON file in internal/handler/onboarding/ — no Go changes. The authoring
standard (manifest block, taxonomy rules, role traps, the disabled-triggers
guardrail) lives in
internal/handler/onboarding/README.md,
and the CI catalog harness validates every embedded template automatically.
Submitting changes¶
Branches & commits¶
Branch from main with a category prefix: feat/…, fix/…, docs/…,
refactor/…, test/…, chore/…, perf/…, ci/…. Use
Conventional Commits:
feat: add widget management API and UI
fix: prevent race in issue checkout
docs: rewrite the action-verb reference
Keep the subject under ~72 characters; use the body for why.
Pull requests¶
- Branch from
main. - Make the change; run
make build,make vet, andmake test(make test-integrationif you touched the DB layer). - Format with
gofmt/goimports. - Push and open a PR against
main. - Fill in the description (summary + test plan).
- Address review with additional commits; avoid force-pushing during review.
## Summary
- What changed and why
## Test plan
- [ ] Unit tests added/updated
- [ ] Integration tests pass
- [ ] Manual steps (if applicable)
Keeping docs current¶
A PR that adds a feature, env var, adapter, or surface must update the
docs in the same change — the relevant docs/user/ page, and API.md /
ARCHITECTURE.md when the change is structural.
Code review¶
Reviewers look for, in roughly this order:
- Tenant isolation. New tenant queries scoped to
company_id? New tenant tables carrying an RLS policy? Handlers free of rawpool.*(or carrying a justifiedrls:allow)? - Security. No hardcoded secrets; parameterised SQL; encrypted writes gated on the vault; the right role middleware on the route.
- Correctness. Does it do what it claims? Edge cases handled? Errors propagated with context and mapped to the right status?
- Migrations. Reversible (
-- +goose Down)? Non-breaking to existing tables? - Tests. New behaviour and bug fixes covered?
Reporting issues¶
- Bugs: open a GitHub issue with repro steps, expected vs actual, Go version, OS, and relevant log output.
- Feature requests: open an issue describing the use case.
- Security issues: do not open a public issue — email the maintainers directly.
Key dependencies¶
| Package | Purpose |
|---|---|
github.com/go-chi/chi/v5 |
HTTP router |
github.com/jackc/pgx/v5 |
PostgreSQL driver + connection pool |
github.com/pressly/goose/v3 |
SQL migrations |
github.com/robfig/cron/v3 |
Cron scheduling for routines |
github.com/aws/aws-sdk-go-v2/... |
Bedrock runtime + S3 (backups) |
github.com/miekg/dns |
Egress allowlist DNS server |
github.com/google/uuid |
UUID generation |
github.com/testcontainers/... |
Integration-test containers |
go.opentelemetry.io/otel/... |
Distributed tracing |
Pinned versions live in go.mod; that's the source of truth.