The engine behind the WireCat command-line tools for Telegram, MAX, mail, notes and meetings. Everything that does not depend on one messenger lives here: the data model, a local SQLite store with full-text and semantic search, a Lucene query language, the shared commands, an MCP server for AI agents, and the guard around every write. A messenger plugs in as one adapter and gets all of it.
Architecture · Write an adapter · Query language · CLI standard · API reference · Changelog · wirecat.dev
| Document | What it answers |
|---|---|
| Architecture | The layers, the module map, the store internals and migrations, who consumes what |
| Write an adapter | How to add a messenger: the port, the Messenger description, the contract cases |
| CLI standard | The rules every WireCat CLI follows: command shape, output, exit codes, permissions, MCP |
| Glossary | One word, one meaning across the tools |
| Query language · spec · Image search | The search syntax and its exact grammar; finding pictures by context and by what they show |
| Mentions · how they work | Phones, e-mail, links, documents and names found in messages: search, review, rebuild; the tables and the rules |
| Storage | The schema, the search indexes and the decisions behind them |
| Knowledge · Meetings | Labels, links and proposals across sources; the meetings module and its providers |
| Testing · Parity audit | The sandbox, the coverage floor, the checks across tg and max |
| Tool boundaries | Which tool owns which command |
| API reference | Every entry point and the detailed contract of each feature |
| Changelog | What each version changed, and what may break callers |
A messaging CLI has two halves. One speaks a protocol: MTProto for Telegram, a WebSocket for MAX, IMAP for mail. The other half is the same for every messenger: store what was read, search it, render it for a person and as JSON for a program, guard every send, expose it all to an agent. This package is the second half.
tg-cli and max-cli are built on it,
and so are the mail and notes tool cli-memo,
zoom-cli and drive-cli. Until
they move to the new name, they import it as @wirecat/cli-messaging: that was this package's name up to 0.261.0.
It is built on @wirecat/cli-base, the layer every CLI needs: two output
streams, closed error codes, the keyring, injectable clocks, the query parser. cli-base is a peer dependency, so an
install holds one copy.
In numbers, on main: 608 source files and 344 test files, 23 entry points, 27 search fields, 13 search presets.
It runs on Node 22.16+ and 24+, and on Bun.
flowchart TB
tools["tg · max · memo · zm · drive"] --> interface
subgraph core ["@wirecat/cli-core"]
interface["Interface: CLI commands and MCP tools"] --> services["Services: one use case each"]
services --> ports["Ports: MessengerAdapter · MeetingSource · TaskStore"]
services --> store[("Store: one SQLite file, wirecat.db")]
ports --> domain["Domain types"]
store --> domain
end
adapters["Adapters, in each tool: Telegram, MAX, IMAP, Zoom…"] -. implement .-> ports
core --> base["@wirecat/cli-base: output, exit codes, keyring, clocks, query parser"]
Five layers, and each calls only the layers below it: domain, adapters behind a port, ports, services, interface.
- The CLI and the MCP server are two adapters over the same services. A command and its MCP tool answer with the same data, because one service produces it.
- A port with one required core and optional groups.
MessengerCoreis what every messenger must do. Seventeen groups — server reads, polls, forums, reactions and the rest — are optional and reached throughcapability(). A messenger without a group refuses the command before connecting; it never crashes and never silently drops an option. - Decorators, so no service can forget. Every adapter is wrapped twice:
storedsaves what a read answered, andobservedtimes each call for the run record. - Override a use case, not a command. A tool replaces one service through
Messenger.services; every command and tool that uses it follows. - Tasks and meetings know nothing of SQLite. They define their own ports (
TaskStore,MeetingStore), and the store implements them.
The build enforces these boundaries. Biome import rules refuse, under src/:
- any messenger library or adapter — this package speaks no provider;
commanderor a command file in services, sends or MCP;- the SQLite table modules anywhere outside the store's own interface;
- any import from the rest of the package inside tasks and meetings, and any file system or SQLite there.
Tests guard the rest. Migrations must be numbered without gaps and may not rebuild a table. The schema spec, the code and its document must agree. A search plan that reads an index once per message fails the build.
- One query language for every list and every source. A TypeScript port of the Apache Lucene 9.12.3 parsers,
with no runtime parser dependency. Phrases,
AND/OR/NOT,+required, grouping, field groups likefrom:(alice OR bob), rangesdate:[2026-01-01 TO 2026-02-01}, comparisonssize>10MB, wildcards and regular expressions. 27 fields: sender, chat, date, file name, MIME type, transcript, subject, label and more. Fuzzy and proximity operators are refused with an error that names the alternative. Syntax · grammar - Any three letters inside a word. An FTS5 trigram index finds
portinsidereport. A second, word index ranks by BM25. - Word forms in three languages. Snowball 3.1.1 stemmers for Russian, English and Spanish:
встречаfindsвстречи. Exact forms rank first;exact:turns stemming off. - Safe regular expressions. A Thompson NFA without backtracking, with budgets for states and work, so no pattern can hang the search.
- Presets for what people actually look for.
preset:password,api-key,card,phone,email,urland seven more find candidates, labelled as candidates. - Search by meaning, opt in. Conversations are split into chunks and embedded locally by a WebAssembly ONNX runtime, the same on Node and Bun — e5-small by default, or any OpenAI-compatible API with your own key, after you see the cost. Results by meaning and by words are merged by reciprocal rank fusion.
- The archive and the server together.
--backend bothalso asks the messenger's own search; its hits are saved and filtered by the same query, so nothing is counted twice.--threadbrings the reply thread around a hit. - Bounded by design. Query size, depth, nodes, pattern length, expansions and run time all have limits.
Measured on synthetic data (methodology and results):
| Stored messages | Search p50 / p95 |
|---|---|
| 10,341 | 9.0 / 22.6 ms |
| 100,341 | 53.9 / 107.6 ms |
- One file, every messenger and account:
wirecat.dbin~/.local/share/WireCat/(%LOCALAPPDATA%\WireCat\Data\on Windows). Messages from Telegram and MAX, mail, notes and meeting transcripts sit side by side, so one query searches all of them. node:sqliteunder Node andbun:sqliteunder Bun, behind a small seam. WAL and a busy timeout on both.- History you can trust. Edits are kept as revisions and deletions as tombstones. Every sender is an identity with a person of their own, so one person across two messengers can be linked.
- Migrations an older build survives. Forward-only, additive and frozen once released.
min_compatiblelets an older CLI keep using a file a newer one migrated, and refuses a file it cannot read. - Built for the long run. History download is resumable and respects the messenger's rate limits; it can run in
the background as a job.
store check,backupandrestorelook after the file itself.
Storage · why SQLite FTS5 and no daemon
Describe a messenger once — its name, how to connect, how me maps to a chat — and the tool gets about sixty
shared commands: chats, contacts, messages, search, reactions, polls, the inbox of unread messages, watch for live
updates, history download, the store, run records, configuration, shell completion, doctor, and commands --json,
the whole command tree as data for an agent.
The same commands become MCP tools for agents without a terminal. The server offers three tools — find a command, run a read, run a write — so an agent's context does not fill with a hundred tool schemas. It runs over stdio, or over HTTP on 127.0.0.1 behind the owner's tunnel, with a one-owner OAuth login.
serve keeps the store current, one process per profile by a lock file; server install adds a systemd user unit
or a launchd agent.
- A permission level per command path: deny, readonly, ask or allow. The most specific key wins, and a built-in default can only tighten what the owner set. No level lets an agent delete for everyone.
- A recipient list and an hourly limit for sends, and a journal of every attempt that records the length of the text, never the text.
- A send id across retries. A write whose outcome is unknown is never retried automatically; a retry under the same send id cannot send twice.
- Deletion asks first, at most ten messages at a time.
- MCP writes follow the same profile permissions. An agent sees only the tools the profile allows.
- Labels, links and facts across sources, always scoped to an explicit account. What an agent adds stays proposed until the owner accepts it, with a fingerprint so a stale review cannot overwrite a newer change.
- A shared vocabulary. Before a new tag is created, similar existing tags and aliases are offered for review.
- Tasks with project keys (
RES-12), dependencies and a readiness check. - Meetings: a WebVTT parser, a
MeetingSourceport a provider implements, transcripts kept by revision, and references to one cue of one transcript.
- Readers for PDF, Word, ODF, HTML and more, each library loaded on first use.
- Local speech recognition from a pinned model catalogue; importing it loads no engine and downloads nothing.
- Charts from statistics as JSON, SVG or PNG, with a replaceable renderer loaded only when an image is asked for.
The CLI standard binds every WireCat CLI. It measures itself against POSIX, GNU, Command Line Interface Guidelines, the MCP specification and JSON Schema 2020-12, and says where it deliberately differs. A rule changes in its own pull request, before the code that needs it.
- stdout carries data, stderr carries diagnostics, in every mode.
--jsonprints one object;--jsonlone per line;--json=id,textonly those fields. - Stable exit codes a script can branch on: 2 validation, 3 config, 4 auth, 5 permission, 6 not found,
8 rate limited, 9 timeout, 14 outcome unknown, and the rest. An error is one JSON object with a stable
codeandreason— callers never parse the message. - Built for agents.
--no-inputnever prompts. Input and output are bounded (16 MiB in, 4 MiB out) and fail visibly instead of truncating. One-shot commands time out after 30 seconds and close what they hold.--dry-runpreviews any write. - The tool never picks an account. The profile is the first word of every account command; without one, the command stops and lists the choices — even when there is only one.
- Run records keep ids and timings, never content. A failure is kept by default, so
runs showcan explain it without anyone sending a log full of messages. - Tests that cannot touch real data. A sandbox keeps every test away from the owner's store, and no test may sleep. A coverage floor: 92% of lines, and at least half of every file.
- Contract cases for every adapter.
contractCasesturns the port's promises into cases any test runner can run, over an in-memory fake or a real client. - Checked across tools and runtimes. CI runs on Node 24 and on Bun; a parity workflow builds tg-cli and max-cli against this repository; macOS and Windows run on a schedule; gitleaks scans the full history.
- One shared toolchain. Biome, markdownlint, CSpell and Lefthook configs come from the WireCat standards, and CI fails when a copy drifts.
A new messenger is three pieces: an adapter that implements MessengerCore (and any optional groups), a
Messenger that describes it, and the contract cases run against it. The
adapter guide walks through each one.
import { contractCases, digitIds } from "@wirecat/cli-core/testing"
import { describe, it } from "vitest"
describe("the adapter keeps the port's promises", () => {
for (const one of contractCases({ connect: (seed) => adapterOver(fakeClient(seed)), ids: digitIds }))
it(one.name, async (context) => {
const result = await one.run()
if (result) context.skip(result.skipped)
})
})Seven entry points are stable: ., ./cli, ./store, ./sends, ./speech, ./services and ./background.
A change that breaks them waits for a breaking release — at most one a week — whose changelog says what to change.
Everything else may change in any release and is still listed under "Changed — may break callers". The full list of
entry points and what each holds is in the API reference.
⚠ Errors are recognised by shape, not by class (isCliFailure). A package linked during development brings its
own copy of cli-base, and an error built by one copy is not an instanceof the other's class.
pnpm install
pnpm lint && pnpm typecheck && pnpm test
pnpm smoke:bun # the same exports, run under BunReleases go through bin/release from main: it refuses a dirty tree, runs every check, publishes to npm and tags
v<version>. The steps and the release rules are in RELEASING.md.
