Skip to content

Trimus Architecture

Trimus is a multi-tenant control plane for AI agents — a self-hosted Go application that governs and executes autonomous agent work against a Kanban-style board. It exposes both a JSON REST API and a server-rendered HTMX web UI, and it runs agents on a background heartbeat through a set of pluggable execution adapters.

This document describes the system as of v5.0.2. It is current to the code; where a claim matters, it cites the package or file that backs it.

  • Module path: github.com/trimus-ai/trimus
  • Language/runtime: Go 1.26
  • Data store: PostgreSQL 16 + pgvector, accessed via pgx/v5 (pgxpool)
  • HTTP router: go-chi/chi/v5
  • Migrations: pressly/goose/v3 (embedded SQL, auto-applied at startup)
  • All static assets and templates are embedded into the binary via Go's embed package (internal/web/, surfaced as handler.StaticFS).

Versioning note. The version string is not a constant in the repo — it is injected at release time by GoReleaser via -ldflags "-X main.version=… -X main.commit=… -X main.date=…" (cmd/server/main.go, internal/version/version.go). A plain go build reports version=dev. Inline v2.xx.x.x comment markers throughout the code are change-provenance annotations, not the product version.


1. The high-level model

The domain is companies → agents → issues. A company is a tenant; everything else is scoped to it. Agents are AI workers bound to an execution adapter and (optionally) an LLM provider. Issues are the units of board work.

Agents do not run on request threads. A background heartbeat loop wakes agents on their configured interval: a worker pool claims a run, dispatches it through the agent's adapter (which calls an LLM, a local CLI, a container, or a remote worker), and parses the agent's output for action verbs/slash-commands that mutate the board (set status, create issues, request approvals, spawn subagents, and so on).

Around this core, Trimus layers governance and integration: human approvals, budgets and cost tracking, routines (cron + webhook scheduling), skills and notes, projects/goals, attachments, per-company encrypted LLM providers, sandboxed plugins, MCP (client + server), A2A federation, and chat channels (Slack, Telegram, web). A server-rendered board UI with SSE live updates gives human operators real-time visibility.

                         ┌──────────────────────────────────────────────┐
   Humans (browser) ───► │  HTMX UI  ──┐                                 │
   Slack / Telegram ───► │  Channels ──┤                                 │
   Scripts / agents ───► │  JSON API ──┼──► chi router  ──► handlers ────┼──► Postgres
   Webhooks (HMAC)  ───► │             │      (auth, RLS scope, RBAC)    │   (RLS: staple_app)
   Peer instances   ───► │  A2A / MCP ─┘                                 │
                         └───────────────────────────┬──────────────────┘
                                                      │ enqueue / claim
                              ┌───────────────────────▼──────────────────┐
                              │  Worker pool + ~30 background workers     │
                              │  (heartbeat timer, scheduler, A2A,        │
                              │   backups, GEPA, MCP sync, retention…)    │
                              └───────────────────────┬──────────────────┘
                                                      │ ExecuteHeartbeatRun
                              ┌───────────────────────▼──────────────────┐
                              │  Engine  ──► adapter  ──► LLM / CLI /     │
                              │              container / remote worker    │
                              │          ◄── parse action verbs ◄──       │
                              └──────────────────────────────────────────┘
                                                      │ publish
                              SSE hub ────────────────┘  (per-company live events)

2. Entry points (cmd/)

Binary Package Role
trimus-server cmd/server The main process. Runs migrations, opens the pool, boots the vault, starts ~30 background workers, builds the chi router (routes.go), serves HTTP.
trimus-worker cmd/worker Optional remote worker: an HTTP client that registers, claims tasks tagged with its capability tags, executes them off-host, and posts results back. Opt-in.
trimus-cli cmd/trimus-cli Operator CLI (see §19).
trimus-seed cmd/seed Demo-data seeder (also the migration entry point for make migrate).
trimus-project-module cmd/project-module Optional standalone, DB-free project-file service: serves one project's files over HTTP via the same internal/projectfs local-FS store, behind a shared-key bearer, for projects with file_backend = external_module.
trimus-license cmd/trimus-license Offline Ed25519 license minting/verify tool (keygen, mint, verify). Holds the private key — never deployed to customer instances; the server embeds only the public key.
cmd/slack-probe Slack Socket Mode connectivity probe (dev/debug; not shipped by GoReleaser).
cmd/sandbox-fixtures Generates fixtures for the plugin-sandbox validation runbook.

make build produces bin/{server,worker,seed,trimus-cli}; GoReleaser ships five binaries under namespaced names (trimus-server, trimus-worker, trimus-seed, trimus-cli, trimus-project-module).


3. Package map (internal/)

One-line responsibilities for each package. Many small files, organized by domain.

