ai-gantry ποΈ
gantry (n.) β the rigid frame in a CNC machine or crane that holds and
positions tools. The frame does nothing by itself; the tools do everything.
A personal agent you can actually own. One Distroless Go binary. One
persona. One OpenAI-compat model. MCP tools you choose. Chat that only dials
out (Telegram, Discord, or Slack). No dashboard. No config UI.
No open ports. Ever.
static binary + persona + mcp.toml + any OpenAI-compat LLM β outbound chat
Chat, memory, and cron work with zero MCP servers. Tools are optional
binaries on PATH (or baked into an image) β the frame stays out of the way.
Pull it (fastest hello)
Images publish on every main push and every v* tag to
Docker Hub and GHCR:
| Tag |
Meaning |
shotah/ai-gantry:latest |
Latest release |
shotah/ai-gantry:edge |
main (moving) |
shotah/ai-gantry:0.x.y |
Pinned release |
ghcr.io/shotah/ai-gantry:β¦ |
Same tags on GHCR |
docker pull shotah/ai-gantry:latest
docker run --rm shotah/ai-gantry:latest version
Wire Telegram + Gemini with the compose example (Hub image by default β no
local build): docs/deploy-docker.md β
examples/personal-assistant/.
Kernel vs appliance
|
Kernel (gantry) |
Appliance (local-agent/) |
| What |
Runtime only β env + mounts |
Kernel + Workspace / Strava / Garmin / Cast / YT Music / search |
| Run it |
Hub image, binary, or systemd |
Native Linux + Ollama, or Docker compose |
| Start here if |
You want a tiny host you control |
You want a full life-stack assistant |
In production as a native appliance (Telegram + local Qwen via Ollama +
MCP). Same kernel ships on Hub for cloud LLMs (Gemini/Grok). Not a demo
scaffold β a binary with real deploy stories.
Who this is for
Self-hosters and local-LLM operators who want an outbound-only assistant,
inspectable SQLite memory, and MCP as the only plugin surface β not
another multi-agent platform.
| Pick gantry when⦠|
Pick something else when⦠|
| You want small, boring, shippable |
You need a web UI or team workspace |
| Allowlist + no inbound ports is the security story |
You need WhatsApp / Teams inbound webhooks |
| Local models must finish tool turns |
You want multi-agent routing / pairing flows |
| Env + mounts is enough config |
You want a dashboard or no-code canvas |
Longer ICP / competition / evangelism notes:
docs/positioning.md.
| Status |
Channel |
Notes |
| Shipped |
Telegram (default) |
Fastest hello path; long-poll |
| Shipped |
Discord |
DMs; Gateway WS β docs/discord.md |
| Shipped |
Slack |
Socket Mode only β docs/slack.md |
| Planned |
Signal |
Sidecar (signal-cli); not a Bot API |
| Wonβt |
WhatsApp / Teams / Messenger webhooks |
Need inbound ports β breaks the model |
One CHANNEL per process. Allowlist only; no pairing.
Why it stays sharp
Platform stacks tax every turn: huge tool catalogs, embedding round-trips,
gateways, dashboards. Gantry refuses that tax β and hardens the loop for
local models that invent tool names or park answers in CoT.
| Lever |
What we do |
Why it matters |
| Tool surface |
Manifest filters + MCP --tool-tier |
Smaller schemas β better tool picks (Flash or Qwen) |
| Name repair |
Prefix alias/rebuild, closest-name hints, then a grammar-constrained retry |
google_search__β¦ and mcp__get_hrv still land; an unresolvable name makes the retry unable to misspell it |
| Think stalls |
Promote CoT β reply after tools |
Multi-step turns finish instead of ERROR |
| Printed calls |
Parse a tool call written as text and run it |
A model that prints {"name":β¦} never speaks JSON at you |
| Multi-bubble |
Interrupt + coalesce + settle (COALESCE_SETTLE_MS) |
βStravaβ¦ wait Garminβ¦ nvm calendarβ β one joined turn |
| Slow turns |
Per-turn perf logs + tool trace in the chat bubble |
Know whether prefill, thinking, or an MCP is the wait |
| Memory |
SQLite + FTS5 in-process |
No embedding API before every reply |
| Runtime |
One static binary (systemd or Distroless) |
No Node/Bun/gateway in the path |
| Gemini 3 |
Preserves thought_signature on tool rounds |
Cloud multi-step turns don't 400 |
Details: docs/mcp.md Β· docs/deploy-native.md.
Start here
Full life-stack (tools + auth helpers): local-agent/.
Cookbook: examples/README.md. Positioning /
design / security / MCP: docs/.
Reference
Deep contract below β principles, env table, memory, packaging. Skim if you
already have a bot running; read before you grant MCP tools or expose an
allowlist to friends.
1. Problem statement
Platform agent stacks drift toward multi-agent products: multiple providers,
dashboards, console features, config UI. Our deployment model is the opposite
(pitch + ICP: docs/positioning.md):
process = persona + model + MCP set + data dir
Want another LLM or persona? Another process (second systemd unit or compose
service). No in-process routing, no dashboard, no manual config surface β a
kernel that does exactly that and nothing else.
2. Design principles
- Stupid simple. One agent, one model, one channel loop. If a feature
needs a diagram to explain, it probably belongs in an MCP binary, not here.
- Highly performant. Pure Go, static binary, no CGO, small RSS, no
background frameworks. Long-poll + goroutines; nothing dials in.
- Highly portable.
CGO_ENABLED=0 static binary β runs under systemd or
Distroless (no shell in the image). No glibc dependency in our binary.
- Plugin-centric. Capabilities come from external binaries over MCP
stdio. The gantry hosts tools; it does not implement them. Import libraries
over writing our own (official MCP SDK, maintained Telegram lib, pure-Go
SQLite).
- 1:1, always. No multi-provider config, no multi-agent config, no peer
routing. Scaling = more processes (compose services or systemd units).
- Env + files is the config plane. Secrets and scalars via env. Structure
via persona markdown, MCP manifest, and a data directory (bind-mounts in
Docker; paths on the host for native).
- Memory is structured and inspectable. SQLite rows you can read and
delete with
sqlite3, not opaque embedding blobs. Persona files always
outrank recalled memory.
3. Non-goals
- Web dashboard, gateway, REST/WS API, pairing
- Multi-agent, multi-provider, model routing/fallback chains
- Multi-channel in one process; inbound-port chat (WhatsApp Cloud, Teams,
Messenger webhooks) β see channel table under Who this is for
- Built-in web search, built-in workspace tools (those are MCP binaries)
- Vector database service (see memory design β SQLite is the store)
- Sandboxing/risk-profile machinery (the host or Distroless container is the
sandbox; we run full-autonomy with an allowlist)
4. Architecture
flowchart LR
TG[Telegram] <-->|long poll, outbound only| K
subgraph Host["host or Distroless container"]
K[gantry binary]
M1[mcp binary A]
M2[mcp binary B]
K -->|MCP stdio| M1
K -->|MCP stdio| M2
end
K -->|OpenAI-compat| LLM[one LLM endpoint]
K --- P[("persona/*.md")]
K --- D[("data/gantry.db")]
M1 --- S[("secrets / .config")]
Deploy shapes: native Β· Docker.
4.1 Process model
One OS process. Goroutines:
| Goroutine |
Job |
| channel poller |
Telegram getUpdates long-poll, allowlist filter |
| agent loop |
per-message: assemble prompt β model β tool calls β reply |
| MCP supervisors |
one per server: spawn, health, restart w/ backoff |
| memory consolidator |
optional timer job (see Β§7) |
No goroutine talks to the network inbound. Healthcheck is gantry status
(exit-code) reading a heartbeat row in SQLite β no port needed.
4.2 Package layout (single module)
cmd/gantry/ main: run | init | auth | status | version
internal/config/ env parsing + validation, fail-fast at boot
internal/channel/ Channel interface; telegram/, stdio/ (test/dev)
internal/provider/ ONE implementation: OpenAI-compatible chat client
internal/mcp/ stdio host: spawn, list tools, call, truncate, restart
internal/agent/ the loop: prompt assembly, tool iteration, caps
internal/session/ bounded history, /new reset, rolling summary
internal/memory/ SQLite structured memory + FTS5 + consolidation
internal/persona/ load + concat markdown from /persona
internal/heartbeat/ SQLite heartbeat for `gantry status`
internal/drain/ wait for in-flight turn on shutdown
internal/cron/ scheduled turns β agent β channel push
(Diagrams + sequences: docs/architecture.md.)
4.3 Dependencies (import over write)
| Concern |
Library |
Why |
| MCP client |
github.com/modelcontextprotocol/go-sdk |
Official SDK; stdio transport, schema handling |
| SQLite |
modernc.org/sqlite |
Pure Go (no CGO), FTS5 works, one file DB |
| Telegram |
github.com/go-telegram/bot |
Zero-dep, maintained, long-poll native |
| LLM client |
github.com/openai/openai-go/v3 |
Official; custom base_url covers Gemini's OpenAI-compat endpoint, xAI, Ollama, etc. |
| Env config |
github.com/caarlos0/env/v11 |
Struct tags β env, tiny |
| MCP manifest |
github.com/pelletier/go-toml/v2 |
Minimal TOML for mcp.toml |
| Logging |
stdlib log/slog |
JSON to stderr (keeps stdio REPL clean; journald / docker logs) |
One provider implementation (OpenAI-compatible) is deliberate: Gemini, Grok,
and local models all speak it. Model identity is just LLM_BASE_URL +
LLM_MODEL + LLM_API_KEY. No provider registry.
5. Configuration contract
Everything is env or a mount. No config UI, no config set, no sync step.
5.1 Environment variables
| Var |
Required |
Example / default |
LLM_BASE_URL |
yes |
https://generativelanguage.googleapis.com/v1beta/openai |
LLM_API_KEY |
yes |
β |
LLM_MODEL |
yes |
gemini-3.5-flash |
LLM_MAX_TOKENS |
no |
4096 (completion output cap; 0 = provider default) |
TELEGRAM_BOT_TOKEN |
yes (telegram) |
β |
TELEGRAM_ALLOWED_USERS |
yes (telegram) |
123456789,987654321 (numeric IDs; allowlist only β no pairing) |
TELEGRAM_ERROR_REPORTING |
no |
off (off|error|warn β tee slog into the Tim chat as expandable HTML) |
DISCORD_BOT_TOKEN |
yes (discord) |
β |
DISCORD_ALLOWED_USERS |
yes (discord) |
snowflake user IDs; allowlist only β see docs/discord.md |
SLACK_BOT_TOKEN |
yes (slack) |
xoxb-β¦ bot token |
SLACK_APP_TOKEN |
yes (slack) |
xapp-β¦ app-level token (connections:write) β docs/slack.md |
SLACK_ALLOWED_USERS |
yes (slack) |
Slack member IDs; allowlist only |
CHANNEL |
no |
telegram (default), discord, slack, or stdio |
PERSONA_DIR |
no |
/persona |
DATA_DIR |
no |
/data |
MCP_MANIFEST |
no |
/etc/gantry/mcp.toml |
HISTORY_MAX_MESSAGES |
no |
200 |
HISTORY_MAX_TOKENS |
no |
128000 |
TOOL_RESULT_MAX_CHARS |
no |
16000 |
TOOL_MAX_ITERATIONS |
no |
20 |
TOOL_SCHEMA_MAX_TOKENS |
no |
0 (log estimate only; >0 = hard fail if over) |
MEMORY_ENABLED |
no |
true |
MEMORY_BACKEND |
no |
builtin (or mcp:<server-name>, see Β§7 / Β§10) |
MEMORY_CONSOLIDATE_MINUTES |
no |
30 (0 = off; builtin backend only) |
CRON_ENABLED |
no |
true |
CRON_TZ |
no |
UTC (IANA, e.g. America/Los_Angeles) |
CRON_MAX_JOBS |
no |
50 |
CRON_TICK_SECONDS |
no |
15 |
STREAM_REPLIES |
no |
true (Telegram edit-in-place / stdio token stream) |
SHOW_THINKING |
no |
true (Telegram CoT italics β expandable blockquote; needs STREAM_REPLIES; independent of LLM_REASONING_EFFORT) |
TOOL_TRACE |
no |
compact (compact = Making Calls: β, β; full = β name / β timing; off = hide; needs STREAM_REPLIES) |
COALESCE_SETTLE_MS |
no |
2000 (quiet ms after a bubble interrupts a running turn, before one joined turn; a lone message never waits; 0 = off) |
SPINUP_NOTICE_MS |
no |
4000 (post βworking on itβ after this much model silence; the first turn after start posts at once; needs STREAM_REPLIES; 0 = off) |
LOG_LEVEL |
no |
info |
Boot is fail-fast: missing required env = clear error + exit 1. No partial
starts, no interactive setup.
5.2 MCP manifest (the one file)
Lists of processes don't fit env vars; this is the single structured file,
mounted read-only. TOML, minimal:
[[server]]
name = "google"
command = "google-mcp"
args = ["--preset", "everyday"]
auth_args = ["auth"]
download_tag = "latest" # or pin "v1.0.0"; "latest" resolves via GitHub API at plan time
download_url = "https://github.com/shotah/google-mcp/releases/download/{tag}/google-mcp_{version}_{os}_{arch}.tar.gz"
[[server]]
name = "garmin"
command = "garmin"
args = ["mcp"]
auth_args = ["login"] # optional; `gantry auth garmin`
tools = ["get_sleep", "get_weight", "get_hrv"] # optional allowlist
# exclude = ["raw_*"] # optional denylist
# tools_prefix = "garm" # optional; default name
download_tag = "latest"
download_url = "https://github.com/shotah/go-garmin/releases/download/{tag}/garmin_{version}_{os}_{arch}.tar.gz"
[[server]]
name = "strava"
command = "strava-mcp"
auth_args = ["auth"]
download_tag = "latest"
download_url = "https://github.com/shotah/go-strava-mcp/releases/download/{tag}/strava-mcp_{version}_{os}_{arch}.tar.gz"
download_url + download_tag feed gantry tools-fetch (native deploy and
Docker bake): placeholders {os} {arch} {tag} {version} (version =
tag without leading v). gantry tools-plan prints the resolved inventory
without downloading. Optional auth_command / auth_args drive
gantry auth <name>.
Listed servers still start; tools / exclude only filter what is
published to the model (boot logs tools_listed vs tools_published).
Schema cost is logged as est_tokens (chars/4); set TOOL_SCHEMA_MAX_TOKENS
to hard-fail when the published set is too fat.
No bundles/grants layer: if a server is in the manifest, the agent gets it.
The process composition IS the grant (1:1 β you chose this persona + MCP set
on purpose).
Tool names are always prefixed {server}__{tool} (OpenAI-safe; avoids
collisions). Local models often turn the hyphenated prefix into underscores
(google_search__google_search), or invent one outright (mcp__get_hrv); the
host repairs both back to the catalog name when exactly one tool can be meant,
and on hard misses returns a model-facing suggestion naming the closest real
tools. Full contract, /tools REPL workflow, and why:
docs/mcp.md.
5.3 Host layout
Same three directories whether Docker bind-mounts them or systemd points at
/opt/gantry/β¦:
| Role |
Typical path |
| Persona markdown |
PERSONA_DIR β /persona or /opt/gantry/persona |
| MCP manifest |
MCP_MANIFEST β /etc/gantry/mcp.toml or /opt/gantry/mcp.toml |
| SQLite + secrets |
DATA_DIR β /data or /opt/gantry/data |
Compose sample + Hub hello: docs/deploy-docker.md.
systemd + Ollama: docs/deploy-native.md.
6. The agent loop (context management)
This is the part that earns its keep. Keep it boring and bounded:
- Assemble prompt: persona markdown (concat, fixed order) + memory
hydration block (Β§7.4) + session history (bounded) + user message.
- Call model with MCP tool schemas (loaded eagerly at boot; refreshed on
server restart).
- Tool iteration: execute calls via MCP host (repair unambiguous prefix
mistakes, else suggest closest real names and constrain the next call to
them with a response-format grammar), truncate each result to
TOOL_RESULT_MAX_CHARS, loop until final text or TOOL_MAX_ITERATIONS.
Each call appends a trace line (β name, β 1.2s Β· 4.1k chars) to a
streaming reply so long chains show motion.
- Reply on the channel; append turn to session.
Every turn logs its own cost: model call (first_token_ms, dur_ms,
prompt_est_tokens, tool_schemas), tool done (dur_ms, result_chars),
and turn perf (model_ms / tool_ms / total_ms). On local models that
split is the difference between a prefill problem and a slow MCP β
docs/deploy-native.md.
Bounding rules:
- Hard cap
HISTORY_MAX_MESSAGES; drop oldest turns past HISTORY_MAX_TOKENS.
Token counts are chars/4 estimates and are labeled as such everywhere
they surface (logs, /status) β see Β§10. Persona + last N turns are
always protected.
- When history is trimmed, dropped turns fold into a persistent per-session
summary paragraph via the same LLM (one string β not a framework). The
summary is injected as a system block on later turns.
- Tool results older than the last 4 collapse to one line:
[tool gmail.search: N chars, truncated].
/new wipes the session (memory untouched).
7. Memory design
Direction taken from Google's Always-On Memory Agent (2026): no embeddings,
no vector DB β an LLM writes structured rows into SQLite and a background job
consolidates them. At personal-agent scale, structured + FTS5 beats ANN
search and stays greppable/deletable. (Meta/OpenAI memory products converge on
the same shape: typed facts + episodic notes + periodic distillation.)
7.1 Store
One SQLite file $DATA_DIR/gantry.db (WAL mode), pure-Go driver:
CREATE TABLE memory (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL, -- fact | preference | person | episode | insight
subject TEXT NOT NULL, -- "chris", "climbing", "mom"
content TEXT NOT NULL, -- one atomic statement
source TEXT NOT NULL, -- chat | consolidation | operator
confidence REAL DEFAULT 1.0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
expires_at TEXT, -- TTL per kind (episodes decay, facts don't)
superseded_by INTEGER -- consolidation links, never silent delete
);
CREATE VIRTUAL TABLE memory_fts USING fts5(subject, content, content=memory);
CREATE TABLE session (...); -- bounded history + rolling summary
CREATE TABLE heartbeat (...); -- for `gantry status`
7.2 Write path
The model gets three built-in tools (the only non-MCP tools in the gantry):
memory_store(kind, subject, content) β atomic statements only
memory_recall(query) β FTS5 + recency-ranked
memory_forget(id | query) β hard requirement; memory must be correctable
Auto-save is off by default. Auto-saved hallucinations (wrong emails) are
worse than no memory. The model stores deliberately; the consolidator promotes.
7.3 Consolidation (the Google idea)
A timer job (default 30 min, 0 disables) runs a cheap pass with the same LLM:
- Read unconsolidated
episode rows + recent session summaries.
- Extract durable
fact/preference/person rows; link duplicates via
superseded_by; flag contradictions with persona files for the human
instead of overwriting.
- Write
insight rows for cross-cutting patterns ("trains Tue/Thu, skips
when traveling").
Cheap model, bounded batch, fully skippable. This is our "sleep cycle."
7.4 Read path (hydration)
At session start and on memory_recall, hydrate at most ~30 rows:
active facts/preferences (non-expired, non-superseded) + FTS5 hits for the
current message, rendered as a compact block:
[memory]
- (person) mom: prefers calls over texts
- (preference) user: coaching tone, no fluff
Persona precedence is law: anything in USER.md outranks memory;
contradictions get surfaced, not obeyed.
7.5 Why not vectors / cloud vector storage
- One user, one process: recall corpus is hundredsβthousands of rows, not
millions. FTS5 + recency + kind filters is enough and is debuggable.
- Embeddings add a second model dependency, cache, and dimension migration
for marginal recall gain at this scale.
- Cloud vector stores add network, cost, and privacy surface to the most
sensitive data in the system.
- Escape hatch: schema reserves the option of an
embedding BLOB column
later. If recall quality ever demonstrably hurts, add it then β behind the
same memory_recall interface, no design change.
8. Ops surface
gantry run β the daemon (default)
gantry status β exit-code healthcheck (reads heartbeat row in $DATA_DIR/gantry.db)
gantry version β build info
- Logs: JSON
slog to stderr (journalctl native, docker logs in compose).
- Telegram/stdio slash commands:
/new (session reset), /cancel (halt in-flight turn), /status, /tools; unix SIGHUP reloads persona.
- Multi-bubble (interrupt β coalesce β settle): a lone message runs at once;
a follow-up sent while a turn is running cancels the current loop, joins the
bubbles into one user message, waits
COALESCE_SETTLE_MS (default 2000)
of quiet, then resubmits as a single turn. /cancel also clears a pending
settle batch. Tools that already finished are not undone. Cron/reaction
synthetics skip this path. Details:
local-agent/docs/telegram.md.
- Spin-up notice: local models prefill in silence, so
SPINUP_NOTICE_MS
(default 4000) opens the streaming bubble with a status line before the
first token β at once on the first turn after start (known-cold: model load
and/or empty prompt cache), otherwise only if the turn stays silent that long
(a prompt-cache miss, which no provider API exposes). The line is transient β
the reply replaces it, unlike a tool trace. Needs STREAM_REPLIES.
- Telegram photos: inbound β vision (Gemini/OpenAI-compat); outbound
SendPhoto when the
reply includes a markdown image or *.png/*.jpg/β¦ URL (caption = remaining text).
- Dev:
make build|test|lint|run|ci|check; make install-hooks for pre-commit
(autofix + lint + test; same shape as go-garmin).
That's the entire ops/UI story. No port is opened by the gantry, ever.
9. Build & packaging
- Go β₯ 1.26, single module,
CGO_ENABLED=0, -trimpath -ldflags="-s -w".
- Targets:
linux/amd64, linux/arm64.
- Image: multi-stage β build gantry (and later copy MCP tool binaries in),
final
FROM gcr.io/distroless/static-debian12:nonroot (ca-certs + tzdata,
uid 65532, no shell). Healthchecks must use exec form
(["CMD","gantry","status"]), never CMD-SHELL.
MCP children must be static binaries too β there is no libc/shell to lean on.
- CI:
go vet, golangci-lint, go test ./internal/... ./cmd/... with coverage; on main,
the badge is pushed to gh-pages as badges/coverage.svg (README uses the raw/gh-pages URL).
- Release:
make release (or BUMP=minor|major / TAG=vX.Y.Z) bumps
VERSION, tags, and pushes; .github/workflows/release.yml runs GoReleaser
on v* tags (same flow as the other shotah MCP repos).
- Images:
.github/workflows/docker.yml pushes multi-arch Distroless to
shotah/ai-gantry (Hub) and ghcr.io/shotah/ai-gantry β :edge on main,
:latest + semver on v* tags. Hub overview syncs from
docs/dockerhub.md (PNG banner; root readme is too
large / SVG-heavy for Hub).
10. Decisions
Locked choices are summarized here; full rationale and rejected alternatives
live in docs/choices.md.
- Name: ai-gantry ποΈ β frame that holds tools; binary
gantry.
- Token counting: estimates (chars/4), labeled as estimates.
- Memory: builtin SQLite, replaceable via
MEMORY_BACKEND=mcp:<name>.
- Streaming replies: on by default (
STREAM_REPLIES=true; edit-in-place where the channel supports it; set false for a single final bubble).
- Channel auth: allowlist only β empty allowlist fails boot (Telegram / Discord / Slack).
- Runtime image: distroless/static-debian12:nonroot β MCP children static too.
- Logs on stderr β stdout stays clean for the stdio REPL.
Architecture diagrams / sequences: docs/architecture.md.
Security tradeoffs & residual risks: docs/security.md.
Design deep-dive: docs/design.md.
License
MIT β see LICENSE.