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 static Go binary, one persona,
one model, MCP tools you choose, chat that only dials out (Telegram, Discord,
or Slack). No dashboard. No config UI. No open ports. Ever.
docker pull β mount persona + mcp.toml β message your bot
Chat, memory, and cron work with zero MCP servers. Tools are optional
binaries you bake or mount later β the frame stays out of the way.
|
Kernel (shotah/ai-gantry) |
Appliance (local-agent/) |
| What |
Distroless runtime only |
Kernel + Workspace / Strava / Garmin / Cast / YT Music / search |
| Image |
Docker Hub / GHCR |
Build locally (gantry-local-agent:local) |
| Start here if |
You want a tiny host you control |
You want a full life-stack assistant |
In production on the appliance path (Gemini + Telegram + MCP). Not a
demo scaffold β a kernel with a real deploy story.
Who this is for
You want a self-hosted assistant with a clear security story (outbound-only,
allowlist, distroless), inspectable memory (sqlite3 on a file you own),
and MCP as the only plugin surface β not another multi-agent platform.
Pick gantry when you want small, boring, and shippable.
Pick something else (OpenClaw-style stacks, LangGraph apps, SaaS agents)
when you need a web UI, team workspace, multi-agent routing, or pairing flows.
We deliberately don't build those.
| 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 β todo.md |
| Wonβt |
WhatsApp / Teams / Messenger webhooks |
Need inbound ports β breaks the model |
One CHANNEL per container. Allowlist only; no pairing.
Why it stays fast
Platform stacks tax every turn: huge tool catalogs, embedding round-trips,
gateways, dashboards. Gantry refuses that tax.
| Lever |
What we do |
Why it matters |
| Tool surface |
Manifest filters + MCP --tool-tier |
Smaller schemas β snappier Flash tool picks |
| Memory |
SQLite + FTS5 in-process |
No embedding API before every reply |
| Runtime |
One static binary on distroless/static |
No Node/Bun/gateway in the path |
| Gemini 3 |
Preserves thought_signature on tool rounds |
Multi-step turns finish instead of 400βing |
Fastest path is Telegram. You need: Docker, a
Gemini API key, a bot token from
@BotFather, and your numeric user id (e.g.
@userinfobot).
git clone https://github.com/shotah/ai-gantry.git && cd ai-gantry
make example-pa
# edit examples/personal-assistant/.env β GEMINI_API_KEY, TELEGRAM_BOT_TOKEN,
# TELEGRAM_ALLOWED_USERS (numeric id)
docker compose -f examples/personal-assistant/compose.yml up -d --build
docker compose -f examples/personal-assistant/compose.yml logs -f
Message the bot β /status β /new. Memory and cron work immediately; MCP
servers stay commented until you want tools.
Prefer the published image? Set image: shotah/ai-gantry:latest in that compose
file and drop the build: block (:edge / :0.x.y also on Hub +
ghcr.io/shotah/ai-gantry).
Discord variant (same compose)
Same examples/personal-assistant/ stack β swap the channel in .env after
docs/discord.md (Message Content Intent + your snowflake):
# examples/personal-assistant/.env
CHANNEL=discord
DISCORD_BOT_TOKEN=...
DISCORD_ALLOWED_USERS=123456789012345678
# GEMINI_API_KEY=... # same as Telegram path
# leave TELEGRAM_* empty
docker compose -f examples/personal-assistant/compose.yml up -d --build
DM the bot β /status β /new. Slack is the same pattern (CHANNEL=slack +
tokens in docs/slack.md).
Go further
| Path |
When |
Command |
| A β REPL |
Hack on the binary locally |
make init && make run (CHANNEL=stdio) |
| B β Kernel bot |
Telegram + Hub image, add MCP yourself |
Hello above Β· examples/personal-assistant/ |
| C β Appliance |
Full life stack + make remote-deploy |
cd local-agent && make init && make up β local-agent/ |
Cookbook: examples/README.md. Design / security /
architecture: docs/. Follow-ups: todo.md.
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:
container = persona + model + MCP set + memory volume
Want another LLM or persona? Spin up another container. 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, ships on distroless/static (no shell).
No glibc dependency in our binary, no writable rootfs beyond mounts.
- 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 containers via compose.
- Env/compose is the config plane. Secrets and scalars via env. The only
files are mounts: persona markdown, MCP server manifest, data volume.
- 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 container IS the sandbox; we run
full-autonomy with an allowlist)
4. Architecture
flowchart LR
TG[Telegram] <-->|long poll, outbound only| K
subgraph Container["container (distroless/static)"]
K[gantry binary]
M1[mcp binary A]
M2[mcp binary B]
K -->|MCP stdio| M1
K -->|MCP stdio| M2
end
K -->|HTTPS| LLM[one LLM endpoint]
K --- P[("/persona *.md")]
K --- D[("/data gantry.db")]
M1 --- S[("/secrets/...")]
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); docker logs still captures it |
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 |
false (Telegram edit-in-place / stdio token stream) |
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-workspace"
command = "google-workspace-mcp-go"
args = ["--tools", "gmail drive calendar docs sheets tasks contacts", "--tool-tier", "core"]
[[server]]
name = "garmin"
command = "garmin"
args = ["mcp"]
tools = ["get_sleep", "get_weight", "get_hrv"] # optional allowlist
# exclude = ["raw_*"] # optional denylist
# tools_prefix = "garm" # optional; default name
[[server]]
name = "strava"
command = "strava-mcp"
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 container composition IS the grant (1:1 model β you built this image/mount
for this persona on purpose).
Tool names are always prefixed {server}__{tool} (OpenAI-safe; avoids collisions).
5.3 Container contract
Sample layout matches compose.yml today; rename the service when you ship a
persona-specific image (e.g. gantry-local-agent:local):
services:
gantry:
image: gantry:local # gantry (+ tool binaries in persona images)
env_file: .env
volumes:
- ./deploy/persona:/persona:ro
- ./deploy/mcp.toml:/etc/gantry/mcp.toml:ro
- ./deploy/data:/data # gantry.db (sessions + memory)
- ./deploy/secrets:/secrets:ro
healthcheck:
# exec form + full path β distroless has no shell
test: ["CMD", "/usr/local/bin/gantry", "status"]
Second persona/LLM = second service block. Nothing shared but the host.
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, truncate each result to
TOOL_RESULT_MAX_CHARS, loop until final text or TOOL_MAX_ITERATIONS.
- Reply on the channel; append turn to session.
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/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 /persona/USER.md outranks memory;
contradictions get surfaced, not obeyed.
7.5 Why not vectors / cloud vector storage
- One user, one container: 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/gantry.db)
gantry version β build info
- Logs: JSON
slog to stderr; docker logs is the console.
- Telegram/stdio
/new β session reset; /status β uptime/model/history/tools; /tools β prefixed catalog; unix SIGHUP reloads persona.
- 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).
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: opt-in (
STREAM_REPLIES=true; edit-in-place where the channel supports it).
- 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.