# Architecture
MTClaw is a single-binary personal AI agent gateway: one long-running
process that polls Telegram, runs an OpenAI-backed agent loop with real
tools, and replies in chat. No dashboard, no web UI, no HTTP/RPC control
surface - the YAML config file is the entire interface.
## Component diagram
```mermaid
flowchart TD
TG[Telegram Bot API] -->|long poll getUpdates| CH[channel/telegram]
CH -->|gating: allowlist + mention| Q[gateway inbound queue]
Q -->|serialize per session| DISP[gateway dispatcher]
CRON[cron scheduler] -->|scheduled prompt| DISP
DISP --> LOOP[agent loop
think / act / observe]
LOOP <-->|chat.completions + tools| PROV[provider/openai]
LOOP -->|tool calls| REG[tool registry]
REG --> FS[fs: read / write / list]
REG --> WEB[web_fetch]
REG --> EXEC[exec]
EXEC --> POL{policy engine}
POL -->|deny match| REFUSE[hard refuse + audit]
POL -->|allow match| RUN[run]
POL -->|ask| APPR[Approver]
APPR -->|inline Yes/No| CH
APPR -->|terminal y/n| TERM[TerminalApprover]
LOOP --> STORE[(SQLite)]
STORE --- SESS[sessions]
STORE --- MSGS[messages]
STORE --- AUD[approvals + exec_audit]
STORE --- CR[cron_runs]
DISP -->|reply, chunked 4096| CH
CFG[config.yaml] -.-> CH & LOOP & PROV & POL & CRON & STORE
```
## Package boundaries
```text
main.go thin: calls internal/cli.Execute()
internal/
cli/ cobra commands: root, gateway, config, prompt, send, sessions,
cron, approvals, doctor, onboard, version. The only package
main.go imports.
config/ types (yaml: tags, Duration), load (decode -> resolve secrets
-> expand paths -> validate), defaults, validate, paths.
Load/Validate never write to the filesystem, but neither is a
pure function of its inputs alone: resolving a `*_file` secret
reads that file from disk when one is set, and Validate's
storage-dir check stats the filesystem up to the nearest
existing ancestor of storage.EffectiveDSN() (storage.dsn,
falling back to the deprecated storage.path alias). LoadFile
is Load's impure, os.ReadFile-based shim; internal/cli's
onboard command also reads os.Getenv directly to verify a
key/token the user just typed, without ever writing it to cfg.
store/ interfaces (Store, SessionStore, MessageStore, ApprovalStore,
AuditStore, CronRunStore), a generic SQL implementation (one
file per table, e.g. sessions.go/messages.go), the portable
embedded migrations/ (tokenized DDL, no driver-specific
syntax), dialect.go (the Dialect seam every query rebinds
placeholders and renders DDL tokens through), migrate.go (the
schema_migrations ledger, embed + apply), and factory.go
(Open(cfg)/Register(driver), a database/sql-style registry).
sqlite/ is the only registered driver: it holds every SQLite-
specific quirk (pragmas, connection pooling, the legacy
PRAGMA user_version adoption path) behind Dialect, and
registers itself via a blank import - the three production
wiring sites (internal/cli/root.go, internal/cli/doctor_checks.go,
internal/gateway/gateway.go) import it only as `_ "…/internal/store/sqlite"`, never by name. Adding a
second backend is one new package implementing Dialect below
this same, unchanged Store interface.
provider/ Provider interface + Request/Response/ToolSpec types,
openai/ (the only package allowed to import the OpenAI SDK).
agent/ the think/act/observe loop, prompt assembly, history
trimming. Depends on provider and store's interfaces and on
a ToolRunner interface - never on internal/tools directly.
tools/ registry, filesystem/web_fetch/exec tools, the policy engine
(deny -> allow -> mode), the auto-mode classifier, the
Approver interface and its terminal/deny-all implementations.
channel/ Channel interface + telegram/ (long polling, gating, message
chunking, bot commands, the inline-keyboard approver). Only
depends on config, store's interfaces, and tools' Approver/
Request/RedactSecrets - never on agent's loop internals.
gateway/ ties everything together for the long-running process: the
inbound queue, per-session dispatch serialization, the
instance lock (one per database, at storage's resolved DSN +
".lock" - see gateway.LockPath), graceful shutdown.
cron/ the scheduler loop and per-job tick logic; gronx for
expression parsing only, overlap/catch-up policy is ours.
logging/ slog handler construction from log.*.
version/ build-stamped Version/Commit/Date: Version is set via -ldflags
(only the release tag needs to be); Commit/Date are resolved
automatically from runtime/debug.ReadBuildInfo's own VCS
stamping for a `go build` run directly inside a git checkout -
not `go run`, and not a module-cache `go install`, neither of
which carries VCS settings ReadBuildInfo can read.
testsupport/ test-only: a fake HOME directory and httptest fakes of the
Telegram Bot API and OpenAI's chat-completions endpoint.
Imported exclusively from _test.go files across cli/gateway/
this package's own fakeapi subpackage - nothing in a
non-test build depends on it.
docs/ this file, configuration.md, security.md, telegram-setup.md,
verification.md; journals/ holds historical notes only
```
Dependency direction is strictly inward: `channel` and `tools` depend on
`config`/`store`/`provider`'s interfaces; `agent` depends on `provider` and
`store` interfaces plus its own `ToolRunner` seam; `gateway` and `cli` are
the only packages allowed to wire concrete implementations together. No
package below `cli` imports `cli`, and no package other than `provider/openai`
imports the OpenAI SDK.
## Schema versioning
`internal/store/migrate.go` maintains a `schema_migrations` ledger table
(`version`, `name`, `applied_at`) as the authoritative record of which
migrations a database has applied - it replaced a bare `PRAGMA user_version`
check. The SQLite dialect (`internal/store/sqlite/dialect.go`) still sets
`PRAGMA user_version` to the ledger's highest applied version after every
successful migration, even though nothing in this codebase reads that pragma
back anymore.
This is deliberate, and load-bearing for safe downgrades: an older mtclaw
binary, built before the ledger existed, only ever knows how to check
`PRAGMA user_version` against its own highest embedded migration. Keeping
that pragma in sync means an old binary opening a database a newer binary
has since migrated still sees the correct version number and applies its own
"database is newer than I understand" refusal correctly, instead of reading
a stale `0` and either failing confusingly or - worse - trying to write to a
schema it cannot fully interpret. **A future migration author must not stop
updating `PRAGMA user_version` when adding a new migration**, even though
the ledger table alone would be sufficient for every current-binary purpose;
doing so would silently break the downgrade guard for anyone still running
an old binary.
## Request lifecycle
A Telegram direct message, end to end:
1. **Long poll.** `channel/telegram` receives a `telego.Update` from
`UpdatesViaLongPolling`.
2. **Gating.** `telegram`'s `decide` (a pure function of config + message)
checks the sender against `allow_from` (DM) or the group's effective
allowlist plus `require_mention` (group/supergroup). A rejection is
silent - no reply, no reaction - so a non-allowlisted sender gets no
confirmation the bot even exists.
3. **Enqueue.** An accepted message becomes a `channel.Inbound` and is
pushed onto the gateway's bounded inbound queue.
4. **Dispatch.** The gateway dispatcher serializes turns **per session**
(one chat's messages never interleave with themselves) while allowing
different sessions to run concurrently, up to a global concurrent-turn
semaphore.
5. **Agent loop.** `agent.Loop.Run` loads recent history from the store,
appends the new user message, and iterates think -> act -> observe:
call the OpenAI provider, and for every tool call the model requests,
dispatch it through the tool registry.
6. **Policy (exec only).** `tools.Policy.Evaluate` runs deny -> allow ->
mode in that fixed order; an `approval`/`auto`-mode "ask" verdict blocks
on an `Approver` (Telegram inline buttons for a chat turn, `DenyAllApprover`
for a cron turn) before running or refusing.
7. **Buffer and flush.** Every message produced during the turn (assistant
response, tool calls, tool results) is buffered in memory and flushed to
the store **once**, in a single transaction, at the end of the turn -
see `docs/security.md`'s note on the crash exposure this trades for.
8. **Reply.** The dispatcher hands the final text back to the channel,
which splits it at markdown-aware boundaries to stay within Telegram's
4096-character message limit, renders each piece as Telegram HTML (bold,
italic, code, pre, links), and sends it - falling back to plain text on
an HTML parse-mode rejection. An approval prompt renders the command
inside an HTML-escaped `
` span so the command's own text can
never break out of it.
Reply delivery has a few guarantees worth knowing (the owners are
`internal/gateway/dispatch.go` and `internal/channel/telegram/send.go`):
replies within one chat and thread arrive strictly in turn order, though
approval prompts and busy or slow-tool notices sit outside that ordering;
a Telegram HTTP 5xx on send is retried once after 1 s (network-level
errors are not); a 429 `retry_after` of up to 60 s is honored, and a
longer wait fails at once; and if a multi-chunk reply fails part-way, the
chat gets a one-line "the reply above is incomplete" notice. Telegram API
calls carry a 60 s client timeout and abort on shutdown or `/stop`, so
shutdown does not wait out an in-flight long poll. A `/new` or `/stop`
sent right after a message does not queue behind that message's turn: the
turn is cancelled and a queued message is dropped rather than run against
history that is being reset. A model refusal is delivered as the reply
text; a `content_filter` finish reason appends a "response withheld or
cut short" notice to the reply.
A cron-triggered turn follows the same agent-loop/policy/store path from
step 5 onward; it enters at the dispatcher (step 4) via the scheduler,
bypassing the inbound queue, instead of a Telegram update, and its exec tool is always wired to `DenyAllApprover` -
see `docs/security.md` for why cron is allow-list-only.
## What we deliberately did not build
MTClaw is a small reimplementation of the
[openclaw](https://github.com/openclaw/openclaw)/[goclaw](https://github.com/nextlevelbuilder/goclaw)
shape, not a port of either project's full feature set. Each of the
following is a plausible follow-up project, and none of them is needed for
a working personal assistant:
- Dashboard / web control UI
- Non-Telegram channels (Slack, Discord, SMS, ...)
- Non-OpenAI model providers
- A WebSocket or HTTP control/RPC API
- Multi-tenancy (MTClaw is single-user by design: one config, one allowlist)
- Agent teams, delegation, or subagents
- Skills (`SKILL.md`) or an MCP bridge
- Vector memory / retrieval / conversation summarization
- Docker or VM sandboxing of tool execution (see `docs/security.md`'s
confinement-is-not-a-sandbox note)
- Lifecycle hooks, pairing codes, media/TTS/STT, tracing/OpenTelemetry,
browser control, a knowledge graph, or role-based access control
If you need any of these, look at openclaw or goclaw directly - they built
the full-featured versions this project intentionally did not.