//developers — the CLI and the MCP server

Developers

A command-line client that sends end-to-end encrypted notifications from scripts and agents, and a local MCP server that lets an assistant read what this machine already holds — and, if you opt in, reach you and no one else. No webhook, no vendor in the middle.

AGPL-3.0-only·Owner-bound MCP·Status: §12

//start — why a CLI, and a first account

Why a CLI at all

Machines need to tell people things. A build broke, a certificate expires on Thursday, a job that has run quietly for a year did not run last night. The usual answer is a webhook into a team chat. That makes the message readable by the chat vendor, by any proxy in front of it, and by anyone who later gets into the workspace.

The CLI encrypts message bodies with libsignal before sending. Our servers queue ciphertext, routing metadata and public prekey material; they never receive the private decryption keys. It is the same protocol the app uses, described in How it works. Nothing is relaxed because the sender is a cron job.

Two things follow, and we would rather write them here than have you discover them:

First contact is trust-on-first-use. The first time two accounts talk, an unknown identity key is accepted. A malicious server could substitute that first bundle. Compare safety numbers out of band before the conversation matters — tacendum safety prints them.

Content is encrypted; metadata is not. Who sent to whom, when, message ids and delivery timing all remain visible to the service. Encryption hides what was said, not that something was said.

This is deliberately narrow. It is a notification channel with real cryptography, not a chatbot platform.

Install and register

An account is a keypair. No phone number, no e-mail, no code, no human verification is required — and the command-line client links nothing: the optional email and username flows exist in the phone app only. Registration signs a server-issued nonce with a locally generated private key. The identity key is the account.

install · needs node 20+ and pnpm

# clone the tag your build names; the CLI builds from the same tree. # §11 has the repository's state today, and the route until then. git clone https://github.com/MiranaTechs/Tacendum.git cd Tacendum && pnpm install && pnpm --filter @tacendum/cli build npm i -g ./packages/cli

register a machine

$ tacendum register ci-bot registered ci-bot as 01K9RZ4M8V2QW7X3YB6NCDPFGH uploaded identity, signed prekey, kyber prekey, 100 one-time prekeys

That ULID is the machine’s address. Hand it to whoever needs to reach it, or scan it into the app as a contact. The private key stays under ~/.tacendum and never leaves the host.

Keep $TACENDUM_HOME to yourself. Anyone who can read it can be that account. Account directories are 0700; credential files such as identity.json and profile.json are 0600. Treat the directory like an SSH private key: one machine, one account, and not in a repository.

//scripts — sending and reading from your own code

Sending from a script

It reads a pipe, because a tool that cannot read a pipe is not a Unix tool.

$ tacendum send ci-bot 01K9RZ4M8V2QW7X3YB6NCDPFGH "deploy finished" # or pipe the thing that failed, with a title $ make build 2>&1 | tacendum send ci-bot 01K9RZ… --title "build failed"

A recipient is a bare ULID, or the name of another account registered on this same machine. Contact nicknames are not implemented — see §12. (tacendum pair is different: it binds an integration account to its owner, §12.)

Exit codes are stable, so a script can branch on them. The contract is append-only: never renumber.

0

success

1

unclassified error

2

permanently unused — deliberate; below

3

authentication

4

network

5

recipient or prekey failure

6

safety-number change

7

receipt timeout

8

rate-limited

9

usage

10

refused

2 is permanently unused: Claude Code treats hook exit 2 as an agent-control code, so no typo may ever produce it.

why nothing may ever take 2

Claude Code reserves hook exit code 2 as its blocking code — a Stop hook that exits 2 forces the agent to continue and feeds the hook’s stderr back to the model as instructions. Running from that hook is this CLI’s reason to exist. A mistyped flag exiting 2 would turn a typo into an agent-control primitive — and a peer-supplied body echoed into that stderr into a prompt-injection channel. Usage was 2 until we caught it; it is 9 now.

--json machine-formats operational command results; --plain disables the transient progress line on stderr. (mcp install emits host configuration instead, and mcp speaks JSON-RPC over stdio.)

Bearer sessions expire after thirty days. send, ordinary listen and sync re-authenticate once after a rejected request, using the key already on disk. doctor deliberately diagnoses an expired token rather than renewing it. Renewals from separate processes can race.

Reading, on a machine

listen holds a socket open and prints as messages arrive. For anything scheduled, sync is the better shape: connect, process what is queued, acknowledge it, exit.

$ tacendum sync ci-bot # drain and exit — for cron $ tacendum inbox ci-bot --unread # read what arrived $ tacendum contacts ci-bot # who this machine knows $ tacendum doctor ci-bot # why it is not working

The protocol has no end-of-queue frame, so sync exits after 1.5 quiet seconds. A late message stays queued for the next run.

