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
embedpackage (internal/web/, surfaced ashandler.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 plaingo buildreportsversion=dev. Inlinev2.xx.x.xcomment 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-llmfallback), 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), andsafety.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 HTTPProvider(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 theWriteJSONhelper (response.go).middleware—auth.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).auth—actor.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): aPublishertees every hub publish onto thetrimus_ssepg_notifychannel (viahub.SetOnPublish), and aListenerholds a dedicatedLISTENconnection and republishes into the web tier's local hub (see §16).httpclient— the proxy-aware constructor for outbound*http.Clientinstances (TRIMUS_HTTP_PROXY/TRIMUS_HTTPS_PROXY/TRIMUS_NO_PROXYoverrides + optional proxy auth).ssrf— SSRF protection for outbound HTTP:ValidateURLpre-checks plus a dial-timeDialControlhook that rejects private/loopback/link-local/metadata IPs.
Data¶
db—RunMigrations,NewPool;db/migrations(goose SQL, 176 files);db/query(the data-access layer, roughly one file per resource);db/scope(RLS helpersWithTenantScope/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 inmetrics.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.budget—evaluate.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.Bootstrapat boot, a 5-minute refresh worker, and the state consumed bymiddleware.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 ininstance_settings); a DB-installed token overridesTRIMUS_LICENSE(see §17).projectstore+projectfs— the pluggable per-project file store:projectstoreresolves a project'sfile_backendinto aprojectfs.Store— local disk (localfs.go), S3/MinIO object storage (s3store.go, keyed offTRIMUS_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 nullablecompany_knowledge.project_id(reusing the company pipeline); and opt-in Compiled Knowledge (LLM-distilled wiki articles injected as a compact prompt index, gated byTRIMUS_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 inhandler.)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— theTRIMUS_HOMEdirectory 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):
- Load config —
config.Load()reads env vars and validatesDATABASE_URL(boot error if unset). - Configure logging —
log/slogJSON handler wrapped bymiddleware.NewRedactingHandler(strips API keys, Bearer tokens, and sensitive attributes). - Run migrations —
db.RunMigrations(DATABASE_URL)runs goose viadatabase/sql+pgx/stdlib, before the pool opens — serialized across concurrently-booting processes by a Postgres advisory lock (internal/db/migrate.go). - Open the pool —
db.NewPool()creates apgxpool.Pooland pings to verify. - Boot the vault — when
TRIMUS_ENCRYPTION_KEYis 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). - Seed admin — if
TRIMUS_ADMIN_EMAIL+TRIMUS_ADMIN_PASSWORDare set and no users exist. - Seed builtin skills —
skills.SeedBuiltinSkills(). - Evaluate the license —
gate.Bootstrapresolves the effective token viagate.ResolveToken(a DB-installed token frominstance_settingswins overTRIMUS_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. - Resolve the role plan —
plan := config.PlanForRole(cfg.Role)fromTRIMUS_ROLE=all|web|scheduler. Object construction below stays unconditional (web-tier handlers reference these objects); only the background loops — the worker poolStart, the heartbeat timer, the ~30 other background workers, and the egress DNS server — are gated onplan.RunWorkers, and the HTTP listener onplan.RunHTTP. - Create the SSE hub —
sse.NewHub(). - Wire the cross-tier SSE bridge (split mode only) — a scheduler-only process (
RunWorkers && !RunHTTP) tees every hub publish intopg_notifyviahub.SetOnPublish(ssePublisher.Publish); a web-only process (RunHTTP && !RunWorkers) startsssebridge.NewListener(pool, hub), whichLISTENs and republishes into its local hub.role=allwires neither side (internal/ssebridge, #237). - Ensure the egress network —
adapters.EnsureEgressNetwork()creates thetrimus-egress-allowlistDocker network on every role; the optional egress DNS server is constructed whenTRIMUS_EGRESS_DNS_ADDRis set but only started whenplan.RunWorkers. - Create the engine —
engine.New(pool, hub, cfg). - Start the worker pool —
worker.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 onplan.RunWorkers. - Initialize the plugin host — event bus, tool registry, host services, job scheduler;
SetSandboxConfig(plugins.SandboxConfigFromEnv()). - Build the router —
chi.NewRouter(), global middleware, thenRegisterRoutes(...). - 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):
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 untilregisterissues one (workers pre-provisioned from the/admin/workersRegister form already hold an admin-minted key, #335). The register response carries the operating prompt; the standaloneGET /api/workers/operating-promptre-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/events → hub.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-partyPOST /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). IncludesPOST /notes/search(pgvector semantic search). - Chats / notes / routines / approvals / projects / goals / secrets / providers / skills — resource-scoped sub-routers (e.g.
/api/chats/{id}withmessages+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), plusGET /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:
- Agent API keys —
Authorization: Bearer <raw-key>; SHA-256-hashed and looked up inagent_api_keys. The raw key is shown once at creation. - Board-user API keys — same Bearer flow against
board_user_api_keys. - User sessions — the
staple_sessioncookie validated againstuser_sessions; checked first byUIAuth. - 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.
- UI auth bridge —
UIAuthturns a session cookie (orkeyparam /staple_keycookie) into a Bearer header soBearerAuthcan run uniformly. - Worker auth —
WorkerAuthvalidates 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, viaWORKER_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 callsEngine.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)¶
- If
agent.ProviderIDis set → provider-based dispatch (resolveAdapterForAgent), which enforces the provider'senabledflag and a cross-company IDOR guard, then returns a provider adapter. This takes precedence over the factory map. - Else if
agent.AdapterTypeis in the factory map → that factory. - Else → the default
llmadapter (an emptyAdapterTypedefaults to"llm"inadapter.go). - 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:
WithTenantScopeopens a transaction, switches to thestaple_approle (NOLOGIN,NOBYPASSRLS, created idempotently by migration 079) and setsapp.current_company_id. Thetenant_isolationRLS 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 oncurrent_setting('app.current_company_id')::uuid.WithBypassis 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 whenTRIMUS_SLACK_BOT_TOKEN+TRIMUS_SLACK_APP_TOKEN+TRIMUS_SLACK_COMPANY_IDare set, andTRIMUS_SLACK_USER_ALLOWLISTis 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 whenTRIMUS_TELEGRAM_BOT_TOKEN+TRIMUS_TELEGRAM_COMPANY_IDare 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.jsonand 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_routingmatching + cascade (cascade exhaustion flipsneeds_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 runsinitialize+tools/listevery 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 thetrimus-serverprocess — workers and subprocesses only see the verb), returning the result asynchronously as an issue-timeline comment or durable chat message. Outbound calls are audited inmcp_tool_calls(agent-initiated rows carryagent_id; external/mcp/{companyId}-endpoint calls leave it NULL), with dashboards at/admin/mcp/callsand/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 (installed→ready→running→stopped/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-protectedhttp_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):
- The board layout root carries
hx-ext="sse" sse-connect="/api/live/events"; the browser opens an EventSource (cookie/query auth viaUIAuth). - 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).
- UI elements use
hx-trigger="sse:<event>"to auto-refresh when events arrive. The engine and handlers publish events likeissue_updated,activity_created,heartbeat_started,heartbeat_completed,heartbeat_log, andagent_status_changed. - 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/readiness —
GET /healthz(always 200 while HTTP responds) andGET /readyz(200/503 with per-check rows:db_ping,workersaggregate with a 2× boot-grace window viaversion.ProcessStart(), per-worker rows,channel:<name>rows, and adisk-pressure check). Text/plain aliases/healthz/liveand/healthz/readyfor load balancers. - Metrics —
GET /metrics(Prometheus; no auth — gate via network policy). Worker tick/error counters ininternal/worker/metrics.go; a recent-error ring buffer ininternal/alerts/ring.go. - Tracing — OpenTelemetry OTLP HTTP export when
OTEL_EXPORTER_OTLP_ENDPOINTis 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, honoringX-Forwarded-Foronly forTRIMUS_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 whatevergate.ResolveTokenyields (a token installed in-app via/admin/operations/license, persisted ininstance_settings, wins overTRIMUS_LICENSE). Tokens are minted offline with thetrimus-licensetool (§2), which alone holds the private key. Defaults live ininternal/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 ininstance_settingsagainst 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 dedicatedtrimususer; 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 soclaude_local/codex_localcan reach~/.claude/~/.codex;install.shinstalls it by default. Remote-worker units ship disabled until opt-in. - macOS launchd —
scripts/service/com.trimus.server.plist(+.user.plist). - Docker — multi-stage
Dockerfile(CGO_ENABLED=0, alpine runtime, non-roottrimususer,EXPOSE 3100,/datavolume). The bundleddocker-compose.ymlshipspgvector/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'shpa.yamlscales both web and worker); a pgvector Postgres StatefulSet (pgvector is mandatory — migrations create thevectorextension); 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.yamlbuilds and packages archives (linux/darwin × amd64/arm64) for GitHub Releases; each archive ships a one-shotinstall.sh. It also publishes multi-arch (amd64/arm64) Docker images forghcr.io/trimus-ai/trimus-server,ghcr.io/trimus-ai/trimus-worker, andghcr.io/trimus-ai/trimus-seedvia itsdockers/docker_manifestssections.make dist/dist-snapshotreproduce this locally;make deploydoes 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 / ban — allow 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¶
- README.md — what Trimus is and how to run it
- API.md — the full HTTP JSON API reference
- PLUGIN.md — the plugin protocol specification
- REMOTE_WORKERS.md — the remote-worker operating contract
- CONTRIBUTING.md — contributor guide and feature workflow
- The full operator/user manual: https://docs.trimus.ai/