How this repository is put together, and which seams you may not cross.
44 operations are declared in src/spec/operations/, the official Bot API
has its own half (§18), and the diagram below shows only the first commands;
max store and max runs answer from this machine. Both runtimes pass. Everything here was
verified against the real service unless it says otherwise. Round trip re-verified live 2026-09-20:
max chats list --limit 3 --verbose --record made three requests, stdout was one JSON value, stderr
only the event lines.
"Brief §N" is docs_ai/REQUIREMENTS.md; rulings (NEED-nn) are in
docs_ai/DECISIONS.md. Long detail lives in companions under architecture/:
session.md (§7), messages.md (§10),
store.md (§15, §16).
MAX and Telegram share one CLI standard and adoption profile. Its external references are POSIX utility conventions, GNU command-line conventions and Command Line Interface Guidelines. They inform option syntax, help, composition and interface stability; the project's resource names and statistics hierarchy are our own decisions. This is an adoption profile, not a claim of complete external conformance.
For agents, the shared standard also assesses the MCP tools specification, the Agent Skills format and additional tool-design guidance. The shell and renderer own the machine contract, services own operations, and guards enforce permissions for CLI and MCP alike. Retrieved messages are data, never authority to act.
The public compliance audit and work queue distinguishes source checks, isolated observations, deliberate differences and remaining gaps. Shared fixes reach this CLI through an exact dependency adoption; documenting a convention does not change the installed binary or assert that every command already follows it.
the public admin/statistics evaluation separates independent model contexts, synthetic CLI/MCP traces, deterministic fixture checks and live-provider claims. Its six contexts and 38 assessed outcomes are a bounded observation, not broad agent conformance; original model identity was not captured and MCP used a shell proxy. Read the report's subject versions and first-failure record before comparing results with another release. Reproduction tooling belongs to cli-messaging; the user CLI contract explains what that evidence means for a statistics answer.
src/commands/ session · account · chats · contacts · messages · store · runs
│ speaks the domain model, owns no protocol knowledge
▼
src/client.ts MaxClient — the only thing above here that knows MAX exists
│ client.chats.list() · client.messages.send() · client.account.me()
┌───────┼────────────────┬──────────────────┐
▼ ▼ ▼ ▼
src/domain/ src/spec/ src/generated/ src/protocol/
Chat one operation, opcode registry frame codec
Message declared once operation table connection
Contact Valibot schemas wire wrappers seq correlation
mapping provenance never hand-edited
▲
src/session/ handshake (INIT → LOGIN) · keyring · state
src/record.ts account-scoped login data in the shared store
src/wire-events.ts which ids and counts a MAX event carries
(the run directory and the event format: cli-messaging's, T6 item 3c)
the diagram above is the personal account's own half. Since #260 and T6 (#330–#382)
max also plugs into @wirecat/cli-messaging: src/messenger.ts describes MAX once as a Messenger
(maxMessenger — provider, paging, the guard, permissions), and src/adapter/max-adapter.ts
(maxAdapter) wraps MaxClient behind cli-messaging's MessengerAdapter port, translating MAX's
models into the shared domain types. src/program.ts registers cli-messaging's shared commands —
store, conversations, polls, reactions, inbox, review, and chats mark-read among chats
— beside max's own; they reach MAX only through that adapter, and read and write the shared store
(~/.local/share/cli-messaging/messages.db), not a cache of max's own. So the path is now
commands → cli-messaging services → maxAdapter → MaxClient → protocol; MaxClient stays the
only code that knows the wire. src/adapter/contract.test.ts runs cli-messaging's contract cases over
the adapter.
- Commands are resource + action (
NEED-48); the diagram matchesmax --help. Adding an operation: §12. @wirecat/cli-coresupplies output streams, renderer, error model and exit codes, keyring, config and clocks — the non-MAX half, shared withbraze-cli.- One npm package (
@wirecat/max-cli, commandmax), split by directory, not workspace (NEED-12).src/commands/may not import fromsrc/protocol/,src/spec/orsrc/generated/: a Biome rule fails the build with that sentence. Verified by writing the forbidden import, both directions. - A second rule marks what any messenger CLI could share (
CLI-30,NEED-147; wasCLI-26, a number taken three times):src/domain/models.ts,src/rendering/andsrc/resolve.tsmay not import the protocol, the specification, the generated wrappers, the session, the client or the commands. The files stay where they are; a futurecli-messengerpackage is a move of exactly these, not an untangling.src/domain/map.tsis outside it on purpose — it is where MAX becomes the model. Verified by a forbidden import in each of the four places. - The newer directories have rules too (2026-10-01):
src/adapter/keeps off the wire, the commands, MCP,max serve, the shared store and the bot;src/session/off the commands, MCP, the shared store, the adapter and the bot;src/server/off the commands, MCP, the adapter,program.tsand the bot;src/messenger.tsoff the wire, MCP,max serveand the bot;src/bot/off the personal account's protocol, session, spec, server, store and client. Test files are exempt in the first three, since they wire the real pieces together. Not ruled, because code already does it:messenger.ts→commands/context.js,session/adopt.ts→server/start.jsandclient.js. Verified by a forbidden import in each place.
| Layer | Knows about | Must never |
|---|---|---|
commands/ |
Chat, Message, Contact, the renderer, exit codes |
opcodes, frames, ws, MAX field names |
client.ts |
opcodes, the session, the domain model | rendering, process.stdout, argv |
domain/ |
MAX's wire shapes, and only to translate them out | the network, the session |
spec/ |
every operation, its shape and where it came from | the socket, the session, output |
generated/ |
what follows from spec/ |
being edited by hand |
protocol/ |
frames, seq, the socket | who is asking or why — including the handshake |
session/ |
the keyring, the state file, INIT → LOGIN | anything it can ask cli-core for |
runs/ |
what a request cost, and where a record of it goes | what a request said — §13 |
Why: if MAX's transport changes, only protocol/ and client.ts change. That is why we own the
adapter instead of depending on @bruch/max-client (NEED-17).
this section described text frames {ver: 11, …} on
wss://ws-api.oneme.ru, and said MessagePack and LZ4 were a TCP-only transport. The web client's
live socket showed otherwise, and we now send what it sends. The capture is
src/testing/fixtures/web-capture-2026-09-25.json — proof, not somebody's README.
wss://api.oneme.ru/websocket, Origin: https://web.max.ru. Every frame is a 10-byte big-endian
header — ver (10), cmd (0 request, 1 response, 2 event, 3 error), seq u16, opcode u16,
then flags << 24 | length — and a MessagePack body:
- LZ4 block compression both ways from 32 bytes of body,
flags = ⌈raw / compressed⌉, kept even when it comes out larger; zero-length body for "no payload". The web client does exactly this on every one of 73 recorded frames. zstd (flags = 0xFF) was never seen and is refused. - Extension type 1 wraps a 64-bit number — ids and times from MAX, ids from the web client. We
wrap every
bigintwe send (toWireIdmakes one of every id) and unwrap what arrives. seqis two bytes and starts at 0. It wraps; themax servepush filter compares modulo 65536, because a plain<=drops every push after the wrap without a word.
⚠ Integers. Message ids have 18 digits (measured), past Number.MAX_SAFE_INTEGER, and times
are 13 — both arrive as 64-bit. The decoder returns a number whenever it is exact and a bigint
only when it is not; a bigint timestamp would break every Date above this layer. Every id
leaves the domain mapper as a string — an id is never arithmetic. Chat ids reach 14 digits and
contact ids 9 (pnpm probe:ids, 2026-09-19).
⚠ The mock speaks our own codec, so the suite alone only proves the codec agrees with itself.
What ties it to MAX: LZ4 checked against the reference C library both ways, the compression rule
checked against every recorded frame, and src/protocol/parity.test.ts — our INIT, LOGIN and
history requests field by field, in order, beside the web client's.
seq is the only link from answer to question: MAX interleaves pushed events with responses on one
socket, so "the next frame is my answer" eventually reads somebody's incoming message.
connect → do the thing → print → close, the close in afinally.Connectionowns the socket, per-request timers and listeners;close()clears all three. ⚠ An open WebSocket or live timer keeps Node alive, somax chats list --json | …would never return: print-then-hang is a defect.INIT(6) thenLOGIN(19) come first; MAX answers nothing before them. The handshake is handwritten insrc/session/handshake.tsand stays so (brief §9); the spec supplies its two payloads, so field names have one home, not two. It gets the client'sinvoke, not the socket, so both requests are checked and reported like any other (§13).- LOGIN returns profile, chats, contacts, recent messages and presence, so
account showandchats listneed no further request.PROFILE(16) is a profile update and refuses an empty payload — not used for reading. it is sent now, bymax account updateonly (MAX-33), with the current first name always included, as the web client does. interactive: falseon login and history (the web client sendstruefor a watching person). Whether it moves presence or read state is not settled (RES-5).
CHAT_HISTORY (49) and CHAT_MARK (50) are separate. Nothing that reads sends 50;
src/client.test.ts asserts opcode 50 is absent from everything a read sends. It goes out from client.chats.markRead only —
max chats mark-read, messages list --mark-read, and the MCP tool behind --allow-mark-read — through
the send guard as kind read. Its request is PyMax's shape, not measured (src/spec/operations/chats.ts).
⚠ Until the spec landed (2026-09-19) that assertion compared against a missing Opcode.CHAT_MARK,
so it read not.toContain(undefined) and always passed — tsconfig.json excluded test files
from type checking (OPS-8). 50 is now a declared opcode with the reason it is never sent.
Not proven: whether LOGIN itself moves presence. Needs a second device watching.
Unlike Braze ("never retry a write"): MSG_SEND carries a client-generated cid, and MAX
deduplicates by it. Measured 2026-09-19 in Saved messages: the same cid twice gave the same
message id and one copy, also across two connections and logins — the case a retry faces.
- A lost send is retried once, with the same
cid. If that fails too, the result isoutcome_unknown(neither failed nor sent) and names thecidassendId;max messages send --send-id <n>repeats it without risking a second copy. - Measured in Saved messages — the same
cidresent 5 and 15 minutes after the first send got the first message's id back each time, and one copy stayed. Fifteen minutes is the longest gap measured; beyond it is unproven, and--send-idreuse hours later should not be relied on. cidis monotonic per process.Date.now()alone gave two sends in one millisecond the samecid, and deduplication would have silently dropped the second. A test caught it.--silentsendsnotify: false(normallytrue). ⚠ Onlytrueis measured; what MAX does withfalsehas never been observed (that means messaging somebody), so it may be ignored. The first use against a real chat settles it.- Every send passes a guard first (cli-messaging's
sendGuardsince T6 item 3b, 2026-09-30 — correction: it wassrc/sends/guard.ts;src/sends.tsnow builds it for a profile and stamps each journal line with the write'soperationId; handed toMaxClientbycreateClient): a read-only profile (code 5), an optional recipient list (7), an hourly limit (8) — all before the socket when the chat is an id. Every outcome, refusals included, goes to<state>/sends/<profile>.jsonlwithout the text; the limit counts that file. a write that counts first holds areservedline, written under a lock file (wx, so Windows too), and its outcome settles it; reading folds settled ones away, so two processes at the limit cannot both pass. Overmax serveonly the server reserves. These stop a model talked into sending by what it read, not an agent that edits the configuration (NEED-159). the operation-id wrapper retains one prepared request object acrossaskandcheck, because the shared guard binds confirmation to that object. The client-side server wrapper forwardsasktoo; a matching operation id on a different request grants no confirmation. MAX now uses canonical permission levels; legacy configuration is translated at read time untilconfig migratewrites the canonical file. CLI, native reads, MCP tools and resources, and raw server requests enforce the same resource policy. Confirmed writes carry explicit permission keys to the server, which rechecks the current configuration; neither confirmation nor moderation consent bypasses a denied or read-only action. max serveruns the same guard on every write it forwards, and journals it (NEED-269): anything of the owner's can write to its socket, not only a command that checked first. Every request is first checked against the operation's strict schema, asbuildRequestchecks it in a command, so a field the specification does not have is refused there too. It reads the configuration again for each write, soconfig set readOnly trueneeds no restart. The command still checks — a refusal before an upload — and journals only its own refusals; the server writes the rest, with the outcome it saw — one line per attempt. A retry after no answer repeats thecidin the same chat and leavesoutcome_unknownthen its outcome; the limit counts the two once. The first line is not held back for the retry, so a crash cannot lose a message that may have gone out. Acidthat was sent, or reused in another chat, counts as a new send.- A forward is a send —
MSG_SENDwith aFORWARDlink and no text — so it gets the same one retry with the samecidand counts against the hourly limit. An edit and a pin are not retried, like a reaction. An edit and a pin that notifies count against the hourly limit, and so does each person added to a group; a scheduled message counts in the hour it goes out. The guard's other two checks apply to all of them. - A deletion is guarded like a send, and each deleted message counts toward the hourly limit
(
MAX-47): it wakes nobody, but many at once is what MAX bans for. At most 10 per call, and--allow-dangerouson every one. Not retried.max servelogs in again after one, since MAX does not push a connection's own deletion back to it and the chat's last message may be gone. - An edit sends the message's attachments back as history gives them. Measured 2026-09-24:
an edit with
attachments: []removes a photo. Somessages editreads the message first, and refuses somebody else's message or a forward before asking MAX.
| What | Where | Why |
|---|---|---|
| token | OS keyring, service max-cli, account = profile |
it is the credential |
deviceId, viewerId, login count |
<state dir>/profiles/<name>.json, mode 0600 |
identifying, and it must be stable |
- The device identity is saved the first time it is read, before any use. Otherwise a failed login or a second call shows MAX a new device — what brief §34 forbids. Found by a test.
MAX_TOKENis read before the keyring (CI, probes).max session starttakes a token four ways (token,qr,qr-chrome,sms); every one ends inadoptToken.qr-chromeandsmslet web.max.ru log in inside a throwaway Chromium profile and read__oneme_authover the DevTools protocol (src/session/browser.ts);qruses our socket (src/session/login.ts) and draws the code in the terminal. SMS over our socket meets a captcha (measured 2026-09-24), so it has no socket path.
- Token exchange
(
MAX-11, measured 2026-09-22): LOGIN returns a new token once the old one has aged, then the same one. Stored after the account check; a keyring failure does not fail the command; never printed. - Login first, profile bound to one account
(
MAX-12, measured 2026-09-21): a token reaches the keyring only after MAX accepts it (src/session/adopt.ts); a profile refuses another account's token. - The login asks for a delta (
MAX-10,NEED-103, measured 2026-09-20,pnpm probe:contacts): the four*Syncfields are timestamps; we send the stored marker, written in one transaction with its rows.max contacts syncforgets it. - Chat members are named:
every group and dialog participant via
CONTACT_INFO, batched at 100; channels skipped. 23 people in chats against 6 contacts (2026-09-20).
Nothing on the wire names this tool (brief §34): WEB_USER_AGENT in src/spec/identity.ts is the
web client's shape, Origin is https://web.max.ru, the device identity is stable per profile.
We use ws, not Node's built-in WebSocket: measured on both runtimes, ws sends custom headers
identically; the built-in's support depends on the bundled undici version.
max messages list "Ivan"matches chat titles as a fragment; an exact title does not win while another title contains it too. An ambiguous name is an error listing the candidates — a send to the wrong chat does not undo. Verified live (a full contact name matched two chats; the command stopped). Candidates print one per line with ids, and in the machine error ascandidates: [{ id, title }], so an agent picks one without parsing a sentence.- A dialog has no title, so the partner's name comes from contacts: one
CONTACT_INFO(32) for all unknown partners. Measured: 6 contacts in the login against 17 dialog partners; named dialogs went from 0 of 17 to 16 of 17. - The same lookup names group senders (
FIND-45). The login names contacts only, so on 2026-09-22 every sender in a real group but the owner was a bare id. After a history read, unnamed ids are looked up in the store, then in oneCONTACT_INFO, and kept; the next read of that chat asks nothing. A refused lookup leaves the ids, notes it on stderr, and still answers. - ⚠ Opcode 36 is
CONTACT_LISTin tsmax and PyMax butGET_BLOCKEDin the protocol documentation. Not used — not a difference to discover in production.
--json, or any non-terminal stdout: one JSON value on stdout and nothing else — no spinner,
✓, warning or ANSI. Diagnostics go to stderr in every mode, so the contract holds by construction.
Verified live: stdout one JSON value, stderr empty. Failures too: run() returns an exit code
(never throws), the error goes to stderr (JSON when piped, a sentence in a terminal), codes from
cli-core (4 authentication, 130 interrupt). Scripts branch on the code, never on text.
{ "items": [ … ], "page": 1, "limit": 20, "hasMore": true }chats list,contacts list,messages list, every machine mode — also--all(page: 1,hasMore: false) and--offline. The shape never says where rows came from; exit code and diagnostics do.hasMoreis a boolean, not a total (counting rows MAX has not sent is another cost). Exact for chats and contacts; a chat list MAX cut (#chatsCut) sayshasMore: trueon its last nonempty page. ⚠ Formessages listit is a claim about our copy: history comes in windows, and MAX's answer says nothing of what is older. A page can come back short mid-chat, so a short page back is checked with one request for a single older message (#olderThaninsrc/client.ts).- In a terminal, the "more pages" line goes to stderr, from
src/commands/paging.ts, notrenderer.result:account showandsession start|endare not listings and answer a bare object.
User-facing version: ../usage.md. Messages print as a feed, not a table; a message id
holds its send time (id >> 16, measured 2026-09-22); a photo link opens without a token
(NEED-120); a reply carries the other message whole —
architecture/messages.md.
When two conflict, the earlier wins:
correctness > not touching someone's account by accident > auditability
> agent determinism > human UX > protocol coverage > implementation cleverness
The second ranks high because this is a real personal account: an unwanted send, a read receipt or a message deleted by a retry happened to a person.
Every opcode and payload shape lives in src/spec/; its Valibot schemas are the definition
(NEED-7). src/generated/ follows from it and is never edited by hand.
| Generated, committed, checked in CI | Handwritten, and staying that way |
|---|---|
src/generated/opcodes.generated.ts — the registry |
the socket, seq correlation, timeouts |
src/generated/operations.generated.ts — the table |
the INIT → LOGIN handshake |
src/generated/client.generated.ts — wire wrappers |
the schemas themselves — they are the spec |
protocol.md — the reference page |
the domain mapping, and error classification |
- In
src/spec/operations/<subject>.ts,defineOperation: dottedname, MAX'sconstant, the opcode, a strict request shape, a loose response shape, where the shape came from, and itsguard—nullwhen nobody else sees it, otherwise what the send guard is asked, read from the request as it goes on the wire. The field is required:max servepasses a write on only through that declaration (NEED-269), and a request it cannot read — a send with no chat that is not a new group, a chat update that is two changes — is refused, never guessed. pnpm generate. A duplicate opcode or name, or a name not<group>.<method>, stops it with a sentence naming both sides.- Call it from
MaxClient. The generated wrapper is typed and stays internal.
A known number we will not send gets reserveOpcode, with the reason: it is in the registry but
never gets a method, so "do not call it because it is in the enum" is enforced by code.
- Requests are strict: an unknown field is refused before the socket.
- Responses are loose: new fields pass through, fields we do not use are optional, nothing in a
response fails a command (brief §29,
NEED-35). Each answer is still checked against the spec; a mismatch is one line on stderr, so a renamed field does not silently blank a column. - ⚠ That note is built by hand from the field path and expected type. Valibot's message quotes the value, which may be somebody's message (brief §14, §24). A test asserts no body reaches a note.
asId reads; id() in src/spec/scalars.ts writes. It replaced Number(chatId), which silently
rounds past 15 digits (Number("7268926000000000001") is a different chat). Ids below 2⁵³ give the
same bytes as before. It never mattered yet: measured 2026-09-19, nothing the login returned was
past 2⁵³ (longest chat id 14 digits). Hardening, so nobody re-asks.
pnpm generate writes and formats in one step; CI regenerates and asserts no diff (OPS-3). The
banner has no date or version — anything that moves on its own makes that check fail forever.
Each request is one object: direction, operation, opcode, seq, ids named, duration, bytes, how
many things came back. Two sinks: --trace renders it on stderr live; --record writes it to a
file. Either, both, or (default) neither. What it looks like and how to use it:
../diagnostics.md.
→ session.login op 19 seq 2 871 B
← session.login op 19 seq 2 213ms 48.0 kB 25 chats 6 contacts
- Never in an event: a chat title, a person's name, a message body, a phone number, a token — not truncated, not hashed (brief §14, §24). Ids are in: opaque, only the session holder can correlate them, and every real complaint is about one conversation.
- ⚠ This holds because
idsOfinsrc/wire-events.ts(correction 2026-09-30: it wassrc/runs/events.ts, and it names the send identitysend, notcid) builds the event from named fields instead of filtering the payload (a filter lets an unseen field through). No branch reachessession.login'stoken. Pino's redaction is the second line of defence. MaxClienttakes an injected event sink, likewarn— it reports, never decides where. The hook is in#send(builds, times, checks).- ⚠
startSessiongets the client'sinvoke, not the socket (since 2026-09-20). Before, it calledconnection.invoke, so INIT and LOGIN bypassed the hook, andchats list(nothing sent beyond LOGIN) recorded an empty run. It also made LOGIN's answer checked against the spec, which foundmessagesis an object, not an array (PROTO-6). - A request refused before the socket (bad id, unknown field) emits no event; the run still records outcome and code.
- A local answer gets its own event shape: no opcode,
seqor bytes ("0 bytes" would be fiction), plus areason:offline(caller said never connect) orhistory(window older than a fetch returns, so the record is authoritative). "Did not ask" and "nothing to ask" differ; only the second is the local copy doing its job.
local commands discovery and runs read commands mount the shared
cli-messaging factories; the MAX modules contain only the app binding. Discovery includes the
common contract field, and truncated run output points to --limit. Reading these records
starts no session and creates no new run.
<state dir>/runs/<UTC day>/<timestamp>-<command>-<suffix>/ with run.json and events.jsonl.
../diagnostics.md has the layout, modes (0700/0600), the twice-written
run.json (running, then the outcome) and 30-day retention, pruned only when a recorded run
starts, whole days by directory name. Rules not stated there:
- Off unless asked (
NEED-49,NEED-52):--record,--no-record, else the config file. a run that fails is kept anyway —recorded()holds the newest 500 events in memory and opens the directory only on a failure, withkeptBecauseFailed: true. Recording turned off by name (--no-record,"record": false) keeps nothing (keepFailedRunsinsrc/config.ts). every failure, not only one insiderun(). The last catchsrc/program.tsdelegates parsing and fallback recording to cli-messaging's shared runner. Its consumer hooks supply legacy command context, server options and bot recording settlement; the shell uses MAX's resolver with the bot/personal scope. Anything no run settled (wasSettled) reaches the samerecorded(): Commander's usage errors, checks before a command opens its run, and commands that never open one. The run is named by the command's words only (commandPath), never the arguments. - Beyond requests:
warningevents carry a code from a closed list (WarningCode), never the sentence; a crash adds acrashevent with the class and up to tenfunction file:lineframes, never the message;providerErroris MAX's refusal key when it is shaped like one (providerErrorKey, cli-messaging's — correction 2026-09-30: it wasmaxError,src/runs/events.ts).run.jsoncarriesruntime,platform,arch. run.jsonis written atomically.finishruns on every path and awaits the logger: Pino appends via a plain stream, and a process that exits first loses the tail — the part somebody wanted.- The day directory is UTC: a run at 01:35 in Madrid lands under the previous day.
- Files, not the SQLite cache (
NEED-51): opposite lifecycles, and twomaxruns can overlap. Two appends never conflict; two DB writers take a lock, and a diagnostic must not fail its command. max runs list | show <id> | path <id>read them back. An empty list explains itself on stderr and names--record.runs pathanswers{"path": "…"}too (NEED-88,max runs path <id> --json | jq -r .path; a terminal gets the path on a labelled line): a contract with no exception is one nobody has to remember, and was worth more thancat "$(max runs path <id>)/events.jsonl".
common settings resolve through cli-messaging settingsFor. MAX retains its strict configuration schema and config-write diagnostics; an adapter hook resolves serve, mcpTools and the default-only speech model from the same layers. Detailed source paths, legacy permission provenance and the existing duration syntax remain unchanged.
Flag → environment → config file → built-in default, decided once in resolveSettings
(src/config.ts). Commands take what they are given; one that re-derived the order would disagree.
Every field, variable and the file format: ../configuration.md; profiles and
paging for users: ../usage.md. The profile follows the same order: the first word
(max personal chats list), then MAX_PROFILE=personal, then "defaultProfile" in the file, then
default.
--profilewas deleted (NEED-45). The first word is the profile unless it names a command (liftProfileinsrc/profile.ts). It must come first:max --json personal chatsreadspersonalas a command and fails.- ⚠ A profile named after a command could never be selected (
max chatsmust mean the command), somax session startrefuses the name (refuseCommandName) — creation is the only moment to explain it. Names become file names and keyring accounts: letters, digits,.,-,_only. - An unknown first word gets
authentication_errornaming that profile and the fix (max x session start, notmax session start, which would log in the wrong profile).max chat listis the everyday case; the message says which word was read as what.
What a user sets and how: ../configuration.md, ../usage.md. The rules
behind it:
~/.config/max-cli/config.json, mode0644, read bycli-core'sloadConfigFile. A missing file is not an error; a malformed one is, in every output mode, naming the field.- The schema is
strictObject:"limitt"is reported asprofiles.default.limitt, where plainobject()would drop it silently. The opposite of MAX answers, whose unknown fields are kept (NEED-35). - No field can hold a secret (token, phone, chat id). Having nowhere to put one beats a rule.
timeoutMsunset is the transport's 30 s (src/protocol/connection.ts);colorunset means decide from the terminal.--limit,--page(1-based),--allon every listing, resolved once inresolveSettings;--pagewith--allis avalidation_error. Neither--pagenor--allhas a config field — a page number in a file is nobody's setting. Paging is SQLLIMIT ? OFFSET ?over the store.- ⚠ A page number over a live list can repeat or skip a row (newest first; a new message shifts
the boundary). Documented in
--help, not engineered away. messages listpages with--beforeinstead, anchored in time (chats.historytakesfrom), so it is exact. It takes a message id, or an ISO 8601 time when the id is gone.- ⚠ A bare integer is always an id. Ids have 18 digits, ms timestamps 13; telling them apart by size breaks when either changes.
contacts list --order recent|namehas no config field: a second spelling of a flag is how--profilecame to be deleted.
MAX_TOKENoutranks the keyring (cli-core'sCredentials.read) — a token for a container or probe without storing it.- ⚠
MAX_CONFIG_DIR,MAX_STATE_DIRandMAX_CACHE_DIRchange which keyring entry a profile means.pathsAreOverriddenmakes the servicemax-cli:<configDir>, so a login made with them is invisible without them ("no session" for a profile that exists). Same environment for both, or neither. This cost an afternoon (UX-1).
--quiet silences diagnostics only; a failure still prints, since an exit code says what kind of
thing failed but not which chat (src/output.ts). A MaxClient is built in exactly one place
(src/commands/context.ts), which hands it the renderer's note. Before, the protocol note went
straight to stderr, so --quiet missed it and --json did not shape it (BUG-7). Six commands
each remembering to pass a warn is a rule that breaks once and then stays invisible; one
construction site cannot.
max no longer opens its per-profile cache. src/record.ts keeps
login chats, people, membership snapshots and the contact sync marker in cli-messaging's
account-scoped messages.db. It opens lazily once the account is known. The shared stored
adapter wrapper saves history; shared services own search, offline reads, edits and deletions.
A failed record write warns without losing an answer MAX already gave, and does not advance its
marker. max serve owns its record across reconnects and closes it on stop.
The native server also runs the shared daily member-fetch scheduler for explicitly tracked groups.
commands/serve.ts injects its worker, keeping the server independent of the adapter. It waits
one minute after login, uses that server's held connection, and skips rosters already saved today
(UTC), including partial snapshots. Reconnects share one worker; shutdown drains its current
fetch and closes its store. Tracking neither starts a server nor extends an auto-started server's idle lifetime. Run max server continuously for daily history.
max contacts sync forgets the contact marker before a full login. Only a complete nonempty
chat snapshot marks departures; an empty answer keeps the previous list. max cache is removed;
store clear --left replaces its departed-chat cleanup. doctor reports an existing legacy file
without opening it. The old data is not migrated.
Details: architecture/store.md.
shared services search the account's messages.db; MaxClient
no longer implements local search. Search finds recorded history, not unread history that has
never been fetched. store fetch fills it; --offline reads it without connecting.
max mcp (src/mcp/) mounts personalMcpTools(maxMessenger) from the shared SDK through
registerPersonalMcpTools. MAX retains MaxSession, native permission guards and account-scoped
archive services. Unsupported forum tools are filtered out; photo previews and cached direct
transcripts retain native callbacks under the shared input schemas. Legacy chats_check and
chats_rules names remain available.
- One connection per agent session, never for long: calls serialize; the connection closes after 2 minutes idle, after 5 minutes since login, on a connection failure and on stdin EOF. Local evidence and conversation callbacks use the profile's known account without connecting.
- Profile permissions govern discovery and execution. The mounting scope rechecks the exact key, including local reads. Native write guards retain recipient checks and the journal. Retired access flags grant no permissions.
- MCP writes use effective profile permissions.
askandallowexecute without a server confirmation form;readonlyrejects writes anddenyrejects access. Retired confirmation flags emit a diagnostic and change nothing. Native recipient and send-journal guards still apply. - Local inference releases the connection. Warm embedding models are disposed on shutdown; speech models are never downloaded by a tool. Native transcription retains same-model cache reuse.
- Discovery uses search, read and write tools.
max_tools_searchfinds command definitions;max_readandmax_writeexecute a command path with its argument object. Exact command permissions are rechecked during execution; a client permittingmax_writedoes not bypass them. - stdout is the protocol. Nothing in
src/mcp/writes data through the renderer; notes go to stderr. The process exits on stdin EOF — measured 110 ms under Node and Bun, 2026-09-24. serveStdiomay build a probe server before settling on the protocol era, so the server is a factory over oneMaxSession, never one instance.
User side: ../mcp.md. The research behind the choices is local-only
(docs_ai/plans/2026-09-23-mcp-research.md).
max … is the personal account over the unofficial protocol; max bot … (CLI-25) is a bot over
MAX's official HTTP Bot API. The two share no transport, no session and no
generated code. Nothing under src/bot/ imports src/protocol/, src/session/ or
src/generated/, and the other way round.
spec/bot/schema.yaml the official OpenAPI document, committed with provenance.json
│ pnpm bot:spec:sync — the only step that reads the network, and only when run by hand
▼
scripts/bot/openapi-adapter.ts OpenAPI → cli-core's format-neutral ApiModel; strict, names what it refuses
scripts/bot/overrides.ts read / write / destructive, where the HTTP method is wrong
│ pnpm bot:generate — part of `pnpm generate`, so CI's freshness check covers it
▼
src/bot/generated/{types,schemas,manifest}.ts docs/dev/bot-api-coverage.md
The generator itself is @wirecat/cli-core/codegen, shared with other CLIs; only the adapter from
OpenAPI is ours. The generated Valibot schemas keep the schema's constraints and turn a 64-bit id
into an exact decimal string. They expect numbers from lossless-json, and they decode only — a
request is validated with them and then sent as the original lossless value. Enums and
discriminated unions are strict, so an update type newer than the snapshot fails validation.
Live evidence 2026-10-03: addMembers remains in the current official OpenAPI0.0.33
and succeeded against the test group with a bot holding member-management rights. The participant
was absent before the request, present in personal-account read-back afterwards, and the original
membership and role were restored. The provider website announces removal on 2026-09-30;
that notice conflicts with the observed backend behavior. Keep the generated and friendly commands;
do not generalize one successful group check into availability for every bot or chat.
At run time: src/commands/bot.ts → src/bot/client.ts (the only door to the generated code) →
src/bot/transport.ts. The transport sends the token as the bare Authorization header, parses
with lossless-json, repeats only reads (429, 502, 503, no answer; Retry-After first), and turns a
write that got no answer into outcome_unknown. It trusts the Минцифры root that signs
platform-api2.max.ru inside this process only: Node's process-wide default CA list (22.19+), or
per request on Bun (NEED-293). The bot token is bot:<profile> in the keyring (src/bot/auth.ts).
A Biome rule keeps src/bot/ away from src/protocol/, src/session/, src/spec/ and
src/client.ts.
Рейтинги архива и evidence реализованы в общих services/store cli-messaging; adapter
сохраняет providerMetadata.graph v1 с доказанной reply-связью или известным отсутствием
reply у полного сообщения. Неполный кадр оставляет связь неизвестной.
Пользовательский контракт описывает формулы, пределы и качество данных.
The shared stats command mounts unanswered, responses, newcomers and discussion reports. Question roots use compiled Lucene selection; explicit reply context extends through a captured cutoff in the same SQLite read snapshot. Membership stays preserve actual join versus first-seen. CLI and the three-tool MCP frontend call the same shared service; report evidence uses a separate versioned selection inside the existing evidence commands. See the user guide.
cli-messaging migration 24 stores explicit roster batches with member/stay references and independent
latest counter observations. Known joining dates define cohorts; firstSeenAt is not substituted.
Retention uses the first roster within 24 hours after each checkpoint, disclosing observed/unknown/pending
denominators and interval departures. The shared service and existing MCP frontend reuse bounded evidence.
MAX's adapter marks authoritative history/context counter reads and refreshes views/reactions through
exact messages.around reads. Comments are unsupported. Counter-only refresh updates the local store,
preserves message bodies/attachments/tombstones and makes no send or read-mark request. Legacy differing
values lose freshness. See user statistics guide.