Skip to content

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 stock postgres:16 image fails it and blocks every later migration. Use the pgvector/pgvector:pg16 image — that's what docker-compose.yml and 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).

export DATABASE_URL="postgres://trimus:trimus@localhost:5432/trimus_dev?sslmode=disable"

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):

export TRIMUS_ENCRYPTION_KEY="$(openssl rand -base64 32)"

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)

make seed    # demo company, agents, issues, API keys

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): RequestIDOtelMiddleware (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).
  • BearerAuth reads Authorization: Bearer <token>, hashes it, looks it up, and puts an auth.Actor on the request context.
  • UIAuth turns a ?key= query param or session cookie into a Bearer header for browser sessions.
  • Role middleware gates who can reach a route (viewer / member / admin per company, plus instance-admin).

Database

  • Driver: pgx/v5 via pgxpool. No ORM — every query is hand-written SQL in internal/db/query/, one file per resource, each function taking context.Context + the pool (or a pgx.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, runs SET LOCAL ROLE staple_app; SET LOCAL app.current_company_id = '<uuid>', and runs fn inside it. staple_app is a NOLOGIN, NOBYPASSRLS role (created by migration 079); the RLS policies added in migrations 080+ filter every query to the scoped company. At COMMIT the SET LOCAL settings 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 /companies picker, migrations, seeding, bulk-rotation CLI. A direct pool.Query/pool.Exec also bypasses RLS — WithBypass just 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 in scope.WithTenantScope (or WithBypass for 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_*.sql173_*.sql (use the next number — check ls internal/db/migrations | sort | tail -1 for the current highest; 176 as of v5.0.2).
  • goose format, with -- +goose Up and -- +goose Down markers.
  • Embedded via go:embed; run automatically on startup.
  • Scope new tenant-owned tables to company_id and add the RLS policy in the same migration era (follow the pattern in migrations 079–104): a company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE, an index on it, and a policy keyed on app.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

  1. Migrationinternal/db/migrations/NNN_widgets.sql with schema, index, RLS policy, and a -- +goose Down.
  2. Query layerinternal/db/query/widgets.go with typed functions scoped to company_id.
  3. Handlerinternal/handler/widgets.go. Closure pattern (deps in, http.HandlerFunc out); route DB access through query.* (the linter will catch raw pool calls). Use the WriteJSON helper for JSON.
  4. Templates (if UI) — under internal/web/templates/, following the existing two-pass render pattern.
  5. Routes — register in cmd/server/routes.go in the right group with the right role gate.
  6. Tests — unit tests beside the code; integration tests in tests/integration/ for query/SQL correctness against a real DB.
  7. 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.Context is 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/slog with 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

make test-integration   # requires Docker

These use testcontainers-go to spin up a real pgvector/pgvector:pg16, run migrations, and execute against it.

Smoke tests

make smoke AGENT_KEY=sk_... BOARD_KEY=sk_...

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:

  1. go build ./...
  2. go vet ./...
  3. Migrate + seed the test database (go run ./cmd/seed)
  4. Unit tests with coveragego test ./internal/... -p 1 (the -p 1 serialises package binaries because the DB-backed tests share one database), then print the coverage total
  5. Integration testsgo test ./tests/integration/...
  6. 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)
  7. Upload the coverage artifact
  8. 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 under docs/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.md entries, .planning/, docs/prd/, and docs/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.md reflects 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

  1. Branch from main.
  2. Make the change; run make build, make vet, and make test (make test-integration if you touched the DB layer).
  3. Format with gofmt/goimports.
  4. Push and open a PR against main.
  5. Fill in the description (summary + test plan).
  6. 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 raw pool.* (or carrying a justified rls: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.