@magician-network/attendant
v0.1.0
Published
The Attendant: Magician as a Telegram-native bot — the same trust engine, spoken. Deep-link joins, consent by button, the receipt loop closed inside the chat.
Readme
@magician-network/attendant
Magician, Telegram-native: the same trust engine, spoken — deep-link
joins, consent by button, the receipt loop closed inside the chat. This
is the native tier of the three-tier architecture
(docs/boardroom-2026-09-02-telegram.md): sovereign (CLI) is the home,
open (/join) is the credibility surface, native is growth.
Run it
npm run build -w @magician-network/core -w @magician-network/attendant
TELEGRAM_BOT_TOKEN=<from @BotFather> node packages/attendant/dist/main.js| Env | Required | What it does |
|---|---|---|
| TELEGRAM_BOT_TOKEN | yes | Two minutes at @BotFather. Long-polling — no webhook, any host. |
| ATTENDANT_STORE | no (default ./.attendant-store) | Where member stores live. Never inside a repo; git-ignored here. |
| ANTHROPIC_API_KEY | no | Switches the conversational layer on. Absent, every command still works and free text gets a truthful sentence — the seam is honest, never a stub. |
Running it non-stop
sudo bash deploy/install.sh # install or deploy a revision
node packages/attendant/dist/main.js --check # verify without pollingdocs/attendant-operations.md is the deployment: one small always-on VM,
a persistent disk, systemd, the token in Secret Manager, and a nightly
encrypted snapshot. deploy/ holds the unit files and the snapshot
script. It says why not Cloud Run in particular — a platform that can
run two instances gives you two processes polling one bot token, and two
writers on one member's directory.
Three properties make a restart safe, and none of them is visible in a session where the bot is started once by hand and never dies:
- Every store write is a rename. A truncated
graph.jsonlocks a member out of their own graph permanently, because every parser here is strict — a half-written document does not degrade, it throws. - The Telegram offset is on disk, saved after every update. Held only in memory, a restart re-handles the whole last batch — and here that is a second consent notification about a consent given once. The save happens after the messages go out, so a crash re-delivers rather than drops: a duplicated prompt is visible and recoverable, a consent request that never arrived is neither.
- One writer, or none. The store is locked before the first update is fetched. A dead process's lock is taken over; a lock held by another host is refused rather than judged, and the message names the file.
- A failure to start names its own cause. The startup check was once
(await fetch(…)).json()with nothing around it, so anything that answered with something other than JSON — a proxy error page, a captive portal, an HTML 502 — killed the process with aSyntaxErrorabout JSON.parse. UnderRestart=alwaysthat is a crash loop blaming a parser for a network fault.preflight.tsseparates a rejected token from a rate limit from a failed lookup from something-that-was-not- Telegram, because each sends you somewhere different.
What a member can do
/start (or tap an invite deep link) · /invite [note] (with a
QR button for the person in front of you — same code, same
single-use rules, a photo the member shows rather than a link they
read aloud) · /add
(quick-add lines, shared grammar with the CLI and /join) ·
/intent <sentence> then confirm wants by button · /paths, then
Request the introduction by button · /requests (watch consents
land, make the introduction, seal the outcome) · /suggest ·
/renewals · /metrics · /export (the file loads straight into
magician ingest) · /delete (total, confirmed, no copy kept) ·
/help.
The ask loop — consent made forwardable
Requesting an introduction opens the engine's consent machine and hands
the REQUESTER one forwardable t.me/…?start=a_<code> link per consent
still needed. The bot never messages a hop owner itself — it may not
even know them; the ask travels on the requester's own relationship,
which is the point. A non-member who taps one onboards first and
answers second: the network grows through being needed, not through
being advertised. The bot cannot verify the tapper IS the named owner —
the same trust posture as the CLI's envelope transport, stated rather
than hidden. The first answer consumes the link. A yes notifies the
requester (a yes is consent to be known); a no sends nothing, ever —
the requester's /requests reads unavailable, indistinguishable from
a route that never existed. When every hop is in, the requester makes
the introduction and seals the outcome — kind by button, note by
sentence, sealed through introduction/1 and learned by the graph the
same way the CLI does it. Each ask also offers a per-hop QR — the
in-person version of forwarding it. A QR for a spent code is refused:
it would only mislead the person scanning it.
Sealing also hands the requester the outcome card — nameless by
construction (outcomeCard in core: built from kind, date and digest,
never from the record's people), stating 'none' as plainly as a deal,
with standing never rendered. Forwarding it is the holder's choice.
QR encoding is qrcode-generator, the reference implementation this
repository deliberately does not re-derive: an encoder written here
could pass every structural test and still be unscannable, and no
offline decoder exists in CI to say otherwise. The PNG wrapper IS
written here because it is provable — the test inflates the IDAT and
compares every pixel.
Architecture: everything with a decision in it is a pure function
Telegram ⇄ telegram.ts (thin executor, the only file with a network)
│ TgUpdate ▲ Action[]
▼ │
bot.ts — handleUpdate(update, store, cfg) → Action[]
│ free text that is not a command or quick-add
▼
claude.ts — the conversational layer (optional, seam-honest)
│
store.ts — one directory per member; filesystem plays RLSbot.tsis the CLI'srun.tspattern aimed at Telegram: every flow tested without a network (bot.test.ts).store.tsis the H1 shape ahead of the H1 server: per-member directories, strict parsers on every load, reads never create, and two deliberate cross-member surfaces —inviteByCode(deep links cap at 64 chars so invites travel as digest-derived codes, re-verified by the same parser on resolution) andaskByCode(a consent ask forwarded by the requester; the answer lands on the requester's request, its natural home, only downstream of the answerer's button).claude.tsgives the model the engine's operations as its only hands. The tool roster IS its reach (a test asserts the roster by name and that no tool can address a person), so conversation structurally cannot exceed the consent model.telegram.tsmaps actions to Bot API calls and decides nothing; a test pins it to importing no engine function.
The refusals, each a test or a structural absence
- No broadcast. Every action addresses the chat its update came from; the TWO exceptions — receipt delivery to the inviter, and the consent-YES notification to the requester — each exist only downstream of an explicit button press by the person the message is about, and the no-broadcast test walks every flow asserting no other cross-chat message can exist. A decline sends nothing, ever.
- "Keep it private" is first-class. Not sending the receipt means the inviter never learns of the join, and the copy says so.
- Inline mode is refused, as a decision. An inline result can land
in a group of hundreds; an invite is for one person. The refusal is
written at the one place it could be undone (
handleUpdate). - No contact import, no member directory, no auto-DM, no gold.
- The register rule reaches the model. A strength the model fills in is a figure nobody asserted — the tool description says to ask, and the engine refuses the malformed line regardless.
Suite
vitest run — 43 tests: the full join loop across two members, the
full ask loop across three (a forwarded ask onboarding a non-member,
the silent decline, single-use codes, seal → learn), consent both ways,
replay refusal, the two-lane no-broadcast invariant, activation at the
thumb, data rights (export/delete), member isolation and traversal
refusal, code-registry shape checks, tampered-store refusal, the honest
seam, every conversational tool, transport action mapping against a
stubbed wire, and the 4096-character cap.
Gates (roadmap v5)
T0 (this package) is built. T1 — the founding circle running on it — is the operator's move and carries the unchanged baseline gates; T3, the public viral loop, opens only past Stage 1's evidence. Growth mechanics never precede the evidence.