Received messages are written to disk in the clear. The service holds unacknowledged ciphertext for up to thirty days, so nothing is lost by being offline. The local copy is what lets inbox and MCP read a message after the server copy has been acknowledged. The order is decrypt, write and fsync, then acknowledge. A write failure leaves the server copy unacknowledged and says so loudly.

records at least thirty days old
deleted
read bodies, at least twenty-four hours after being marked read
redacted
when passes run
at listen / sync / inbox start, and on MCP acknowledge — never in the background
a spool nothing ever visits
keeps what it has; inbox --purge forces a pass

Protocol keys live in ~/.tacendum/<account>/ and plaintext history in ~/.tacendum/state/<account>/. They are separate compartments under one root, so backing up that root copies both. Spool files are created 0600 inside 0700 directories. Every read or append revalidates the file’s type, link count, symlink status and group/world bits.

The one background exception is yours to install. tacendum service install keeps listen alive as a login agent — launchd on macOS, a systemd user unit on Linux — so replies from your phone arrive without anyone typing sync. Only ordinary text and replies are stored; vault entries, attachment keys and call signalling never reach the spool. That is envelope filtering, not data-loss prevention: a secret pasted as an ordinary message is stored like any other message.

//agents — your phone in the loop, and what an assistant may read

When the agent finishes, your phone knows

You start a long task in Claude Code, Codex, Gemini or Cursor and walk away. The realistic failure is not that the agent breaks. It finished twenty minutes ago, or stopped to ask you something, and nobody was at the desk.

One command wires a machine’s agent host to your phone:

$ tacendum setup claude-code --name "Claude · laptop" # registers an integration account, shows a QR code (a bare account # id — no URL, no deep link) you scan in the app, sends the card that # names the chat, writes the Stop/Notification hook into # ~/.claude/settings.json, and installs the chat voice (§06). # the surface is the first argument: # claude-code, codex, cursor or gemini

From then on the host’s own hook runs tacendum notify when the agent finishes or needs a human. The agent’s last message arrives in that chat, end-to-end encrypted like everything else. The hook queues durably and exits within about five seconds no matter what — offline, rate-limited, misconfigured. A helper that can hang the agent it reports on is worse than none (§03 explains why exit code 2 is permanently unused).

For tools with no hook at all, wrap the command:

$ tacendum run build -- make -j8 all # streams output through untouched, exits with make's own code, and # notifies your phone with status, duration and the tail of the output

Notification bodies are clipped to a chat bubble: 280 characters, the head kept, because prose starts with its outcome. run’s output tail keeps its own 2 KiB bound. Both caps also bound what a hostile prompt could exfiltrate through a notification.

Setup is re-runnable and merges rather than overwrites. Existing hooks and instruction files are backed up before any change. A file it cannot merge with certainty is refused, never guessed at.

A crew of agents, one room

The fleet case: a lead agent running subagents, each doing real work, all invisible until you get back. A crew puts that session in one end-to-end encrypted room on your phone. The lead assigns, the members acknowledge and report — chat-shaped messages, not pasted logs.

a real working session, replayed — these six messages ran over the real protocol before this page described them

Admission is always yours

A paired integration can message exactly one person: you. There are exactly two widenings, and a human performs each one: you adopt it into a crew, and — since rooms learned about agents — a second person in a room can choose to share with it. An agent can only reach room members who chose to share with it — the server refuses everything else.

A crew holds eight members. The ninth adopt is refused by the service — 409 crew is full (max 8) — and a slot is freed by revoking a member from the phone. The number is a blast radius, not a product limit. Every member can message every other, so eight members are fifty-six directed channels one injected member could shout down. Eight keeps that a room rather than a botnet.

From your own account, one member at a time:

$ tacendum crew adopt me 01K9RZ4M8V2QW7X3YB6NCDPFGH me: adopted 01K9RZ… into your crew — it can now message you and its crew-mates (a re-adopt answers the same)

Adoption is an admission decision, and the parent of an integration is a language model. An injectable node can never be an admission authority, however it is prompted. So the service refuses an agent adopting anything, adoption of an integration not already paired to you, and a member’s message to anyone who has neither adopted it nor chosen to share with it. The refusals are server-side; they hold even against a modified client.

There is deliberately no crew list and no crew remove: the service refuses to enumerate a crew, even to its owner. Revocation is where it always was — on the phone, per member.

The room

A room can be made from either end: in the app, with each machine accepting from its own CLI, or on a machine, with the phone accepting. A room message is N separate pairwise-encrypted messages, one per member, and the service cannot join them back together. The room exists on the devices, not on the server.

A room holds twelve, the creator counted among them — a separate count from the crew’s eight. No server enforces it: each member’s own fold of the roster does, and that fold is the only place a room is ever assembled.