Execution

  • engine — the heartbeat execution engine. engine.go (Engine, New, ExecuteHeartbeatRun), actions.go (the action-verb parser/applier), adapter.go (executeWithAdapter, AdapterFactory, default-llm fallback), per-adapter bridges (adapter_claude_local.go, adapter_codex_local.go, adapter_docker.go, adapter_lambda.go, adapter_remote.go, adapter_claude_sandbox.go, adapter_codex_sandbox.go, adapter_llm.go), chat substrate (chat.go, chat_context*.go), prompt assembly (prompt.go, remote_prompt.go), notes extraction (findings.go, auto_extract.go), evolution_trace.go, pricing.go, subagent_limits.go, notifier.go.
  • engine/adapters — concrete adapter implementations + provider HTTP clients (openai_compatible.go, anthropic.go, perplexity.go, bedrock.go, embeddings.go), the egress DNS server (dns_server.go, dns_logic.go, dns_attribution.go), Docker network management (docker_network.go), sandbox wrapping (sandbox_wrap.go), and safety.go / validate.go.
  • engine/typedtools — typed-tool registry (registry.go, list_issues.go, list_notes.go, get_agent.go, list-team) consumed by the MCP server endpoint and by agents via the /mcp: action verb (#362).
  • engine/council — the Council orchestrator: a deterministic, tiered Head → Leads → researchers flow convened explicitly by an operator (POST /api/issues/{issueId}/convene-council); topology.go (the tier plan), orchestrator.go, install.go.
  • providerrouter — resolves a company's configured LLM provider into an HTTP Provider (router.go, openai.go, anthropic.go).

HTTP & middleware

  • handler — all HTTP handlers (UI + JSON API + health + auth + a2a + mcp). The largest package by file count. Embeds the static FS and templates; owns template rendering (render.go) and the WriteJSON helper (response.go).
  • middlewareauth.go (BearerAuth, UIAuth), worker_auth.go (WorkerAuth), role.go (RequireRole, RequireCompanyAccess, RequireInstanceAdmin), csrf.go, ratelimit.go, bodylimit.go (MaxBodySize), redact.go (log redaction), requestid.go, logging.go, telemetry.go (OTel spans).
  • authactor.go (the actor model), apikey.go (API-key issuance/verify), oauth.go (Google/GitHub OAuth provider config).
  • sse — the SSE hub (hub.go): per-company pub/sub, membership re-verification, metrics.
  • ssebridge — the split-mode cross-tier SSE bridge (bridge.go): a Publisher tees every hub publish onto the trimus_sse pg_notify channel (via hub.SetOnPublish), and a Listener holds a dedicated LISTEN connection and republishes into the web tier's local hub (see §16).
  • httpclient — the proxy-aware constructor for outbound *http.Client instances (TRIMUS_HTTP_PROXY / TRIMUS_HTTPS_PROXY / TRIMUS_NO_PROXY overrides + optional proxy auth).
  • ssrf — SSRF protection for outbound HTTP: ValidateURL pre-checks plus a dial-time DialControl hook that rejects private/loopback/link-local/metadata IPs.

Data

  • dbRunMigrations, NewPool; db/migrations (goose SQL, 176 files); db/query (the data-access layer, roughly one file per resource); db/scope (RLS helpers WithTenantScope / WithBypass).
  • crypto — the KEK/DEK vault (vault.go), envelope encrypt/decrypt (envelope.go), streaming backup encryption (backup_stream.go).
  • config — the env-var loader (config.go; see §10).

Workers & ops

  • worker — the worker pool (pool.go) plus ~30 background workers (heartbeat timer, scheduler, retention, watchdogs, A2A workers, backup workers, GEPA, MCP sync, knowledge sync, compiled-knowledge compile, cost alerter…). Tick/error counters in metrics.go.
  • backup — backup runner (runner.go), S3 sink/source/list (s3sink.go, s3source.go, s3list.go), backup-health aggregation (health.go).
  • health — disk-pressure summary (disk.go) + check-timing histograms (timing.go).
  • alerts — a recent-error ring buffer (ring.go) for worker drill-downs.
  • budgetevaluate.go (EvaluatePolicies): threshold check + budget-incident creation with open-incident dedup.

Features & integration

  • channels/{slack,telegram,pairing,notify,health} — chat adapters + DM pairing + the outbound notifier + a channel-health registry.
  • license + license/gate — offline Ed25519 license verification (verify.go, payload.go, embedded public keys) plus enforcement state: gate.Bootstrap at boot, a 5-minute refresh worker, and the state consumed by middleware.LicenseGate(). No license ⇒ built-in 30-day trial ⇒ read-only; expired license ⇒ 7-day grace. A token can be installed in-app (gate.InstallToken, persisted in instance_settings); a DB-installed token overrides TRIMUS_LICENSE (see §17).
  • projectstore + projectfs — the pluggable per-project file store: projectstore resolves a project's file_backend into a projectfs.Store — local disk (localfs.go), S3/MinIO object storage (s3store.go, keyed off TRIMUS_PROJECT_S3_BUCKET), or the external project-module service (moduleclient.go + short-lived module tokens).
  • knowledgegit / knowledgeslack / projectknowledge / compiledknowledge — the knowledge-ingestion subsystems: KB ingestion from git repos and admin-selected Slack channels; project-scoped knowledge via a nullable company_knowledge.project_id (reusing the company pipeline); and opt-in Compiled Knowledge (LLM-distilled wiki articles injected as a compact prompt index, gated by TRIMUS_COMPILED_KNOWLEDGE_ENABLED).
  • a2a — Agent-to-Agent federation: agent_card.go, client.go, protocol.go, matcher.go, cascade.go, assembly.go, issue_translator.go, sync.go, ratelimit.go.
  • mcp — the Model Context Protocol client (client.go) + protocol types (protocol.go). (Trimus's MCP server endpoint lives in handler.)
  • plugins — the sandboxed plugin host: host.go, event_bus.go, host_services.go, job_scheduler.go, tool_registry.go, sandbox.go (the Docker sandbox), protocol.go.
  • evolution — self-evolution scoring substrate: scoring.go, scoring_chat.go, scoring_heartbeat.go, hints.go.
  • attach — attachment text extraction (extract.go; PDF + text).
  • fsskills — filesystem skills: store.go, materialize.go, merge.go, slug.go, yaml.go, codex.go.
  • skills — DB/instance skills (skills.go, SeedBuiltinSkills).
  • roles — embedded default role prompts (roles.go + data/*.md): ceo, cfo, cio, clo, cmo, cro, cto, designer, devops, engineer, general, pm, qa, researcher, scout, triage (16).
  • home — the TRIMUS_HOME directory layout (dirs.go, paths.go, boot.go), project workspaces (project_workspace.go), trash (trash.go).
  • version, web, testsupport — build metadata, embedded web assets, and test helpers.

4. Startup sequence

cmd/server/main.go boots in this order (symbols, not line numbers — the file moves every release):

  1. Load configconfig.Load() reads env vars and validates DATABASE_URL (boot error if unset).
  2. Configure logginglog/slog JSON handler wrapped by middleware.NewRedactingHandler (strips API keys, Bearer tokens, and sensitive attributes).
  3. Run migrationsdb.RunMigrations(DATABASE_URL) runs goose via database/sql + pgx/stdlib, before the pool opens — serialized across concurrently-booting processes by a Postgres advisory lock (internal/db/migrate.go).
  4. Open the pooldb.NewPool() creates a pgxpool.Pool and pings to verify.
  5. Boot the vault — when TRIMUS_ENCRYPTION_KEY is set, crypto.InitDefault() loads/unwraps DEKs and bootstraps an initial active DEK on first run. Unset ⇒ vault not initialized; encrypted writes are refused (warned on stderr + a UI banner).
  6. Seed admin — if TRIMUS_ADMIN_EMAIL + TRIMUS_ADMIN_PASSWORD are set and no users exist.
  7. Seed builtin skillsskills.SeedBuiltinSkills().
  8. Evaluate the licensegate.Bootstrap resolves the effective token via gate.ResolveToken (a DB-installed token from instance_settings wins over TRIMUS_LICENSE) and verifies it against the embedded Ed25519 public keys. A bad/missing/expired license never blocks boot — it degrades the instance (trial / read-only) — and a background refresher (gate.NewWorker) re-evaluates every 5 minutes on every role.
  9. Resolve the role planplan := config.PlanForRole(cfg.Role) from TRIMUS_ROLE=all|web|scheduler. Object construction below stays unconditional (web-tier handlers reference these objects); only the background loops — the worker pool Start, the heartbeat timer, the ~30 other background workers, and the egress DNS server — are gated on plan.RunWorkers, and the HTTP listener on plan.RunHTTP.
  10. Create the SSE hubsse.NewHub().
  11. Wire the cross-tier SSE bridge (split mode only) — a scheduler-only process (RunWorkers && !RunHTTP) tees every hub publish into pg_notify via hub.SetOnPublish(ssePublisher.Publish); a web-only process (RunHTTP && !RunWorkers) starts ssebridge.NewListener(pool, hub), which LISTENs and republishes into its local hub. role=all wires neither side (internal/ssebridge, #237).
  12. Ensure the egress networkadapters.EnsureEgressNetwork() creates the trimus-egress-allowlist Docker network on every role; the optional egress DNS server is constructed when TRIMUS_EGRESS_DNS_ADDR is set but only started when plan.RunWorkers.
  13. Create the engineengine.New(pool, hub, cfg).
  14. Start the worker poolworker.NewPool(pool, cfg.WorkerPoolSize, eng) (default size 5), then the heartbeat timer and the rest of the background-worker fleet (scheduler, A2A, backups, GEPA, retention, knowledge sync, watchdogs, cost alerter…) — all gated on plan.RunWorkers.
  15. Initialize the plugin host — event bus, tool registry, host services, job scheduler; SetSandboxConfig(plugins.SandboxConfigFromEnv()).
  16. Build the routerchi.NewRouter(), global middleware, then RegisterRoutes(...).
  17. Start the HTTP server (skipped when !plan.RunHTTP) and block, with graceful shutdown on SIGINT/SIGTERM (cancel context → drain workers → drain HTTP).

5. The request path

The router is built in cmd/server/main.go; every route is registered in cmd/server/routes.go (RegisterRoutes). Three global middlewares wrap all requests, in this order (cmd/server/main.go):

RequestID  →  OtelMiddleware(tracer)  →  StructuredLogger(logger)

Beyond that, requests fall into one of a few route groups, each layering its own middleware. The three primary surfaces are the public group, the session UI group, and the bearer API group.

Public group (no auth, rate-limited)

middleware.RateLimit(RATE_LIMIT_RPM, trustedProxies) only. Endpoints that must be reachable without credentials:

  • GET /api/health (legacy DB-ping), GET /healthz (liveness), GET /readyz (readiness), GET /healthz/live + /healthz/ready (text/plain LB aliases)
  • GET /metrics (Prometheus — gate via network policy)
  • POST /api/routine-triggers/public/{publicId}/fire (HMAC webhook-in)
  • GET /api/roles/{role}/default-prompt
  • A2A discovery: GET /companies/{companyId}/.well-known/agent-card.json, GET /companies/{companyId}/agents/{agentId}/agent-card.json
  • Remote-worker bootstrap: POST /api/workers/register (own 1 MB body cap) — intentionally unauthenticated, because a self-registering worker has no key until register issues one (workers pre-provisioned from the /admin/workers Register form already hold an admin-minted key, #335). The register response carries the operating prompt; the standalone GET /api/workers/operating-prompt re-fetch endpoint is authenticated (see the remote-worker API below).

Auth group (no auth, CSRF-protected)

GET/POST /login, POST /logout (CSRF-protected), and the OAuth entry points (/auth/google, /auth/google/callback, /auth/github, /auth/github/callback).

Session UI group (CSRF + 1 MB body + UIAuth + BearerAuth)

The server-rendered HTMX pages. UIAuth reads the staple_session cookie (or a key query param / staple_key cookie) and injects a Bearer header for BearerAuth to resolve. Sub-groups add RequireRole(...):

  • Viewer+ read pages (/companies, /companies/{id}/dashboard, /inbox, /issues, /issues/{id}, /runs/{id}, /agents, /agents/{id}, /routines, /approvals, /chats, /notes, /costs, /projects, /goals, /feedback, /activity, /org-chart, /skills, /capabilities, /files, /plugins, command-search, preferences, /me/settings, pairing-code create/delete, inbox dismiss/restore).
  • Member+ write actions (RequireRole "admin","member"): issue/agent/routine/approval/chat/note/project/goal mutations, skills/fs-skills/files CRUD, bulk-import.
  • Company-admin actions (RequireRole "admin"): company CRUD + settings, egress, pairings audit, user-models, MCP servers, secrets, logo, providers, A2A peers, plugin lifecycle, portability export, company user management.
  • Instance-admin UI (RequireInstanceAdmin): user management, /admin/skills, /admin/settings, /admin/workers, /admin/costs, /admin/operations, /admin/audit, /admin/mcp/*, /admin/user-models, /admin/gepa/pending, /admin/subagents, /admin/trash, SSE metrics, portability import.

SSE group (rate-limit + UIAuth + BearerAuth)

GET /api/live/eventshub.Handler() — the browser's EventSource connection.

User-session JSON API (CSRF + 1 MB + rate-limit + UIAuth + BearerAuth)

/api/me (+ change-password), instance-admin /api/users, company-admin /api/companies/{id}/users, and chat-message / issue-comment feedback (these are called by the browser with cookie auth, so they live here rather than in the bearer-only group).

Bearer API group (1 MB + rate-limit + BearerAuth)

The programmatic JSON API used by agents, scripts, and integrations. Notable shapes:

  • Agent self-service: GET /api/agents/me, GET /api/agents/me/inbox-lite, POST /api/agents/me/heartbeat/invoke; third-party POST /api/agents/{id}/heartbeat/invoke; agent management (pause/resume, runtime-state, task-sessions, fs-skills, files, permissions, configuration + revisions).
  • Issues: POST /api/issues/{id}/trigger-agent; /api/issues/{id} GET/PATCH/DELETE plus checkout/release/comments/labels/documents/attachments/work-products/relations/feedback/execution-decisions/read/archive-inbox.
  • Company-scoped /api/companies/{companyId} (RequireCompanyAccess → role groups): viewer reads, member creates, admin writes (secrets, providers, budget policies, portability, members, plugins). Includes POST /notes/search (pgvector semantic search).
  • Chats / notes / routines / approvals / projects / goals / secrets / providers / skills — resource-scoped sub-routers (e.g. /api/chats/{id} with messages + messages/stream).
  • MCP server: POST /mcp/{companyId}. A2A: POST /companies/{id}/agents/{id}/a2a + peer CRUD.
  • Instance settings/flags/roles/trash/skills (instance-admin), LLM reflection (/llms/agent-configuration.txt, /llms/agent-configuration/{adapterType}.txt, /llms/agent-icons.txt).
  • Remote worker API (WorkerAuth): POST /api/workers/heartbeat, /tasks/claim, /tasks/{id}/complete, /tasks/{id}/fail, GET /api/workers/operating-prompt (re-fetch after a server upgrade; #148 keeps it off the anonymous surface), plus GET /api/workers.

A full endpoint-by-endpoint listing — with auth requirements and request/response shapes — is in API.md.

Two example flows

JSON API request
  → RequestID → OtelMiddleware → StructuredLogger      (global)
  → RateLimit                                          (group)
  → BearerAuth        hash token → agent_api_keys / board_user_api_keys → Actor
  → RequireCompanyAccess   actor.CompanyID must match {companyId}; resolve user role
  → RequireRole       check company role against the allowed set
  → handler           (WithTenantScope where it reads tenant data) → WriteJSON

UI request
  → RequestID → OtelMiddleware → StructuredLogger      (global)
  → CSRFProtect → MaxBodySize → UIAuth → BearerAuth     (group)
  → RequireRole / RequireInstanceAdmin                 (as needed)
  → handler           query DB → render template (two-pass layout)

6. Authentication & authorization

Every authenticated request resolves to an auth.Actor on the request context, carrying ActorType (agent / board_user / user), CompanyID, CompanyRole (admin / member / viewer, resolved per-company for users), and InstanceRole (admin / user).

Auth mechanisms:

  1. Agent API keysAuthorization: Bearer <raw-key>; SHA-256-hashed and looked up in agent_api_keys. The raw key is shown once at creation.
  2. Board-user API keys — same Bearer flow against board_user_api_keys.
  3. User sessions — the staple_session cookie validated against user_sessions; checked first by UIAuth.
  4. OAuth 2.0 — Google (openid+email+profile) and GitHub (user:email) sign-in; on callback, find-or-create the user, create a session, set the cookie. OAuth tokens are encrypted with the vault.
  5. UI auth bridgeUIAuth turns a session cookie (or key param / staple_key cookie) into a Bearer header so BearerAuth can run uniformly.
  6. Worker authWorkerAuth validates the remote-worker key on the worker API.

RBAC: three hierarchical company roles (Viewer ⊂ Member ⊂ Admin) plus Instance Admin (cross-company; bypasses company role checks). Non-user actors (agents, board users) are gated by company-ID matching rather than role. RequireCompanyAccess enforces that an actor can only touch its own company (instance admins excepted).


7. The heartbeat loop and worker pool

Agents execute off the request path, driven by background workers.

Worker pool (internal/worker/pool.go)

  • worker.NewPool(pool, cfg.WorkerPoolSize, eng) launches N goroutines (default 5, via WORKER_POOL_SIZE).
  • Each worker loops: claim a task → process → complete/fail. Tasks are claimed atomically with SELECT FOR UPDATE SKIP LOCKED, so multiple workers (and remote workers) consume the queue safely without double-claiming.
  • The primary task kind is heartbeat_run; the pool calls Engine.ExecuteHeartbeatRun(ctx, runID, agentID).

Heartbeat timer (internal/worker/heartbeat_timer.go)

Periodically scans for agents with heartbeat_enabled = true whose interval has elapsed, creates a heartbeat_runs row, enqueues a heartbeat_run task, and publishes a heartbeat_started SSE event. Manual triggers ("Run Heartbeat", "Run Agent", POST /api/agents/{id}/heartbeat/invoke, POST /api/issues/{id}/trigger-agent) enqueue the same task kind.

The run, end to end (Engine.ExecuteHeartbeatRun)

claim heartbeat_run task
  → transition run → running
  → load agent; verify active
  → select adapter (see §8)
  → find work: wakeup requests first, then assigned actionable issues
  → atomic checkout (conflict detection via pgx.ErrNoRows)
  → assemble prompt (agent context, issue, recent comments, roster, manager, wakeup)
  → execute via adapter (LLM / CLI / container / remote)
  → parse action verbs from the output (internal/engine/actions.go)
  → post the agent's response as an issue comment
  → apply verbs (status / priority / assign / create-issue / approvals / …)
  → auto-release the checkout unless the agent held it
  → record cost (token counts → cost_events); update heartbeat_runs (model, provider, tokens, summary)
  → consume the wakeup request
  → publish SSE (heartbeat_completed, issue_updated, …)
  → evaluate budget policies (budget.EvaluatePolicies → incidents on breach)

Per-run streaming output is captured into heartbeat_run_events; the /api/heartbeat-runs/{id}/events and /api/runs/{id}/events endpoints support transcript replay and gap recovery.

The other background workers (~30)

The same internal/worker package runs a fleet of timers alongside the pool and heartbeat timer, each with tick/error counters surfaced on /admin/operations and /metrics. Among them: the routine scheduler (robfig/cron/v3, with concurrency and catch-up policies), run-events and heartbeat-run retention, progress watchdog + stale-run watcher, the four A2A workers (peer refresh, routing, outbound poller, push notifier), the backup workers (cron, retention, verify, reconcile, health), GEPA (prompt auto-evolution), MCP sync, evolution settler/retention, context compression, pairing cleanup, the cost-budget alerter, and the knowledge-ingestion workers (knowledge-git sync, Slack knowledge sync, project knowledge sync, compiled-knowledge compile, knowledge embedding). The license refresher (gate.NewWorker, §17) runs alongside them on every role. Most are individually disable-able by env var (see §10).


8. The adapter system

An adapter is how an agent actually executes. The engine holds a factory map keyed by agent.AdapterType (internal/engine/engine.go):

adapters: map[string]AdapterFactory{
    "claude_local":   newClaudeLocalAdapter,
    "codex_local":    newCodexLocalAdapter,
    "docker":         newDockerAdapter,
    "lambda":         newLambdaAdapter,
    "remote":         newRemoteAdapter,
    "claude_sandbox": newClaudeSandboxAdapter,   // sandboxed CLI in hardened Docker
    "codex_sandbox":  newCodexSandboxAdapter,
}

Selection order (in ExecuteHeartbeatRun)

  1. If agent.ProviderID is set → provider-based dispatch (resolveAdapterForAgent), which enforces the provider's enabled flag and a cross-company IDOR guard, then returns a provider adapter. This takes precedence over the factory map.
  2. Else if agent.AdapterType is in the factory map → that factory.
  3. Else → the default llm adapter (an empty AdapterType defaults to "llm" in adapter.go).
  4. If the LLM adapter is nil (no provider configured) → the run fails.

The adapters

Adapter How it works
llm (default) Built-in Go HTTP execution against an LLM provider. Families: OpenAI, Anthropic, OpenAI-compatible, Perplexity, Bedrock (adapters/openai_compatible.go, anthropic.go, perplexity.go, bedrock.go). Renders the full heartbeat prompt before calling the provider. (Renamed from process in migration 061.)
claude_local Spawns the Claude Code CLI as a subprocess with session persistence, CWD-aware resume, and stale-session retry. Reads the host's ~/.claude/ OAuth state (hence user-mode systemd).
codex_local Spawns the OpenAI Codex CLI as a subprocess; a distinct JSONL stream protocol. Reads ~/.codex/.
claude_sandbox / codex_sandbox The same CLIs wrapped in a hardened Docker container on the egress-allowlist network (adapters/sandbox_wrap.go). Recommended production default for CLI agents.
docker Containerized agent execution with read-only root + hardening (no-new-privileges, cap-drop ALL, pids-limit, memory caps) and configurable image/pull-policy/network.
lambda POSTs a JSON payload to an HTTP endpoint (AWS Lambda or any service); sync or async modes.
remote Dispatches to a remote worker process over the worker protocol (see REMOTE_WORKERS.md).

Local and remote adapters receive payload parity — the same timeline, roster, manager, project, and label context in both paths. The per-adapter runtime-isolation contract (what the runtime can read on disk, which env it sees, where it can reach on the network) is detailed in docs/user/operators/adapter-isolation.md.

Provider router (internal/providerrouter/router.go)

For(ctx, pool, encKey, companyID) returns the first enabled provider for a company; ForFamily(...) filters by family. It maps openai / openai_compatible → the OpenAI provider and anthropic → the Anthropic provider; Perplexity and Bedrock are handled by their dedicated adapters rather than this router. The router is also used directly by chat and embeddings, not just heartbeats.


9. The action-verb mechanism

The agent's action space is a set of /slash-command verbs parsed in internal/engine/actions.go. Both kebab-case and snake_case spellings are accepted (/create-issue == /create_issue). The engine reads the agent's output, extracts verbs, and applies their effects to the board.

Verb(s) Effect
/status Set issue status
/priority Set issue priority
/assign Assign an issue to an agent
/checkout, /release Claim / release an issue
/request-approval Create a human approval request
/create-issue Create a new issue
/get-issue, /list-issues Read issues
/blocks, /blocked-by, /relates (/relates-to) Issue relations
/create-note, /edit-note, /get-note, /list-notes, /archive-note, /unarchive-note Notes CRUD
/record-finding Record a finding (as a note)
/install-memory, /install-note Install a memory/note capability
/use-skill, /install-skill, /install-routine, /install-agent, /install-federation Install capabilities / hire agents / register peers
/fire-routine, /promote-routine Routine control
/spawn-subagent, /await-subagents, /await-subagents-recursive Subagent orchestration
/needs-routing Flag an issue for A2A / cascade routing
/await-input Pause for human/agent input
/update-user-model Update a per-user profile
/write-file Write a file to the agent's workspace
/report (/write-report) Produce a report work-product on the current issue (heartbeat-only; routines can pre-inject a metrics snapshot into the fired issue)

Subagents have dedicated operator dashboards (/admin/subagents and its by-parent, leaderboard, tree, and per-run cancel/redispatch/bulk-cancel views).


10. Configuration

All configuration is via environment variables (12-factor; no config files). internal/config/config.go is the authoritative source for the ~60 variables it parses into the server config struct. Backup, worker, GEPA, embedding, compression, login-lockout, egress-DNS, project-file S3 (TRIMUS_PROJECT_S3_BUCKET), compiled-knowledge (TRIMUS_COMPILED_KNOWLEDGE_ENABLED), and Slack-ingest (TRIMUS_SLACK_INGEST_ENABLED) variables are read directly via os.Getenv in their own packages (workers, cmd/worker, crypto, projectfs) — cite those files, not config.go, for them.

A representative slice (full reference in docs/user/operators/environment.md):

Variable Default Meaning
DATABASE_URL required Postgres connection string (boot error if unset)
TRIMUS_ENCRYPTION_KEY — (warns) Base64 32-byte AES-256 KEK. Unset ⇒ encrypted writes refused (server still boots).
TRIMUS_ROLE all Process role: all|web|scheduler (see §18); invalid value = boot error
TRIMUS_LICENSE / TRIMUS_LICENSE_FILE Ed25519 license token (inline / file path). Unset ⇒ built-in 30-day trial, then read-only; a token installed via the admin UI (DB) overrides the env value. Configured-but-unreadable TRIMUS_LICENSE_FILE = boot error.
TRIMUS_HOST / PORT ` /3100` Bind host / HTTP port
WORKER_POOL_SIZE 5 In-process worker goroutines
RATE_LIMIT_RPM 300 Requests/minute per limiter key
TRIMUS_TRUSTED_PROXIES none CIDR list; only then is X-Forwarded-For honored. Invalid CIDR = boot error.
TRIMUS_HOME / TRIMUS_STORAGE_DIR $HOME/.trimus / ./storage Company/agent home root / attachment disk dir
TRIMUS_PUBLIC_URL http://localhost:<port> URL agents use to reach the API
TRIMUS_DEFAULT_LLM_PROVIDER / _MODEL / _API_KEY / _BASE_URL openai / gpt-4o / — / https://api.openai.com/v1 Fallback LLM for agents without a configured provider
TRIMUS_SESSION_SECRET random (warns) Session signing key; random ⇒ sessions invalidate on restart
TRIMUS_SECURE_COOKIES false Secure flag on auth cookies
TRIMUS_SLACK_* Slack Socket Mode (requires TRIMUS_SLACK_USER_ALLOWLIST when configured)
TRIMUS_TELEGRAM_* Telegram long-poll
TRIMUS_EGRESS_DNS_ADDR Enables the deny-by-default egress DNS resolver
TRIMUS_PROJECT_S3_BUCKET Set ⇒ project files use S3/MinIO object storage (read in internal/projectfs/s3config.go, not config.go)
TRIMUS_COMPILED_KNOWLEDGE_ENABLED off Opt-in Compiled Knowledge compile + prompt injection (read in internal/worker + internal/engine, not config.go)
OTEL_EXPORTER_OTLP_ENDPOINT Enables OpenTelemetry tracing

11. The data layer

Connection pool & migrations

A single pgxpool.Pool is created at startup and shared across all handlers and workers. Migrations are 176 numbered goose SQL files in internal/db/migrations, embedded via go:embed and applied automatically at startup before the pool opens (serialized across concurrent boots by a Postgres advisory lock — internal/db/migrate.go). The highest is 176_worker_shared_models.sql.

Query layer

internal/db/query holds the data-access functions, roughly one file per resource. Conventions: $N parameter placeholders (pgx), struct + scan* helpers, dynamic WHERE/SET builders for list and PATCH operations, INSERT … RETURNING for creates, and fmt.Errorf("context: %w", err) for error wrapping. Optional PATCH fields use pointer types (nil = don't update). Timestamps are timestamptz (UTC); cron is standard 5-field, evaluated in UTC.

Row-Level Security (tenant isolation)

┌─────────────────────────────────────────────────────────────┐
│ pool role (DATABASE_URL): superuser / BYPASSRLS              │
│   • used directly for admin / login / instance-scope reads   │
│   • WithBypass(...) is the explicit bypass path              │
└─────────────────────────────────────────────────────────────┘
                       │  WithTenantScope(ctx, pool, companyID, fn)
┌─────────────────────────────────────────────────────────────┐
│ BEGIN;                                                       │
│   SET LOCAL ROLE staple_app;             -- NOLOGIN/NOBYPASS │
│   SET LOCAL app.current_company_id = '<uuid>';              │
│   fn(tx)   -- RLS engaged for the tx lifetime               │
│ COMMIT;                                                      │
└─────────────────────────────────────────────────────────────┘

The pool connects as a privileged role, so by default RLS is bypassed. Tenant-scoped access goes through internal/db/scope/scope.go:

  • WithTenantScope opens a transaction, switches to the staple_app role (NOLOGIN, NOBYPASSRLS, created idempotently by migration 079) and sets app.current_company_id. The tenant_isolation RLS policies — added incrementally across migrations 080 (issues), 081–082 (high-value + extended tables), 091 (egress), 094/096 (a2a), 100 (pairing), 104 (note embeddings) — then key on current_setting('app.current_company_id')::uuid.
  • WithBypass is the symmetric, explicit RLS-bypass path for legitimately cross-tenant work (login, instance admin, migrations).

Two migration patterns coexist: P1 (the query function wraps WithTenantScope internally, so handler call sites are unchanged) for companyID-taking queries, and P2 (the handler opens the scope from actor.CompanyID and passes the tx to entity-id *Tx variants like GetIssueByIDTx) for entity-id-only queries.

CI enforcement: internal/handler/no_raw_db_test.go is an AST-based linter run on every go test that fails the build on any new raw pool.Query / pool.Exec / pool.QueryRow / pool.Begin* / pool.SendBatch call in production handler code. Legitimately-raw pre-RLS lookups are allowlisted with an // rls:allow <reason> annotation that must explain why (pre-RLS company_id resolution, post-fire metadata write, transactional Tx wrapper, or agent-scoped scan). Inventory the current allowlist with grep -rn "rls:allow" --include="*.go" internal/.


12. The encryption vault (KEK/DEK)

Secrets are encrypted at rest with a two-layer envelope (internal/crypto):

  • KEK — the Key Encryption Key, supplied as base64 via TRIMUS_ENCRYPTION_KEY. It never encrypts application data directly.
  • DEK — a per-installation Data Encryption Key, stored in data_encryption_keys (migration 083) wrapped under the KEK. The active DEK encrypts the actual application data: company_secrets / company_secret_versions, company_providers.api_key + custom_headers, OAuth tokens, and trigger HMAC secrets.

Both layers use AES-256-GCM. Ciphertext format is enc:v2:<dek_id>:<base64(nonce‖ciphertext‖tag)> (legacy enc:v1:… still decrypts). On boot, NewVault loads and unwraps the DEKs, raising ErrKEKFingerprintMismatch loudly if a wrapped DEK was wrapped under a different KEK (catching "rotated the env var but not the DB"), and bootstraps an initial active DEK when the table is empty. crypto.InitDefault wires this so crypto.Encrypt/Decrypt call sites switch from v1 to v2 transparently.

Rotation is operator-driven via the CLI: rotate-kek re-wraps every DEK in one transaction (application data untouched); rotate-dek mints a new active DEK and demotes the prior one; rewrap-all re-encrypts every encrypted column under the active DEK. vault-status reports the active DEK, count, KEK fingerprint, and timestamps. Runbook: docs/user/operators/key-rotation.md.

When TRIMUS_ENCRYPTION_KEY is unset, the vault is not initialized and new encrypted writes fail — but the server still boots (read-only paths work), warning on stderr, in a WARN log, and via a UI banner.


13. Channels and DM pairing

Trimus can receive chat from three surfaces, all backed by engine.ChatComplete:

  • Web UI — chat with any agent from the browser (token-by-token streaming over text/event-stream).
  • Slack (internal/channels/slack) — Socket Mode, no public URL needed. Starts only when TRIMUS_SLACK_BOT_TOKEN + TRIMUS_SLACK_APP_TOKEN + TRIMUS_SLACK_COMPANY_ID are set, and TRIMUS_SLACK_USER_ALLOWLIST is required (comma-separated user IDs or *) — the dispatcher refuses to construct without it. Placeholder-then-edit reply UX. One bot → one company → one default agent.
  • Telegram (internal/channels/telegram) — long-poll Bot API, no public URL needed. Starts when TRIMUS_TELEGRAM_BOT_TOKEN + TRIMUS_TELEGRAM_COMPANY_ID are set. Same one-bot-one-company shape.

DM pairing (internal/channels/pairing) links a human's chat identity to a Trimus user: mint a single-use, 5-minute code (UI /me/pairing-codes/create or CLI pair-code), then DM the bot /pair <code>. Revoke via /me/pairing-codes/delete or CLI unpair-user. A cleanup worker expires codes and auto-revokes identities idle for >90 days. Pairing activity is audited at /companies/{id}/pairings/audit. Both channels register with the channel-health registry so /readyz reports a channel:<name> row.


14. A2A federation and MCP

A2A (Agent-to-Agent federation) lets a company's agents collaborate with peer Trimus/agent instances:

  • Discovery — public Agent Cards at /companies/{id}/.well-known/agent-card.json and per-agent card endpoints.
  • Inbound — JSON-RPC per agent at POST /companies/{id}/agents/{id}/a2a (BearerAuth; the agent must be externally-available/published; mismatches 404 to avoid leaking existence).
  • Outbound peers — registration/refresh APIs and an admin UI; the four A2A workers handle peer-card refresh, needs_routing matching + cascade (cascade exhaustion flips needs_human_attention), outbound polling, and push notifications (POST a Task object to webhooks on state divergence).

MCP (Model Context Protocol) runs in both directions:

  • Client (internal/mcp) — Trimus connects to external MCP servers registered per company (mcp_servers). A sync worker runs initialize + tools/list every 5 minutes, persists tool descriptors, and invalidates the engine's typed-tools cache. Registered remote tools surface to agents as a /mcp:<server>:<tool> action verb (#362): the engine renders a bounded inventory into agent prompts and dispatches the call server-side (the decrypted bearer never leaves the trimus-server process — workers and subprocesses only see the verb), returning the result asynchronously as an issue-timeline comment or durable chat message. Outbound calls are audited in mcp_tool_calls (agent-initiated rows carry agent_id; external /mcp/{companyId}-endpoint calls leave it NULL), with dashboards at /admin/mcp/calls and /admin/mcp/servers/stats. See docs/user/operators/mcp.md.
  • Server — Trimus exposes its own MCP endpoint at POST /mcp/{companyId} (initialize, tools/list, tools/call), backed by the per-company typed-tools registry (engine.typedToolsFor).

15. The plugin system

Plugins extend Trimus with custom tools, event handlers, scheduled jobs, and webhook endpoints. They are standalone OS-process binaries that speak a JSON-lines protocol over stdin/stdout (each message is a {"type": …, "payload": …}\n envelope; 1 MB max). The full protocol is in PLUGIN.md.

  • Host (host.go) — manages process lifecycle (installedreadyrunningstopped/errored/disabled) and synchronous tool invocation via pending channels.
  • Event bus (event_bus.go) — in-memory, company-scoped pub/sub with glob-style pattern matching (*, issue.*, *.created).
  • Tool registry (tool_registry.go) — namespaced tools (namespace:name); thread-safe; listable by company or plugin.
  • Job scheduler (job_scheduler.go) — cron-based job triggering.
  • Host services (host_services.go) — SSRF-protected http_fetch (blocks private/reserved IP ranges, resolves hostnames first to defeat DNS rebinding) and activity logging.

Sandbox + approval. Plugins run inside a Docker sandbox (read-only root, no network, resource caps) configured by SandboxConfigFromEnv(); opt out with TRIMUS_PLUGIN_SANDBOX=disabled. An approval state machine gates execution: allow SHA-256-hashes the plugin binary, and StartPlugin verifies both the hash and the approval before launching. The gate is in-handler (not middleware) so denied attempts still write a denied audit row. A host_capabilities allowlist further gates host-service dispatches per plugin.


16. SSE and live updates

Live UI updates flow through the SSE hub (internal/sse/hub.go):

  1. The board layout root carries hx-ext="sse" sse-connect="/api/live/events"; the browser opens an EventSource (cookie/query auth via UIAuth).
  2. The hub is per-company pub/sub — events are scoped to the actor's company. A periodic membership re-check drops live events for users whose company membership was revoked (API-key / instance actors stay valid for their connection lifetime).
  3. UI elements use hx-trigger="sse:<event>" to auto-refresh when events arrive. The engine and handlers publish events like issue_updated, activity_created, heartbeat_started, heartbeat_completed, heartbeat_log, and agent_status_changed.
  4. Token-streaming chat reuses the SSE machinery in the response body (user_message, agent_message_start, delta, agent_message_complete).

Split mode. When running as separate web and scheduler processes (TRIMUS_ROLE, §18), scheduler-side events still reach browser connections on web pods — through internal/ssebridge. The scheduler tees every hub publish into pg_notify('trimus_sse', …) on an async drain goroutine (the engine hot path never blocks), and each web pod holds a dedicated LISTEN connection that republishes received events into its local hub. Postgres caps a NOTIFY payload at 8000 bytes, so oversized payloads travel as a compact run-event pointer (runID + seq) and are re-hydrated from Postgres on the web side (query.GetRunEventForBridge). role=all wires neither side — one hub, one source, no bridge.

Diagnostics are at GET /api/admin/sse-metrics (instance-admin): per-company publish/sent/drop counters and active-subscriber counts.


17. Health, monitoring, and security middleware

  • Liveness/readinessGET /healthz (always 200 while HTTP responds) and GET /readyz (200/503 with per-check rows: db_ping, workers aggregate with a 2× boot-grace window via version.ProcessStart(), per-worker rows, channel:<name> rows, and a disk-pressure check). Text/plain aliases /healthz/live and /healthz/ready for load balancers.
  • MetricsGET /metrics (Prometheus; no auth — gate via network policy). Worker tick/error counters in internal/worker/metrics.go; a recent-error ring buffer in internal/alerts/ring.go.
  • Tracing — OpenTelemetry OTLP HTTP export when OTEL_EXPORTER_OTLP_ENDPOINT is set (a no-op span otherwise).
  • Operator panel/admin/operations: per-worker tick counters, instance-flag effective values + toggles (consulted on the next tick, no restart), a provider connectivity test, and a manual backup trigger.
  • Security middleware — sliding-window rate limiting (RATE_LIMIT_RPM, keyed by token or client IP, honoring X-Forwarded-For only for TRIMUS_TRUSTED_PROXIES), CSRF on form-POST groups (with a trusted-origin allowlist), body-size caps (1 MB default; attachments have their own caps), log redaction, login lockout (per email+IP sliding window), bcrypt password hashing, and LicenseGate — write routes are gated on license state (no license ⇒ 30-day trial ⇒ read-only, 7-day grace, fail-open on verifier errors); the admin surface stays ungated so a license can be installed/replaced in-app while degraded (cmd/server/routes.go, internal/license/gate). A consolidated cross-tenant audit dashboard (/admin/audit, with CSV export and per-actor timelines) merges every per-resource audit table.
  • Licensing — offline Ed25519 license tokens (internal/license): the server embeds only the public keys and verifies whatever gate.ResolveToken yields (a token installed in-app via /admin/operations/license, persisted in instance_settings, wins over TRIMUS_LICENSE). Tokens are minted offline with the trimus-license tool (§2), which alone holds the private key. Defaults live in internal/license/payload.go: a 30-day built-in trial (DefaultTrial) and a 7-day post-expiry grace window (DefaultGrace); the trial start is HMAC-sealed in instance_settings against tamper-based extension.

18. Build & deployment

Build. make build compiles bin/{server,worker,seed,trimus-cli}; make build-release injects version/commit/date via -ldflags (matching GoReleaser) with CGO_ENABLED=0 for static binaries. make all runs build + vet + test.

Deployment models:

  • systemd (Linux)scripts/service/trimus.service (system unit; runs as a dedicated trimus user; hardened: NoNewPrivileges, PrivateTmp, ProtectSystem=full, ProtectHome=read-only; EnvironmentFile=/etc/trimus/trimus.env). A user unit (trimus.user.service) runs as the invoking user and is deliberately less hardened so claude_local / codex_local can reach ~/.claude / ~/.codex; install.sh installs it by default. Remote-worker units ship disabled until opt-in.
  • macOS launchdscripts/service/com.trimus.server.plist (+ .user.plist).
  • Docker — multi-stage Dockerfile (CGO_ENABLED=0, alpine runtime, non-root trimus user, EXPOSE 3100, /data volume). The bundled docker-compose.yml ships pgvector/pgvector:pg16 (Postgres 16 + pgvector).
  • Kubernetes (Helm)deploy/helm/trimus: a web tier (TRIMUS_ROLE=web, HPA-scalable) + a scheduler tier (TRIMUS_ROLE=scheduler, pinned to 1 replica), or a single all-role Deployment (TRIMUS_ROLE=all); an optional remote-worker pool Deployment (the chart's hpa.yaml scales both web and worker); a pgvector Postgres StatefulSet (pgvector is mandatory — migrations create the vector extension); optional in-cluster MinIO (StatefulSet + bucket-create Job) for object-storage project files; ingress. Multi-replica boots are safe because boot migrations are serialized via a Postgres advisory lock (internal/db/migrate.go).
  • Release.goreleaser.yaml builds and packages archives (linux/darwin × amd64/arm64) for GitHub Releases; each archive ships a one-shot install.sh. It also publishes multi-arch (amd64/arm64) Docker images for ghcr.io/trimus-ai/trimus-server, ghcr.io/trimus-ai/trimus-worker, and ghcr.io/trimus-ai/trimus-seed via its dockers / docker_manifests sections. make dist / dist-snapshot reproduce this locally; make deploy does an in-place binary swap + service restart on a host.

Backups. trimus-cli backup/restore wrap pg_dump/pg_restore with encrypted streaming (AES-256-GCM under the active DEK → *.dump.enc) and optional S3 upload. Background workers cover scheduled backups, retention, verification, reconciliation, and composite health.

Docs. The published manual is built from docs/user/ by .github/workflows/docs.yml; make docs-stage copies the top-level reference markdowns (this file, API.md, PLUGIN.md, REMOTE_WORKERS.md, CHANGELOG.md, CONTRIBUTING.md) into docs/_build/reference/ and rewrites cross-tree links. Do not hand-edit docs/_build/ — it is generated.


19. Operator CLI (cmd/trimus-cli)

A summary; the full table is in §9 of the user docs and the CLI's own --help.

Group Subcommands
Vault / secrets vault-status, rotate-kek, rotate-dek, rewrap-all, encrypt-secrets, encrypt-providers, secret
Tenancy company, flag (instance flags), unlock-user (clear login lockout)
Plugins plugin (allow / banallow SHA-256-hashes the plugin binary; both write audit rows)
Pairing / users pair-code, unpair-user, user-model, chat (interactive REPL)
Notes reembed-notes
Backups backup, restore, backup-verify, restore-validate, backup-list, backup-stats, backup-health, backup-import
Costs cost (cross-tenant LLM spend)

CLI environment: DATABASE_URL (most commands), TRIMUS_ENCRYPTION_KEY (vault/encrypt/rotate/backup), TRIMUS_BACKUP_S3_*, and TRIMUS_API_KEY + TRIMUS_BASE_URL for the chat REPL. CLI-originated audit rows are stamped actor=__cli__ or actor=NULL (rendered "system").


See also