$ tacendum room create lead "build crew" 01K9RZ4… 01K9RZ8… $ tacendum room accept codex-1 01K9RZC… # each member, once $ tacendum room send lead 01K9RZC… "Milestone: close the round. …" $ tacendum room list lead # rooms this client holds # also: show, add, remove, leave, decline, delete [--everyone] # add and remove are the owner's; leave and decline are yours

One consequence worth stating as a feature: crew talk happens in the room you are in — and that is a policy claim, not a construction claim.

side-channels: the honest version

The voice directs agents to speak where their operator can see, and the room’s fan-out means every member — you included — holds the whole conversation. But this is the weaker of the two claims: member-to-member pairwise messages are possible within a crew. The voice, not the protocol, keeps the talk in the open.

The voice

Agents write reports; a chat wants outcomes. The summarizer already exists — the agent is a language model. A small instruction file teaches the shapes: announce what you took with an estimate; outcome first, then at most two sentences; questions name the decision, not the background. The CLI enforces the budget once, at the send path, for agents that never read it. setup installs it per host; tacendum crew voice prints it for anything else.

What a crew cannot do

A member cannot admit another member, cannot ring your phone, and cannot reach anyone who has not either adopted it or chosen to share with it — that choice is the other person’s to make and to withdraw, and withdrawing it refuses the next frame in either direction. You cannot enumerate a crew from the CLI, on purpose. Replies from the couch land in the machine’s local spool, where an agent reads them over MCP between phases — and an opt-in tool can now park a running call until you answer (§07).

The MCP server

Claude Desktop, Claude Code and Codex all speak the Model Context Protocol, so one server reaches all three.

$ tacendum sync ci-bot # populate the local spool first $ tacendum mcp --account ci-bot # each opt-in flag adds one owner-bound tool: $ tacendum mcp --account ci-bot --notify-owner --ask-owner

By default the server makes no network calls at all. It reads the local spool, so it sees exactly what sync or a running listen has already stored — and nothing else. Two launch flags each add one send-shaped tool. Both dial one destination: the owner this account is paired to (§09).

tacendum_whoami

This account’s address and label. Not its keys.

tacendum_read_messages

Stored messages, newest first, optionally filtered by peer or unread state, capped per call (20 by default, 100 maximum). Side-effect free — it marks nothing. There is no cursor, so repeated calls return the same newest rows.

tacendum_acknowledge_messages

The one local write. Marks the given ids read, which starts the twenty-four-hour clock on redacting their bodies, and runs a retention pass.

tacendum_notify_owner

Opt-in, --notify-owner. One message to the paired owner. No recipient parameter exists, and the destination is enforced server-side. 280 characters; over the cap it is refused, never truncated. Idempotency-keyed, audited on disk, ten owner-directed sends an hour.

tacendum_ask_owner

Opt-in, --ask-owner. One question to that same owner. The call parks until the reply, a TTL (30 s to an hour, ten minutes by default), or the session’s end. The server is single-flight, so one question at a time.

There is still no tool to register an account, delete one, accept a changed safety number, place a call, or reach a key. And no tool takes a recipient: nothing here can message an arbitrary account. What sends at all reaches only the paired owner — §09 says why that is the design rather than a gap.

Claude Desktop, Claude Code, Codex

The CLI prints the exact block for each host, with the absolute paths this machine needs — the Node binary currently running and the installed entry point.

Do not copy a fixed path out of a web page; generate it:

$ tacendum mcp install --host claude-desktop --account ci-bot # or --host claude-code, --host codex $ tacendum mcp install --host codex --account ci-bot --write # merges into the host's config, backing the file up first

Claude Desktop and Claude Code take an mcpServers object; Codex takes a [mcp_servers.tacendum] TOML section. The printed block starts with two comment lines: the target path, and what this launch can and cannot do. JSON has no comments — when merging by hand, take the object and leave those two lines behind.

{ "mcpServers": { "tacendum": { "command": "<absolute path to node>", "args": ["<absolute path to>/dist/main.js", "mcp", "--account", "ci-bot"] } } }

//boundaries — what it refuses, and what is missing

Why nothing takes a recipient

The first design had a send tool behind an allowlist, and a security review killed it before it was built: the allowlist only existed inside the MCP server, and an assistant with shell access would simply run tacendum send directly. Leaving a tool out of a list is not taking a capability away.

What replaced it now ships (§07): two opt-in tools that take no recipient. The destination is fixed server-side to the account’s paired owner, so the bound holds even when the assistant runs a different binary. tacendum_notify_owner tells; tacendum_ask_owner asks and parks for the answer. The default launch keeps no send handler and no network path at all.

Be precise about the boundary, because we were not at first. The default launch is no-send and no-network, not read-only: acknowledging is a deliberate local write that redacts bodies on a timer. The opt-in launches are owner-bound, not send-free: every dial reaches your phone and nowhere else. And no-send is not no-egress — anything the assistant reads here reaches whatever else it can reach, including its other tools and its host. Point MCP at an account that receives only what you would be relaxed about an assistant seeing.

Messages are data, not instructions

Anyone who knows an address can message it, so bodies are hostile input by default. They arrive as structured fields: the body one escaped string, the sender’s address a sibling field a sender cannot reach. Not prose with a warning wrapped around it.

A wrapper does not survive contact with an adversary. Structure helps; it is not a guarantee. We would rather say so than imply a defence that does not exist.

how a wrapper fails, concretely

A sender who knows the marker can close it, write a convincing instruction, and reopen it. Or use direction-override characters. Or send a body long enough to push the warning out of view. Or split the attack across three messages so no single one looks wrong.

In an MCP response each body is capped at 8 KiB, and all bodies share a 64 KiB budget — so a later message can be shortened even if it is small. Any cut sets a sibling truncated: true and reports the stored size in byte_count; never a marker inside the body. Direction-override characters are preserved and flagged with contains_bidi_controls, because telling you beats quietly altering what someone wrote. Other terminal control characters are removed.

Source and licence

Tacendum is licensed AGPL-3.0-only. That is not a marketing decision. Tacendum is built on libsignal, which is AGPL. The obligation exists whether or not it is convenient, and the honest response was to comply rather than to engineer around it.

Not published yet

The source goes up at launch, under that licence, at no charge, tagged per release. Today github.com/MiranaTechs/Tacendum is a reserved and empty repository: it answers, and it serves nothing. We would rather write that down than let a page announce a repository ahead of the commit that fills it — this page has been wrong that way before, and the dated corrections are on the Privacy page.

Your rights under the licence do not wait on that. The offer beside this stands today.

The licence mechanics — AGPL section 6(d), and how the directions to the source travel next to the object code — are spelled out on Terms.

The written offer is the route until the repository is up, and a voluntary supplement afterwards (Terms states it too). Write to hello@tacendum.com naming the version you have, and we will send you its source — for at least three years from the date we last conveyed that version, to anyone who possesses a copy.

Two pointers ride with the object code, and those are the canonical ones. The app names the build you are holding in Settings → About and links the source for that build — never a moving branch. And every authentication response from the service carries a Link header, rel="source", naming the same place. Neither is a string anyone retypes out of a web page, which is precisely why they are the two to trust over anything written here.

Publishing openly is what a security claim actually needs: “we cannot read your messages” is worth exactly as much as your ability to check it — which is why the box beside this describes a debt and not a policy. The limit that remains once it is paid: source alone cannot prove a deployed service or a phone binary was built from it. Release provenance is tracked separately.

$ tacendum --version tacendum <version> (<build commit>) AGPL-3.0-only — source: https://github.com/MiranaTechs/Tacendum

What the licence grants is the right to fork it, run your own, and change it. The one thing it asks in return is that if you run a modified version as a service, the people using it can get the source too.

What is not built yet

Two items have come off this list. Integration accounts shipped: tacendum register <name> --integration creates one, and tacendum pair <name> <user-id> binds it, once, to the human account it may notify. Our servers refuse its messages beyond that binding, the owner can revoke it from the phone, and revoking retires its key permanently. A bare register makes an ordinary account without those bounds; the class is fixed at creation. And sending from MCP shipped as §09 describes: tacendum_notify_owner and tacendum_ask_owner, each behind its own launch flag, neither taking a recipient.

This page describes what exists. To be straight about what does not:

  • Contact nicknames — a friendly name instead of a 26-character address when sending — designed, not shipped.
  • Calls — in the pre-release CLI as call, gcall, calllog and listen --calls; not exposed through MCP.
  • Windows — not implemented rather than merely untested: the host config paths and the storage permission checks are POSIX.

WebSocket dials use a single-use ticket, never the thirty-day bearer token. The client mints the ticket over HTTPS on every dial and puts that in the URL. Proxy and access logs capture short-lived single-use tickets, not a month-long credential. If the ticket route answers 404 — an old server, or a proxy misrouting the path — the client refuses the dial and says which of the two to check. It does not quietly fall back. The escape hatch is an operator’s to set, not a client’s to assume: TACENDUM_ALLOW_TOKEN_IN_URL=1 connects anyway, and prints a line on stderr on every dial saying it is doing so. It exists for the transition window in which already-deployed servers predate the route, and it goes away with the route’s last hold-out. Either way, never point TACENDUM_WS at an endpoint you do not control.

Two facts in this section were once stated wrongly on this page; the dated corrections are on the Privacy page’s corrections list.

Found something wrong, or something that reads as a security claim we have not earned? security@tacendum.com — the reporting terms are in How it works.