sessionphone
Ring your Claude Code session from your phone over Delta Chat.
A Claude Code user installs the plugin and opens Delta Chat on their phone. Then they ring their session: they talk hands-free, hear it answer, and can text it too. There's no public mail server and no public IP: mail stays on this machine, through sessionmail: its mail drop, or the mail system (MTA) when this Mac has one.
Quick start
sessionphone runs on macOS. It needs uv, and the bot mails its agent
through sessionmail (step 7 of the skill). Install the plugin with two commands:
claude plugin marketplace add <path or url>
claude plugin install sessionphone@sessionphone
Then say "set up Delta". The skill (skills/sessionphone/SKILL.md) walks
the agent through it: it makes its own Delta Chat profile
(sessionphone setup), shows you a QR code (sessionphone qr), links your
phone (sessionphone link), starts the bot (sessionphone bot install) and
rings you. Before your phone is linked, setup checks the speech-to-text
server the bot will hear you through, and when it does not answer, says what
to install. "Status" at the end of this page says what works and what does
not.
Where Delta text lands. The bot mails each Delta message, and each call
turn, to its agent's box, named after the agent: box claude, unless
DC_AGENT names another agent or the session running install was started as
one (Which agent install picks); install's box: line names
it. A Claude session hears that box only when it
was started as that agent (session start claude); a plain claude gets no
copy, and nothing tells it so. bot install asks sessionmail whether the
session running it hears the box, and says so on its hear: line. Set
DC_MAILTO to send the text to another box instead: install again with it
set, and the hear: line then tells you about that box.
Hearing Delta text in Claude has the rest.
Installation
claude plugin marketplace add <path or url>
claude plugin install sessionphone@sessionphone
The plugin's bin/sessionphone builds its own environment with uv the
first time it runs. It fetches in two steps, so a slow link meets the small
step first. The sizes are the wheels for CPython 3.13 on an arm64 Mac at
macOS 14 or later, read from uv.lock.
- Setup is the first download, and the small one.
setup,qr,linkand the other light verbs need four packages: click, deltachat-rpc-client, deltachat-rpc-server and segno. That is 10.0 MB. It was 52.5 MB when every dependency came first, the dev tools included. uv uses a Python already on the Mac when it is 3.11 to 3.14. Only a Mac with none fetches Python too (25.3 MB, measured for CPython 3.13). A user never gets the dev tools (pytest, ruff): every run passes--no-dev. - The media pack comes once, at the first media verb.
bot install,bot run,webandweb enrolneed themediaextra: aiortc, aioice, av, numpy, websockets, cryptography and their kin, 30.8 MB. Before that first run the launcher prints one line that says what it is fetching and why. If a media verb is the very first command, that one fetch also brings the light four, so it is 40.8 MB (66.1 MB with Python). - A failed fetch changes nothing else. The launcher says the pack did not download, and exits 2. Setup, qr and link still work. Run the same command again on a better link. uv keeps the packages it finished, so the re-run goes on from there (inferred from uv's cache, not measured).
Other ways to install
- pip or
uv tool: thesessionphonepackage alone is the light core. To run the bot or the web page, ask for the extra:uv tool install 'sessionphone[media]'orpip install 'sessionphone[media]'. Without it, a media verb refuses (exit 2) with a plain message that says how to fetch the pack for the environment it runs in. It refuses before it writes anything: no launchd job and no channels seed. The exception is a chat-only bot: withDC_CALLS=off,bot installandbot runwork without the pack, because that bot never loads it. A bot with calls on that finds the pack missing still comes online for chat. It refuses each call in plain words that say how to fetch the pack, including when the pack is removed while the bot is running. - From a checkout:
make installrunsuv tool install -e '.[media]', so it includes the pack. Re-installing that tool without[media]would remove the pack from under a bot that runs from it.uv syncandmake testkeep the whole stack, because thedevgroup carries the pack. - sessionkit: the kit asks for
sessionphone[media](0.2.1 or later), so a kit install already has the pack, andbot installfetches nothing.
Requirements
- macOS (tested on macOS 27, Apple silicon)
- Linux
- WSL
- Windows
What sessionphone depends on, what needs each one, and how to upgrade it: dependencies.
Usage
sessionphone --help lists the verbs. The skill in
skills/sessionphone/SKILL.md is the map for the agent.
The bot's picture: sessionphone avatar
sessionphone avatar PATH gives the agent's Delta Chat profile a picture
(PNG, JPEG, GIF or BMP), and sessionphone avatar --clear removes it. With
no argument it prints the current one, but only while the bot is stopped.
Setting and clearing work while the bot is running: the bot takes the
change as a job, so nothing is restarted.
The picture shows after the bot's next message. Setting it sends
nothing, and people see the new face only when the bot's account next
writes to them. Formats and exit codes:
skills/sessionphone/references/profile.md.
A default picture. sessionphone has a slot for a default picture,
src/sessionphone/images/default-avatar.png (or .jpg), and none ships in
it yet. When one ships, the bot sets it at its start, but only when the
account has no picture and you have not run sessionphone avatar --clear.
A picture you set is never replaced. --clear is remembered (a file
avatar-none under the root), so the default does not come back, and
sessionphone avatar PATH forgets it again. bot install says so in one
picture: line, and the bot's log says what it did.
The bot's name
The bot's Delta Chat name follows its agent: a bot for versa is named
Versa. Set DC_NAME for another name:
DC_NAME='Ada Lovelace' sessionphone bot install
The bot sets the name at its start, the one time it holds the account, and
logs one name: line, old -> new. It then writes the saved invite again,
because the invite carries the name, so sessionphone invite and qr show
the new one.
A name you set is kept. Unset, DC_NAME gives the agent's name, but only
over a name nobody chose: an empty one, or the name of the agent the bot is or
of the bot it replaced. So bot install --replace claude on a bot that came
online as Claude makes it Versa. Any other name, such as one
sessionphone setup --name gave it, is yours: the bot keeps it, and
bot install's name: line says so, with the DC_NAME=... sessionphone bot install line that changes it. DC_NAME always wins.
What is kept, and where. bot install --replace OLD writes the replaced
agent's name to a file, name-replaced, under the root. The bot reads it at
its start and removes it once the name is settled. Nothing older is
remembered. DC_NAME is saved in phone.json like every DC_*.
The bot greets a call by its display name, the name people see in their
chat list. DC_AGENT_NAME is a different setting: the agent's name, which the
bot puts in its mail's From. Set, it is also the name in the greetings.
The invite, as a QR on the terminal: sessionphone invite
Someone who has only Delta Chat scans the agent's invite and is connected to
it. The invite is the link setup saved; invite never opens the account,
so it works while the bot runs.
sessionphone inviteprints the link, one line. Safe to pipe.--qrprints the link as a QR in Unicode half-blocks (two module rows a line, a 4-module quiet zone), a blank line, then the link. No colour codes, so it reads the same in a terminal and in a tool result. In Delta Chat, the person taps the QR icon ("Scan QR code") and points the camera at the screen. It is about 33 lines, so Claude Code will likely fold it ("+30 lines (ctrl+o to expand)") and the person sees no code until Ctrl+O expands it. The skill offers the PNG or theqrpage first, the link second, and this terminal QR last.- Polarity. A dark module prints as a space, the terminal's background.
That is the right way round on a dark-theme terminal. On a light theme the
QR comes out inverted, so add
--invert(dark modules as blocks). No plain-text print is right on both. --png FILEwrites the same QR as a PNG (12 px a module, about 730 px a side; overwritten if it exists) and prints its absolute path as the last line, to open in a viewer (openon macOS,xdg-openon Linux). Its directory must exist.--qr --png FILEprints the QR, the blank line, the link, then the path.- Exit 0 done; 1
--invertwithout--qr, or the PNG could not be written (one plain line); 2 not set up, or the root is not 0700. Nothing is written before the exit-2 checks. - The link drifts, harmlessly. The running bot's own invite differs from the saved one only in a fresh auth token (each one minted stays valid) and in relays the account gained after setup, so the saved link still joins.
The agent contract: what reaches your agent, and how
This is THE one supported hand-off from sessionphone to an agent. Chat
reaches the agent as mail from the bot's own sessionmail seat, signed
by that seat whenever the bot signs: on an MTA, or with a key given
(Bot mail signing). On the mail drop with no key it is
not signed, and "signed" in the tables below reads as "as the bot signs". App
taps reach a backend as lines in xdc-in.jsonl, and an app's realtime
data, when DC_XDC_RT_IN is set, as lines in that sibling file
(below). This section is what the
code does today.
Every mail goes by
sessionmail's message send, by the carrier it will use (the mail drop, or
the host's mail system), and sessionphone carries no mail route of its own. The call mails go the same
way, on Message-IDs the bot mints itself. See
Call mails: signed, on the bot's own ids.
Chat: one signed mail per message
| # | What reaches the agent | How it goes |
|---|---|---|
| M1 | a chat message, typed, a transcribed voice note or a posted image with its text, with its quote | message send, signed |
| M2 | a held contact request (a stranger, not accepted), flagged untrusted | message send, signed |
| M3 | a reaction | message send, signed |
| M4 | a webxdc tap, only when DC_XDC_IN is unset (deprecated) |
message send, signed |
| M5 | a call: invited, missed, ringing, connected, each turn, each preview, the transcript | message send --message-id, signed; From domain sessionmail's (see call mails) |
The bot runs, as its seat:
message send DC_MAILTO -s SUBJECT -f - --raw --no-shorten --json --header ...
It reads --json's answer. When the bot signs, it never sends
unsigned: a send that cannot sign is refused and kept (see
the undelivered spool).
The mail's shape:
| Field | Value |
|---|---|
| To | DC_MAILTO, else <agent>, a bare box name: sessionmail adds this host |
| From | <Name>'s Delta Chat bridge <<agent>-chat@...>. The seat is <agent>-chat, the local part of DC_ADDR; sessionmail sets the domain |
| Subject | [<room> <chat_id>] <tail>. <room> is the chat name cut to 14 characters, after DC_PROFILE when one is set. <tail> is <sender>: <text> or the kind's own words (REQUEST ...). ASCII, 69 characters at most. A backtick becomes ' and $( becomes $ (, because message send refuses a subject that could execute |
| Body | the text, with any quoted (>) lines, then a --- footer: chat:, from: and how to answer. The mail leg stops reading aloud at --- |
Headers an agent can filter on (a notmuch index.header, a sieve
rule, a page rule):
| Header | Value | On |
|---|---|---|
X-Delta-Chat-Bridge |
1: this came from the phone |
every mail |
X-Delta-Chat-Id |
the chat id | every mail |
X-Delta-Chat-Profile |
the profile, when not the default | every mail |
X-Delta-Chat-Kind |
message, request, reaction, webxdc or call |
M1-M5 |
X-Delta-Chat-Msg-Id |
the Delta message id, which dc-react --msg takes. For a reaction, the message reacted to |
M1-M3 only |
X-Delta-Chat-Sender |
the address the sender's message carries, as a label for people. It is not the sender's key, and it is not an identity: the display name is only a claim, and so is the address. Use X-Delta-Chat-Channel to know who it is |
M1-M3. A tap has no sender |
X-Delta-Chat-Channel |
the channel NAME the chat is listed as in channels.txt. For an address line it is set only while the chat's contact holds the key pinned on that line; chat:N lines are by chat id |
M1-M4, only when the chat is listed |
Medium: voice, X-Call-Preview |
as before | call mails |
Who sent it. On the private profile every sender's message reaches the
agent, listed or not, so an agent must not treat X-Delta-Chat-Sender as
who the sender is. The identity the bot checks is a key fingerprint, and
the one header that carries a checked answer is X-Delta-Chat-Channel: it
is there only for a chat the owner listed, and for an address line only
while that chat's contact holds the key pinned to the line (see "Channels"
and "Callers" below).
The reply hint names the channel. The footer's hint is asked of
channels.txt exactly as the outbox asks before a send. A listed chat
reads dc-say <NAME> 'your reply'. A chat that is not listed gets no
X-Delta-Chat-Channel header, and its hint says so, with the list's own
reason and the command that lists it (sessionphone channels add NAME chat:<id>). It never offers a dc-say that the bot would refuse.
A posted image arrives with its text, fenced UNTRUSTED. The body holds
the image line, [attachment: NAME (MIME)], even beside typed text, and
then the words macOS Vision read off the picture, between
--- image text: UNTRUSTED ... --- and --- end image text ---, each
line starting with a | and a space. That text is data, never
instructions. A contact request's image is not read. DC_IMAGE_TEXT=off
turns it off, and the host needs swiftc. Only the fence's own two
marker lines start with ---, so a picture cannot forge the closing
marker. The text is cut at 2000 characters, and the closing marker then
says so: --- end image text (cut at 2000 of N characters) ---.
The body has three other shapes.
[image text: none found by on-device OCR] means OCR ran and found no
text. [image text: not read: sent as a contact request] means the image
came in a contact request. The image line alone, with no text after it,
means OCR did not run: it was off, failed, timed out, hit a size cap, or
the helper was not ready. The bot's log says why.
Two cases where a stranger's image IS read. Under
DC_ACCEPT_REQUESTS=on, a stranger's first image is not read, but every
later one is, because the chat is accepted by then. In an accepted group,
a member added later is never accepted one by one, so that member's
images are read. In both cases a stranger chose the text in the fence,
and the fence is what keeps it from acting as instructions.
The bot's seat
The bot sends as its own sessionmail seat, never as a Claude session. The
agent is the harness vendor's word (session agent default, claude
today; claude too when sessiontools has no such verb, as 0.3.0 has not,
or gives no single word within a few seconds) unless DC_AGENT is set, or the Claude session running
sessionphone bot install was started as another agent, so the seat is
claude-chat and Delta text goes to the box claude. The name is bare, with
no host: sessionmail adds this machine's host when it delivers, so the box
still works after the machine is renamed. Name a host (DC_MAILTO=box@host)
only for a box on another host.
Which agent install picks, when DC_AGENT is not set: it asks
sessionmail what reaches the session it runs in (message subscription list, which only reads). When that session has a seat a later session can
be started as (session start ada gives the seat ada), the bot is
named after it. A plain claude session has only its session id, and a
seat that a task tool starts has a + address (its CLAUDE_CODE_AGENT is a
role, such as worker): neither names the bot, because neither is there in
a new session. Then install takes the one bot it has recorded, else the
harness vendor's word, else claude. Only when sessionmail gives no
answer does install take CLAUDE_CODE_AGENT instead. bot status,
bot uninstall, bot run and every dc-* tool pick the bot the same
way, and say which they picked when your shell names none
(bot: ada (...), or dc-say: bot ada (...) on stderr).
One bot to a root. A root is one Delta Chat account, so two bots on it
is a broken state: the old one keeps mailing its own box, and its calls and
messages never reach the new one. When phone.json records another bot on
the same root (its SESSIONPHONE_ROOT, DC_ROOT or DC_ACCOUNTS), as
after a rename, install refuses (exit 2), writes nothing, and names it:
sessionphone: ~/.sessionkit/phone/settings/phone.json records bot claude on the same root as bot ada (~/.sessionphone). ... To replace it: `sessionphone bot install --replace claude` ...
--replace claude retires that bot: its launchd job is booted out, its
plist removed, and phone.json forgets it once the new job is loaded, each
said in a replaced bot claude line. To keep both, give the new bot its own
root (SESSIONPHONE_ROOT=DIR sessionphone bot install).
Whether it signs follows the carrier this bot's own message send will
use. Bot mail signing has the rule and why.
- On the mail drop (no mail server), the bot does not sign. It writes
into the box itself, as you.
sessionphone bot installneeds no key, no registry and no seat step. - On an MTA, or for a fleet, give it a key:
sessionphone bot install --registry DIR. It mints the seat into the registry DIR when the seat has no key yet, runs its key agent, and saves the signer, so later installs and starts sign. On an MTA the bot must sign, and with no key and no--registryinstall refuses (exit 2). - Mail reaches your Claude only when the agent's box exists (the bot
makes it at install and at every start) and your Claude was started as
the agent (
session start <agent>, which runsclaude --agent <agent>). Install'shear:line says whether the session that ran it hears the box. See Hearing Delta text in Claude.
To give the bot a key:
-
A seat registry, DIR.
message seat initadds the seat to a registry and makes none itself. If you have one already (the directorySESSION_REGISTRYnames), use it. Otherwise make one: a git directory whose committedallowed_signersholds one seed line,<id>@session namespaces="session-birth@example.com,session-mail@example.com,git" <public key>, for a key you make for it:git init ~/seat-registry ssh-keygen -t ed25519 -N '' -C registry-seed -f ~/seat-registry-seed printf '00000000-0000-0000-0000-000000000001@session namespaces="session-birth@example.com,session-mail@example.com,git" %s\n' \ "$(cut -d' ' -f1-2 ~/seat-registry-seed.pub)" > ~/seat-registry/allowed_signers git -C ~/seat-registry add allowed_signers && git -C ~/seat-registry commit -m seed
sessionmail's
docs/message-seat.md("The trust registry") describes the registry and its seed line. -
Install with it. One command mints the seat's key if it has none, runs the seat's key agent, and runs the bot:
sessionphone bot install --registry ~/seat-registry
A key already there is never minted again, so a re-run is safe. A seat you minted yourself (
message seat init --registry DIR claude-chat) is not minted again either:--registry DIRruns its agent and saves the signer. Without--registry, a plainbot installruns its agent only on an MTA, and saves nothing. On the mail drop it runs no agent, and the bot does not sign.
When the bot signs (an MTA, or --registry) and no DC_SIGNER_SOCK and
DC_SIGNER_PUB are set (in this shell or saved), bot install runs
message seat agent --install <agent>-chat. That loads the seat's key in an
ssh-agent under launchd, at sessionmail's default socket, and the job's
DC_SIGNER_SOCK and DC_SIGNER_PUB are set to the SEAT_SOCKET and
SEAT_PUB it prints. With --registry they are saved, so later installs
and starts sign. Without it they are not: each install asks again, and a
second run finds the agent already there. On every carrier, bot install
then pins DC_MESSAGE to the message on this shell's PATH (launchd's
PATH is short), runs the bot's own start check on the job's environment,
and makes the agent's box with message setup --box <agent>, checked with
message path <agent>. A job that would refuse to start is never loaded.
Its seat: line names the seat, the mode and why, for example claude-chat, unsigned (carrier drop: this bot writes into the box itself, from sessionmail's default), no key.
- A seat not minted yet, on an MTA with no
--registry, is refused (exit 2) and nothing is written: the message quotes sessionmail's refusal and namessessionphone bot install --registry DIR. - Any other refusal from
message seat agent --install(exit 2; for one, aHOMEthat is not an absolute path) is quoted as sessionmail wrote it, and nothing is written. - A failure (exit 1: launchd did not load the agent, or it never
answered) is quoted with sessionmail's
FAILEDline and the agent's last log lines. Nothing of the bot is installed. To recover, runmessage seat agent --uninstall <agent>-chat, thensessionphone bot installagain.
A sessionmail that cannot run the agent for install (its message seat agent --help has no line that is exactly Capability: install) gets the
steps by hand instead. On the mail drop with no --registry it asks
nothing, since no key is wanted. When the bot signs and no signer is set,
install refuses (exit 2), writes nothing, names these steps and says what
this sessionmail lacks:
-
Run the seat's key in an ssh-agent under launchd:
message seat agent claude-chat SOCKET.message seat agent --templateprints the LaunchAgent to render and load; sessionmail'sdocs/message-seat.mdsays how. SOCKET is 103 bytes at most. -
Point the bot at it, then install. Copy the seat's
.pub($SESSIONMAIL_HOME/seat-keys/claude-chat/seat.pub) somewhere apart from the key, then:export DC_SIGNER_SOCK=SOCKET DC_SIGNER_PUB=/path/to/claude-chat.pub sessionphone bot install
To use a seat agent you run yourself with any sessionmail, set both
DC_SIGNER_SOCK and DC_SIGNER_PUB: install then runs no agent. With only
one of them set, install refuses.
Every DC_* set (and not empty) in that shell is carried into the launchd
job, and so are sessionmail's mail-route settings: SESSIONMAIL_SENDMAIL,
SESSIONMAIL_RELAY_DOMAIN, SESSIONMAIL_HOME, SESSIONMAIL_ROOT and
SESSIONMAIL_HOST. Nothing else from that shell is: not the installing
seat's own SESSIONMAIL_* (its SESSIONMAIL_FROM_NAME,
SESSIONMAIL_SUBSCRIBE, pager settings), and not the old SESSION_MAIL_*
spelling. launchd gives the job no other environment.
SESSIONMAIL_ORIGIN is not carried. The agent's side checks each signature
against its own origin, so a value only the bot's job holds makes every mail
fail that check. On one host both sides read the same settings, so no pin is
needed. If you want one, set it in sessionmail's settings key
postfix.origin, which both sides read. When this shell sets
SESSIONMAIL_ORIGIN, the mail: line says it was not carried.
| Setting | What it is, and what the child message send gets |
|---|---|
DC_ADDR |
the bot's own From seat, <agent>-chat; its local part is the seat the key signs as. The child gets TM_AGENT_ADDRESS and EMAIL = that local part, so an EMAIL inherited from whoever ran install never becomes the From |
DC_MAILTO |
where Delta text goes: the agent's box, the bare <agent> by default; kept as given, so box@host names a box on another host |
DC_SIGNER_SOCK |
CLAUDE_SESSION_AGENT_SOCK: the seat agent's socket (SEAT_SOCKET) |
DC_SIGNER_PUB |
CLAUDE_SESSION_PUB: the seat's .pub (SEAT_PUB), or a copy of it kept apart from the key |
DC_MESSAGE |
the message binary, resolved to an absolute path at start (launchd's PATH is short). Else message on PATH |
DC_AGENT_NAME |
SESSIONMAIL_FROM_NAME = <Name>'s Delta Chat bridge |
| (always) | SESSIONMAIL_REQUIRE_SIGNATURE=1 when the bot signs, never when it does not (Bot mail signing); a value the bot inherited is dropped. Every CLAUDE_SESSION_* the bot inherited is stripped |
Do not set DC_ADDR to the agent's box. The key is the bot's seat, so the
bot refuses: the signer and the From would be different seats. To send Delta
text somewhere else, set DC_MAILTO.
Hearing Delta text in Claude
Delta text lands in the agent's box, claude. Mail to that box
reaches a session only through the pager's doorbell, and the doorbell rings
only for a session started as that agent. bot install (and
sessionphone setup) asks sessionmail what reaches the session it runs in
(message subscription list) and says, on its hear: line, whether that
session will hear the box. It never guesses from a variable's name.
When the session will not hear it, the line says so first, and it is the
first line bot install prints, ahead of installed::
hear: this session will NOT hear Delta messages or calls: they go to box <agent>, and this session hears only its own session box. Ways out: start Claude with `session start <agent>` (only a session started as <agent> hears that box, today and in later sessions); or install again with DC_AGENT=NAME (the agent a session is started as) or DC_MAILTO=BOX (a box a session hears); or `sessionphone bot install --hear-here`, which lets this session hear box <agent> until this session ends.
This is a warning, not a refusal: the bot is installed either way, and the
exit code is 0. When the session does hear the box, the line says
this session hears box <agent> (its seat <agent>): Delta messages and calls reach it, while it runs. When DC_MAILTO names the session's own box, its
session id, the line says (its own session box) instead. That box lasts
only as long as the session: a later session has another id, and does not
hear it. From a shell that is not a Claude session (a Terminal), nothing can
be measured, so the line gives the advice:
hear: To hear Delta messages, start Claude with: session start <agent> (it runs `claude --agent <agent>`: only a session started as <agent> hears the box; a plain `claude` gets no copy of its mail, and nothing says so). To send Delta text to another box, install again with DC_AGENT=NAME (the agent a session is started as) or DC_MAILTO=BOX (a box a session hears).
The bot gives the same advice at every start, on the agent box: line of
bot.log, after the box it made or found. It cannot know which session
installed it.
-
session start <agent>needs an agent file,~/.session/agents/<agent>.md. The line adds (no agent file "" yet: see the README) whensession start --list-agentsdoes not list it. Session Kit's setup writes that file. -
bot install --hear-herelets the session running install hear the box now, by a session subscription (message subscription add <agent>). It ends with that session: a new session does not hear the box unless it is started withsession start <agent>, or runs--hear-hereagain. Install never does this unless asked. Itshear-here:line, beforehear:, says what it did, or why not. When it did not take, the session still will not hear the box, so the warning leads andhear-here:follows it. -
When the bot's name came from the default (no
DC_AGENT, no seat, no bot recorded) and nothing hears its box, thehear:line is followed by one more line, which names the fix:default: no seat owns box claude: the bot's name came from the default, so nothing hears its mail yet. Start a bare `session start` first; it becomes claude.
Install asks sessionmail who hears the box (
message subscription list <agent>, read only). It prints the line only when the answer is plainly "nobody": no such box, or0 hear itwith no live listener. When this session hears the box, or the answer cannot be read, there is no line. -
Two windows started as
session start <agent>share one box, and both get each Delta message. The second launch needs--allow-second-holder; without it, it exits 3. -
A Claude session started any other way (plain
claude) does not hear the box<agent>: it gets no copy of the mail, and nothing tells it so. -
When sessionmail cannot be asked (an older one without
message subscription list), the line gives the advice and says that nothing was measured. It never says the session hears the box. -
When
DC_MAILTOnames a box on another host and the carrier is an MTA, install makes no box and prints nohear:line: that box is its reader's to make. On the mail drop, which delivers on this host only, install refuses instead (see the start-up refusals below).
The carrier is sessionmail's to pick, not sessionphone's. The bot sends
by sessionmail's message send, and its mail goes by the carrier this bot's
own message send will use: SESSIONMAIL_SENDMAIL when it is set, else the
sendmail key in sessionmail's mail.json, else sessionmail's default,
drop (from sessionmail 0.2.0 on). drop writes straight into this host's
mailboxes, with no MTA; anything else names the MTA. The key is sendmail;
a key named carrier is not read. Whether postfix is installed does not
decide it: a job with no sendmail setting uses the drop even on a Mac
that has postfix. bot install carries SESSIONMAIL_SENDMAIL from the shell
that runs it into the job, so the variable wins there.
The seat: and mail: lines name where the carrier came from, as
sessionmail used it: SESSIONMAIL_SENDMAIL; sessionmail's ~/.sessionkit/mail/settings/mail.json `sendmail` only when that file sets
the key; else sessionmail's default. On a fresh install mail.json holds no
sendmail key, and the drop comes from the template sessionmail installs
beside it, so the lines say sessionmail's installed default, ~/.sessionkit/mail/settings/mail.template.json `sendmail` .
bot install says what it carried on its mail: line, read back from the
plist it wrote, for example carried SESSIONMAIL_SENDMAIL=drop; the bot's mail goes by drop (SESSIONMAIL_SENDMAIL=drop, no MTA). It says plainly when it
carried no SESSIONMAIL_* setting. The bot names its carrier again at every
start, on the agent mail: line of bot.log. The job keeps the value it was
installed with, so after you change SESSIONMAIL_SENDMAIL (or any of
the mail-route settings above), run sessionphone bot install again.
bot install does not remember the mail-route settings: a later install
from a shell without them carries none. To keep the carrier, write it in
sessionmail's own file, ~/.sessionkit/mail/settings/mail.json
("sendmail": "drop"), with a sessionmail that reads it. Install asks the
installed sessionmail whether it does, and its settings: block says, for
each one your shell set, whether it is kept and the step that keeps it.
The bot refuses to start (exit 2) when no mail could reach the agent, and says what to do:
- no
messagebinary:DC_MESSAGEnames no executable, or there is nomessageonPATH; - no carrier it can send by: sessionmail refuses the carrier, cannot
read its
mail.jsonor names a kind of carrier the bot does not know, or the bot cannot ask it (itsmessagehas no carrier resolver). The bot never falls back to unsigned, and never guesses an MTA; - no signer, when the bot signs:
DC_SIGNER_SOCKorDC_SIGNER_PUBis not set, orDC_SIGNER_PUBis not a file; - no box: the agent's box is not there and
message setup --box <agent>could not make it. The bot checks this at every start, before its first send; - a box off this host on the mail drop:
DC_MAILTOnames another host, and the drop delivers on this host only, so every mail of the run would be refused. The line names the address and what to do: setDC_MAILTOto a box on this host, or use a carrier that reaches it (an MTA inSESSIONMAIL_SENDMAIL). On an MTA the bot starts, and the MTA routes it.
bot install runs the same check first, so it refuses a job that would
refuse to start, and loads nothing. sessionphone bot status shows the last
line of the bot's log. Before the bot has written one, it shows the last line
of bot.launchd.log, where a start-up refusal goes.
A signer socket that is not live yet is not a start-up refusal. Each send is
refused with kind identity and kept, and it goes once the agent is back.
A mail that fails is kept: the undelivered spool
A send that message send refuses is never lost:
- Where. It is written to
DC_UNDELIVERED, else<DC_ROOT>/undelivered/, as<epoch>-<n>.json. The file is mode 0600 and the directories 0700, because it holds the mail body. - What is kept. The send's arguments and the body. The environment is never stored: a retry rebuilds it, so a signer fixed in the meantime is the one used.
- No double mail. The message is still remembered, so the Delta start-up sweep never mails it again: the spool is the queue.
What is retried is chosen by the refusal's KIND, never its exit code:
- Retried:
identity,mta,undeliverable, and the bot's ownexec(the binary could not run) andtimeout(no answer in 30 s). - Backoff: each mail keeps its own attempt count and next try. The wait starts at 60 s and doubles to at most 3600 s.
- Order: the outbox loop tries every due mail, oldest first, and a mail that keeps failing never blocks the ones behind it.
- The exception: the MTA itself is down. A pass stops at the first
refusal of kind
timeoutormta. The mails behind it keep their next try and wait for the next pass, so an agent'sdc-sayis held for one timeout at most. - A possible duplicate. Kind
mtawithsent: unknownmay have gone. It is retried anyway, and loggedsent=unknown, because a duplicate beats a loss. Not a call mail: its box is checked first (below).
What goes to undelivered/dead/. Nothing is ever deleted from dead/:
- Every other kind (
usage,subject,address,header,body, ...) goes there at once. A retry could only repeat that refusal. - A mail still failing 24 h after its first try goes there too.
- So does a record that cannot be read: it is moved as it was, and logged once.
The log lines, in bot.log:
BRIDGE MAIL FAILED rc=<rc> kind=<kind>: <message>, kept at <path>
BRIDGE MAIL FAILED <error>; NOT KEPT, the mail is lost: <why>
BRIDGE MAIL RETRY <n> FAILED <error>; next try in <s>s: <path>
BRIDGE MAIL HELD <n> more not tried this pass: <name> got kind <kind>, ...
BRIDGE MAIL SENT on try <n> (first failed HH:MM:SS, last sent=<sent>): <name> <Message-ID>
BRIDGE MAIL ALREADY THERE on try <n>: <Message-ID> is in <box>'s box; not sent again: <name>
BRIDGE MAIL DEAD <path>: <why>
NOT KEPT means the spool itself could not be written (a full disk, say).
dc-live prints undelivered mail to the agent: N waiting, M dead on
stderr whenever either count is not zero. bin/bridge-check reports the
same two counts, and they never fail its gate. sessionphone bot status
prints undelivered: N waiting, M dead (<dir>), from the spool its installed
job names (DC_UNDELIVERED, else undelivered in the bot's home). The
channels alert keeps its own retry.
The call mails are spooled too, all but a call's preview: the turn
supersedes it and carries its whole text. A call mail goes under a
Message-ID the bridge made, and sessionmail has no duplicate check: a send
under an id already in the box lands twice. So before the spool retries a
mail that carries its own --message-id, it reads the target box for that
id, read-only (message path <box>, then the header of each file in new/
and cur/):
- The id is there: the mail went. The record is removed and nothing is sent
(
BRIDGE MAIL ALREADY THERE). - The id is not there: the mail is retried.
- The box cannot be checked from here (a box on another host, behind an
MTA): the mail is retried only if every try so far said
sent=no. One that may have gone (sent=unknown) goes todead/instead, and is counted there. It is never sent twice.
App taps: DC_XDC_IN is the integration point
DC_XDC_IN=PATH is THE supported way for an app's backend to hear taps.
The bot appends one JSON line per tap to PATH and mails nothing:
- Each line is written by one
os.writeon anO_APPENDdescriptor, so a reader tailing the file never sees half a record. - The file is mode 0600, and its directory 0700 when the bot makes it.
- With
DC_XDC_INunset, each tap goes out as one mail (M4), as it always has. That is kept so that no bot silently loses taps, but it is deprecated: the old default, not part of the contract.
Line format v1. These four keys are frozen, and come first:
{"ts": "2030-06-09T12:00:00+0000", "msg_id": 1001, "serial": 7, "payload": {"kind": "set"}, "chat_id": 12, "info": "set ears.listen"}
| Key | Type | Meaning |
|---|---|---|
ts |
string, %Y-%m-%dT%H:%M:%S%z |
when the bot wrote the line |
msg_id |
int | the webxdc message: which app instance |
serial |
int, at least 1 | the core's status-update serial. It only grows |
payload |
any JSON | exactly what the app sent |
chat_id |
int, added | the chat the app lives in, so a backend can answer through a channel. Left out if the bot could not read it |
info |
string, added | the update's info line, only when it has one |
A reader must read with .get, and ignore keys it does not know: later keys
are added the same way. A tap carries no sender. An update says nothing
about who made it, and a payload's own from or by is a claim, not proof.
Never gate on it.
What is dropped at source, in this order, on both sinks (the file and tap-as-mail):
- An update with no usable serial. Nothing is fetched, so no history is replayed.
- Our own echo: a payload this bot process pushed with
dc-xdc-update. LoggedXDC ECHO. - A state update: a payload that is a JSON object whose
kindis"state"or ends in"-state". LoggedXDC STATE, never written or mailed. It is a backend's whole state going to the app, never a person's tap. The echo rule only knows this process's own pushes, so this rule catches a push made before a restart or by another process or device. It cannot be turned off and runs beforeDC_XDC_MATCH, so the-statesuffix is reserved: an app must not name a tap kind*-state. The match is exact and case-sensitive onkindonly:statefulandStateare taps. DC_XDC_MATCH, if set.
DC_XDC_MATCH=key=value[,key=value...], optional, for either sink:
- A payload is a tap only if it is a JSON object and ANY listed pair
matches. A key may repeat:
from=xdc,from=deskmatches either. - Values compare as strings, so
n=1matches{"n": 1}. Whitespace is trimmed. - An item with no
=or no key is logged once at start and ignored. - Unset, every update that survives rules 1-3 is a tap. It is not a package
default: an install whose desk app marks its taps sets
from=xdc,kind=desk-answer, and an app that sends nofromneeds no setting.
On the public profile, a tap that passes these rules from a chat not on that
profile's channels.txt is then rejected and recorded, as any inbound (see
the inbound gate below).
The bot never runs notmuch. A tap is not mail, and notmuch belongs to
the reader. The file is the event: a reader that must react to a tap
watches it (a launchd WatchPaths on DC_XDC_IN, a systemd path unit, or
tail -F).
Replacing local glue. If a job of yours watched the bot's state or
wrote into its outbox by hand, this contract replaces it: watch
DC_XDC_IN for taps, and send with the dc-* tools to named channels.
Retire the old job once its replacement runs.
Realtime data into an app: dc-xdc-realtime
A status update (dc-xdc-update) is a real message: it is stored, and the
app replays it on reopen. Realtime data is not. It goes straight to every
peer that has the app open at that moment, and anything sent while the app is
closed, or before a peer has connected, is lost without a word: neither the
send nor any event reports a miss.
dc-xdc-realtime [--profile public] MSG_ID PAYLOAD_JSON
dc-xdc-realtime [--profile public] --advertise MSG_ID
- The job is
{"webxdc_realtime": MSG_ID, "payload": ANY_JSON}, or{"webxdc_realtime_advertise": MSG_ID}. Both are gated on the chat the message is in, asdc-xdc-updateis, and the message must be a webxdc app. - The payload is sent as compact UTF-8 JSON bytes; the app reads
JSON.parse(new TextDecoder().decode(data)). Encoded, it may be at most 128000 bytes. A bigger one is refused (exit 3), never truncated. - The first data the bot sends to a message advertises first, once per
message per bot process.
--advertiseadvertises now, for an app that was reopened and hears nothing. - The ack's sent means only that the send returned: it always says
delivery not confirmed: realtime is ephemeral. - The bot hands each job to one realtime sender thread, so a slow send
never holds up a message, a tap or a call. The tool waits up to 30 s for
that thread's answer. If the thread dies, the bot logs
XDC FEED thread died: ...; feeds off until restart, refuses every later realtime job with that reason, and keeps running. A restart brings it back. - At start the bot logs the account's
webxdc_realtime_enabled(core default"1"). If it is"0", every realtime job is refused.
The Guide Trail app: sessionphone app
sessionphone ships one webxdc app, the Guide Trail: a black page that shows a conversation's words as they are spoken. It is package data, so an installed bot host has it without a checkout.
sessionphone app list # the apps that ship
sessionphone app build guide-trail [-o PATH] # build the .xdc, print its path
buildwrites$DC_ROOT/apps/guide-trail.xdcunless-onames a path. The zip hasindex.htmlat its root and nowebxdc.js(the messenger provides it). The same tree always gives the same bytes.- Send it with
dc-send-file CHAT_ID "$(sessionphone app build guide-trail)". The ack names the new message's id, and that id is whatdc-xdc-realtimetakes. - The app reads three payloads, as realtime data or a status update's
payload:{"partial": TEXT}(a chunk of what the user is saying now, added to the live line),{"final": TEXT}(the finished turn, replacing the live line), and{"lines": [{"who": ROLE, "name": LABEL, "text": TEXT}]}.whois a role,useroragent, and sets the colour; the label shown isname, else the role in upper case. Any otherwhois drawn as a user line, labelled with what it says. - Every string from a payload reaches the page as text, never as markup.
- A trail opened mid-conversation starts empty: realtime data reaches only the peers that have the app open when it is sent.
icon.pngis the icon from the app's first prototype.
The heard feed: dc-xdc-realtime --feed
A feed streams the heard log into one webxdc app the bot sent, live, while the bot runs: what the owner's mic hears, and what this agent says. It is how the Guide Trail shows a conversation as it happens.
dc-xdc-realtime [--profile public] --feed MSG_ID # bind MSG_ID to the heard log
dc-xdc-realtime [--profile public] --feed MSG_ID --stop # stop it
The job is {"webxdc_realtime_feed": MSG_ID, "feed": "heard"}, or
{"webxdc_realtime_feed": MSG_ID, "stop": true}. Each new line of the heard
log (heard_<day>.jsonl, in $EARS_HEARD_DIR, else
$VOICEMODE_BASE_DIR/logs/conversations, else
~/.voicemode/logs/conversations) becomes one realtime payload:
| # | Heard line | Payload into the app |
|---|---|---|
| F1 | kind: partial, source: mic |
{"partial": TEXT}: a chunk, added to the live line |
| F2 | kind: turn, source: mic, with a real word |
{"final": TEXT}: replaces the live line |
| F3 | kind: saying by this agent (DC_AGENT) |
{"lines": [{"who": "agent", "name": DC_AGENT_NAME, "text": TEXT}]} |
Call lines (source: call), other agents' sayings and noise turns are not
fed. Text is one line, at most 4000 characters. That cut is display only: no
agent reads this feed, and the heard log it reads keeps every word.
- Two consents. The msg's chat must pass the channels list, as every
send does, and be listed in
DC_XDC_FEED_CHATS(comma-separated chat ids). Unset, the default, means no feed can bind. The heard log holds every word the mic hears, the room included, and realtime data reaches every member's open app: list only your own 1:1 chat.DC_HEARD_CHATSis a different consent: it lets a call write the heard log. - At most
DC_XDC_FEEDS_MAXfeeds at once (default 2), one per msg. Binding a msg already bound acksalready feeding; stopping one that is not acksnot feeding. - It survives a restart. Bindings are written whole to
xdc-feeds.json(0600) beside the bot's outbox, so each profile has its own. At start each one is checked again, as a new bind is, and resumed from the end of the heard log: nothing is replayed, and a trail opened mid-conversation starts empty. - It ends by itself when the app msg is deleted (
WebxdcInstanceDeleted), after 3 failed sends in a row, or when the msg is gone, or no longer allowed, at start. The bot log saysXDC FEED msg N: ... feed stopped. - The pace. Partials are chunks: those still waiting are joined in order
into one
{"partial"}, and a{"final"}drops the partials before it, because it replaces them. Each feed sends at most 10 payloads a second and holds at most 100 waiting; when full, the oldest partial text is dropped first, and the log says so at most once per 10 s. - Never in the way. The feed reads the log on its own thread and hands
each send to the one realtime sender thread, one at a time per feed, so a
slow or stuck send delays only that feed. It advertises once when bound,
and again when the app joins, at most once a minute per msg. If the feed
thread dies, the bot logs
XDC FEED thread died: ...; feeds off until restart, refuses every later feed and realtime job with that reason, and keeps running. - Nothing confirms delivery: realtime data is ephemeral.
Realtime data from an app: DC_XDC_RT_IN
An app can send realtime data back to the bot over the same channel the bot
feeds it on. DC_XDC_RT_IN=PATH writes each datum as one JSON line to
PATH. Unset, the default, the bot counts each datum and drops it, and says
so in its log at most once a minute per msg. Realtime data is never mailed:
an app can send many a second.
It is a sibling of DC_XDC_IN, never the same file. A datum has no
serial, and xdc-in.jsonl's line format v1 freezes serial as the key its
readers count on. Point the two settings at two different files. If they name
the same file, however it is spelled, the bot refuses DC_XDC_RT_IN: it says
so at start, and counts and drops realtime data as if it were unset.
{"ts": "2030-06-09T12:00:00+0000", "msg_id": 1001, "seq": 1, "via": "realtime", "payload": {"choice": "left"}, "chat_id": 12}
| Key | Type | Meaning |
|---|---|---|
ts |
string, %Y-%m-%dT%H:%M:%S%z |
when the bot wrote the line |
msg_id |
int | the webxdc message: which app instance |
seq |
int, from 1 | this bot process's count of lines for that msg. A restart starts it again at 1 |
via |
"realtime" |
so a reader that merges this file with xdc-in.jsonl can tell them apart |
payload |
any JSON | exactly what the app sent, decoded |
chat_id |
int | the chat the app lives in. Left out if the bot could not read it |
There is no serial. The file is written as xdc-in.jsonl is: one
os.write per line on an O_APPEND descriptor, mode 0600, its directory 0700.
What is dropped, in this order:
| # | Datum | What happens |
|---|---|---|
| R1 | from an app this bot process did not join (it never advertised on it, sent it realtime data or fed it) | dropped, with no log line: other apps' traffic is not ours |
| R2 | any, with DC_XDC_RT_IN unset |
counted and dropped, logged once a minute per msg |
| R3 | over 20 a second for one msg | dropped, counted, logged XDC RT DROP once per 10 s. This runs before the decode, so a flood costs no parsing |
| R4 | not UTF-8 JSON | logged XDC RT SKIP msg N: not JSON (B bytes): the size, never the content |
| R5 | a state payload (kind "state" or *-state), or one DC_XDC_MATCH does not pass |
dropped as a tap is, logged XDC RT STATE / XDC RT SKIP without the payload |
| R6 | on the public profile, from a chat not on channels.txt |
rejected and recorded in rejected.jsonl as kind webxdc-realtime. The gate is asked once a minute per msg, so a rejection is recorded once a minute, not once per datum |
Each skip is logged at most once per 10 s per msg and reason, with how many it stands for. A datum carries no sender, as a tap does not.
There is no echo filter. Whether the core hands the bot its own realtime sends back is not yet measured. If it does, a feed's own payloads will show up in this file.
Call mails: signed, on the bot's own ids
The call mails (M5) thread on Message-IDs the bot mints itself: a call's
root call-<chat>-<epoch>@<host>, its ring, each turn and each preview. They
go by the same message send, as the same seat, signed, with sessionmail's
own flags for the ids:
message send DC_MAILTO -s SUBJECT -f - --raw --no-shorten --json \
--message-id <id> -r <root> --supersedes <preview> -c <guest> --header ...
- The ids' host is sessionmail's, not
DC_HOST:$SESSIONMAIL_HOST, else this machine's short name.message send --message-idrefuses any other host. The bot passes the same value tomessage send, so the two cannot disagree.DC_HOSTstill builds the default addresses and the web call's Reply-To. -r <root>writes In-Reply-To and References. Cc, Supersedes and the ids are never raw--headers:message sendowns them.- A turn arrives whole. The body of a turn mail and of a preview carries every word heard, however long the turn, flattened to one line and never cut. The heard log's turn and partial lines, and the call's transcript, hold the whole text too. Only display is cut: the Subject (the first 44 characters of the turn, 34 of a preview) and the bridge's own log lines.
- The From keeps its display name and the seat,
<agent>-chat. Its domain is sessionmail's, as for every other mail. - A call mail that fails is kept and retried, all but a preview (see the undelivered spool above). sessionmail has no duplicate check, so the spool reads the box for the mail's id before each retry, and never sends it twice. The Delta call path makes one try itself; the spool makes the rest. A web call's turn is tried again only when nothing was sent, for a reason in the world, not in the mail.
- The bot refuses to start (exit 2) when the installed
messagehas no--message-id. At start it runs one probe that sends nothing,message send probe@invalid -s probe --no-body --message-id '<x>' --json, and takes only sessionmail's refusal of the id (kindmessage-id). There is no fallback to raw sendmail: sessionphone has none.sessionphone web --legsruns the same check. - The key must be the From's seat.
DC_SIGNER_PUB's comment names the seat its key was minted for,<address> seat, asmessage seat initwrites it. The bot (DC_ADDR) andweb --legs(the web seat) each refuse to start when their seat's local part is not the key's, and the refusal names both seats. A comment in any other form refuses too.
Channels: the bot sends only where its owner said it may
Every dc-* tool drops a job in the bot's outbox, and the bot sends it. So
an agent that could write the outbox could message any chat on the owner's
Delta account. It cannot now. The bot sends only to a chat on its
allow-list, channels.txt, which the owner edits.
The file. channels.txt in the bot's root (~/.sessionphone/channels.txt,
or $DC_ROOT/channels.txt); DC_CHANNELS_FILE overrides the path. Keep it 0600.
One entry per line, # starts a comment:
# sessionphone keys v1: an address line without key: is refused until pinned
# NAME TARGET KEY # note
owner owner@relay.example key:4E4D1E1E160E2C8D034A91AFDCF4D1301E82234F # owner, joined 2030-06-09
family chat:42 # the family group
- NAME is lowercase
[a-z0-9_-]+, unique, may not start with-, and may not be all digits (a tool always reads an all-digit argument as a chat number). A line that breaks this is logged and ignored. - TARGET is an address, meaning the 1:1 chat with that contact (never a
group they are in), or
chat:N, one chat by id, the only way to list a group. - An address line is bound to a key.
key:FPRis the fingerprint of the key the contact holds: 40 hex digits (or 64 for a newer key), as the phone shows it under the contact's profile, Encryption. Spaces and colons in what you type are dropped. The bot sends to, and on the public profile takes in, that contact's 1:1 chat only while the contact holds that key. The key is the identity; the address is the label. If the contact's address changes but the key is the same, the chat still passes and the bot logsnow writes. A different key at the same address does not pass.chat:Nlines take no key (a chat id already belongs to one contact), and akey:on one is a bad line. - An address line with no
key:is refused until a key is pinned to it. The refusal names the command:sessionphone channels pin NAME. sessionphone channelslists it, each address line with its key orUNPINNED.sessionphone channels add NAME TARGET [--key FPR] [--note TEXT]adds a line, andsessionphone channels pin NAME [--key FPR]pins (or re-pins) a listed one. With--keythey only write the file, so they work while the bot runs. Without--keythey open the account to find the one key contact with that address, so the bot must be stopped; they pin only when exactly one key contact has the address, print its fingerprint to compare with the phone, and otherwise write nothing and say why. You can edit the file by hand. The bot re-reads it on every job, so an edit needs no restart, and it logs what changed, a key change included.- A key that rotates is a new contact. When someone's key changes, their chats are refused until you pin the new key.
A tool takes a NAME where it takes a chat. dc-say owner 'hi',
dc-call family, dc-send-file, dc-invite, dc-hangup, dc-answer. A
number still works, but only if it is listed: a chat:N entry, or the 1:1
chat of a listed address. dc-dm and dc-ring take a channel NAME or a
contact's name, and a channel comes first. A NAME that is chat:N for a
group posts into (or rings) the group. dc-react, dc-xdc-update and
dc-delete take a message id and are gated on the chat the message is in.
Inbound is not permission: a chat that wrote to the bot is not listed by that.
A send to a chat that is not listed is refused, loudly, and never sent.
The tool exits 3, prints the list's path on stderr (REFUSED: chat 13 is not on the allow-list /path/channels.txt; nothing was sent. Add it: sessionphone channels add NAME chat:13), and the bot logs REFUSED send ....
The exit codes of a gated send:
| exit | means |
|---|---|
| 0 | sent; the bot's ack says so |
| 3 | refused (not listed, no list), or no bot took the job; nothing was sent |
| 4 | NOT CONFIRMED: the bot took the job and wrote no ack, so it may not have gone (look in bot.log) |
Exit 4 is also what a bot older than the allow-list shows, since it writes no verdict. No refused send exits 0.
It fails closed. A missing list, a list the bot cannot read, or one writable by group or world refuses every send, and the refusal names the path and the reason. There is no off switch: an environment variable that turned the gate off is exactly what an agent would flip.
When the list refuses every send, the bot mails its agent once. At
start, and once a second whether or not a job came in, the bot reads its
list. If it is missing, unreadable, or writable by group or world, the bot
sends ONE mail to the agent's box (DC_MAILTO, else <agent>) by
message send, signed by the bot's seat, never by Delta, so the closed
list cannot block it. The subject
reads [channels] REFUSING EVERY SEND: no channels.txt, and the body names
the list's path, the reason, and the exact command that fixes it (for a
missing list with an owner line in the callers file, DC_CHANNELS_FILE=<path> sessionphone channels add owner <ADDR>, or sessionphone channels seed,
which lists that same owner line). It is one mail per reason: not one
per refused job, again only when the reason changes (missing to loose, or a
list that was fixed and broke again) or the bot restarts, and not again
within the hour for the same reason, so a bot that launchd restarts every
few minutes does not flood the box. A mail that is refused is tried
again every 5 minutes. The bot logs CHANNELS ALERT mailed to ..., or
CHANNELS ALERT NOT MAILED to ...: <why>, and keeps what it did in
<outbox>/.channels-alert (0600). A dc-* tool refused for the whole list
reads that file and adds one line on stderr after REFUSED:: the agent
was mailed about this at <time> (<Message-ID>), or was NOT mailed: <why>
with the fix, or has NOT been mailed: no running bot has reported this
(no bot running, or one older than this check: restart it). The ack of a
job the bot refused for the whole list says the same. A chat that is simply
not listed sends no mail: that is an agent's mistake, not an outage.
Before it queues anything, a tool checks the list (the same parser as the
bot) and refuses at once, exit 3 and no job written, for a missing list, a
NAME not on it, or a number no entry could pass. That check can only refuse.
The bot's own gate decides the rest, so every gated send waits for the bot's
answer, even under DC_TAKEN_WAIT=0. That is a change: under
DC_TAKEN_WAIT=0 a gated send now blocks up to 10 s, and with no bot it ends
NOT TAKEN, exit 3, with the job left in the outbox (remove it before you
resend; the message says so). Before, it said queued and exited 0.
DC_TAKEN_WAIT=0 still applies to dc-hangup and dc-answer by chat
number, which send no words and need no list. With no list, those two take
only a number: a NAME cannot resolve, so it is refused.
The list is seeded for you. sessionphone link lists the first joiner as
owner, bound to the key of the contact that joined by the invite, before it
greets them, so even that first send goes to a listed chat.
sessionphone bot install --owner ADDR seeds owner if the list has no
channel yet, with the key from --owner-key FPR or the one the callers file
already pins to ADDR; without --owner it takes the callers file's owner
line, key and all. ADDR is the owner's Delta Chat address, as their Delta Chat profile shows it, not
their mail address. If there is neither, install refuses (exit 2, before it
touches launchd), names the file, and gives the two ways forward: link the
phone (the first to join becomes the owner), or --owner with the owner's
Delta Chat address. No kit-wide operator setting is read: it holds a mail
address, and the owner must be a Delta Chat contact. Approved callers come
from the callers file only: DC_CALLERS grants nothing, and install refuses
while it is set. sessionphone channels seed runs that
same seed, and nothing else, for a bot that is already running: it lists the
callers file's owner line as owner in a missing or empty list, and never
touches launchd, so it is safe for a bot bot install did not make (a
hand-written launchd job). DC_CHANNELS_FILE, DC_CALLERS_FILE and DC_ROOT
in the shell name the bot's files. A list that already has a channel is left
alone (exit 0); no owner line, or a loose or unreadable list, exits 2 and
writes nothing. The bot itself never seeds: a missing list stays a stop until
someone runs one of these. Approved callers (callers.txt) and
channels are two lists: an approved caller whose chat is not listed hears the
call but gets no in-call chat text.
The public profile gates inbound too. A bot run --profile public reads
its own list, <root>/public/channels.txt (DC_CHANNELS_FILE overrides; chat
ids belong to one account, so each profile has its own, and the bot refuses to
start on the default profile's). Every per-account file of a named profile
lives in its home, <root>/<profile>/: see "Two profiles on one root" in
docs/settings.md. A message,
contact request, reaction or webxdc tap from a chat that is not on it is
rejected: it is not mailed to the agent or accepted, and one line goes in
rejected.jsonl beside that list (who and why, never what they said; capped
at 1 MiB, one rotated generation). sessionphone channels rejected --profile public prints them, one line per chat, newest first. A webxdc record carries
no sender (the update does not name one). A hand-started public bot with its
own DC_CHANNELS_FILE writes rejected.jsonl beside that file; the
--profile public command and the tools' pre-check look at the default
place. A rejected message is still marked seen, so when the bot's account
sends read receipts, the stranger may see their message as read (the core
sends none for a contact request it never accepted; not measured live). The
bot's log line for each rejection names the profile and the list it read. The
private profile's inbound is unchanged.
Nothing the stranger sent is written to bot.log.
The honest limit. The agents run as the owner's user, so code that does
can edit channels.txt, or stop the bot and open the account directly (the
profile recipe in the skill does the latter). This fence stops mistakes and
stray sends. It is not a boundary against hostile code running as that
user. The edit is never silent, though: the bot logs each change to the list
at its next job. Not governed: mail to a guest (dc-invite ... ADDRESS mails
the guest, which is not a Delta send), and call signalling and voice into a
call that already passed a gate.
Before you upgrade, check the owner line. In channels.txt the owner
line is the one named owner, or one whose note starts owner. link,
bot install and channels seed write it named owner. If the owner's line
has another NAME and no such note, add # owner to it before you upgrade.
Otherwise every send to the owner is refused until a key is pinned by hand.
The list must be 0600, and a list shared by two bots needs the same care as
a shared callers.txt: see "Before you upgrade, check each list" under
Callers below.
Upgrading a list that has address lines. A channels.txt from an older
version has address lines with no key. The first time the new bot starts it
migrates the file once (a marker line on top says it has run): it pins the
owner line to the one key contact with that address, keeps the original as
channels.txt.pre-keys (0600, written once), and logs a summary, keys: <file>: N pinned, N unpinned, N ambiguous. Every other address line stays
unpinned, because nobody was there to compare its key. Each unpinned or
ambiguous line is logged, UNPINNED or AMBIGUOUS, with the command that
fixes it; if no address line is pinned the log says so in a WARNING. What
you see is a refused send, no key is pinned to it, until you run
sessionphone channels pin NAME, which prints the key it found so you can
compare it with the phone. An ambiguous line is one where two or more key
contacts share the address (the log lists each contact id and fingerprint). The
bot pins none of them. Choose the key the phone shows for the person you mean
and run sessionphone channels pin NAME --key FPR; a line with no key
contact yet is pinned the same way once they have written to the bot, or by
--key straight away. Later starts do not pin anything: only the commands
above do.
Upgrading breaks every send until the list exists. See the CHANGELOG:
write channels.txt first. The upgrade step, per bot root, with that bot's
DC_* in the shell: sessionphone channels seed (or sessionphone bot install again, for a bot install made), then sessionphone channels add NAME chat:N for each group it posts to. Then restart each bot after
upgrading, so the running bot is the code the tools on disk expect.
A tool never queues a job the running bot would misread. At start the bot
writes <outbox>/.bot (mode 0600): its pid and the job keys it reads. Every
dc-* tool checks its job against that stamp before it writes anything. A
key the running bot does not read exits 3 and queues nothing:
dc-say: REFUSED: the running bot (pid N, started T) does not read job key(s) 'chat': it is older than this dc-* tool. ... To send it, restart the bot (kickstart its launchd job: `launchctl kickstart -k gui/UID/LABEL`; `sessionphone bot install` again only for a bot install made); nothing was queued.
With no stamp, or a stamp whose pid is gone, the bot is older than this
check (or not running), and it is judged on the keys a bot from before the
allow-list reads. A send to a chat number still goes. A channel NAME
("chat") or --voice-message is refused with no bot has stamped <outbox>/.bot and the same "restart the bot", because an old bot crashes on
the first (KeyError: 'chat_id') and silently sends the second as a plain
file. One restart writes the stamp. A program that writes its own jobs into
the outbox is not checked: only the tools are.
Callers: who may ring the bot, bound to a key
The bot answers a call only from a contact that holds a key the owner
approved. The list is callers.txt in the bot's root (DC_CALLERS_FILE
overrides the path), 0600, one line per caller, # starts a comment:
# sessionphone keys v1: a line without key: is refused until pinned
owner@relay.example key:4E4D1E1E160E2C8D034A91AFDCF4D1301E82234F # owner, joined 2030-06-09
bob@relay.example # no key yet: refused
- The key is the identity; the address is the label.
key:FPRis the fingerprint of the key the caller's contact holds: 40 hex digits (64 for a newer key), as the phone shows it under the contact's profile, Encryption. Case, spaces and colons in what you type do not matter. A call passes when its contact holds that key. If the contact's address has changed, the call still passes and the log sayslisted as X, now writes Y. A call from a contact that holds a different key is refused, whatever address it carries, and so is a call from a contact with no key. - A line with no
key:is refused, and the log names the command:sessionphone callers pin ADDR --key FPR. The first line is a marker comment; it only records that the one-time upgrade below has run. sessionphone linkwrites the owner bound to the key of the contact that joined by the invite, the one moment the bot can trust.sessionphone bot install --owner ADDR --owner-key FPRwrites the same line from what you type.--owneralone on a list with no marker writes an address-only line, which the first start pins (below). On a list that has the marker, install refuses (exit 2), writes nothing and names--owner-key.sessionphone callerslists each line with its key orUNPINNED.sessionphone callers add ADDR [--key FPR] [--note TEXT]approves a caller, andsessionphone callers pin ADDR [--key FPR]pins, or re-pins, a listed one. With--keythey only write the file, so they work while the bot runs. Without--keythey open the account and pin only when exactly one key contact has ADDR, which needs the bot stopped; they print the fingerprint they found so you can compare it with the phone, and otherwise write nothing, exit 1, and say why (--keyis the way out).- They touch the bot's own file and account.
link,callers,callers addandcallers pinresolve the callers file and the account exactly as the bot does, from the shell's environment:DC_CALLERS_FILE, else<home>/callers.txt, andDC_ACCOUNTS, else<home>/accounts, where<home>isDC_ROOT(elseSESSIONPHONE_ROOT, else the kept root) plus/<DC_PROFILE>when one is set. Run them with the bot's settings in front (DC_PROFILE=public sessionphone callers pin ADDR --key FPR) to reach that bot. Each says which file it read or wrote (callers: <file>, orin <file>on its result line), andlinkand a pin without--keysay which account they opened (account: <folder>). The bot's own hints (no owner,DC_CALLERSignored) name the command with the settings it needs. - Reading is unchanged. Every other reader of the file still takes the first word of a line as the address. An older sessionphone reading a keyed file finds no address it recognises and refuses the line: it never passes a caller the new bot would refuse.
- A key that rotates is a new contact. When someone's key changes, their calls are refused until you pin the new key.
On 0.3.0, upgrade now. Its caller check trusted the sender's address, which could be faked. Upgrade to 0.3.1 after the checks below; until you can, stop the bot.
Before you upgrade, check each list. The first start pins only the owner line, and only in a list it can migrate. Three things lock the owner out. Fix each one before the upgrade:
-
The owner line needs its note. The migration pins only the line whose note starts
owner.sessionphone linkwrites that note, and so doesbot install --owner. A line written by hand, or bycallers addwith no--note, has none. Then after the upgrade nobody can ring until a key is pinned by hand. Add# ownerto the owner's line before you upgrade:owner@relay.example # owner
-
The list must be 0600. The migration skips a list that group or others can write (
mode & 022, such as 0664). It adds no marker, and every address line in it stays refused, the owner's included. The log saysNOT migrated: mode 0664 lets others write it. 0.3.0 never checked the mode ofcallers.txt, so a loose one that rings today stops ringing after the upgrade. Check the mode, on macOS:stat -f '%Lp %N' callers.txt channels.txt
On Linux:
stat -c '%a %n' callers.txt channels.txt
Then make both lists 0600 before you upgrade:
chmod 600 callers.txt channels.txt
-
One
callers.txtshared by two bots (both name it inDC_CALLERS_FILE). The first bot to start migrates it, counting key contacts in its own account only. The second sees the marker and skips the file, with no log line. So an owner line that only the second bot's account knows stays unpinned, and that owner cannot ring the second bot. There is no lock between the two bots, so do not rely on which starts first. Give each bot its own file. Or, with both bots stopped and already upgraded, write each owner'skey:FPRon its line yourself, before the first start:owner@relay.example key:4E4D1E1E160E2C8D034A91AFDCF4D1301E82234F # owner
Write a key only after the upgrade. An older version reads a keyed line as no address it knows, and refuses the owner until the new bot runs.
Which lines will stay unpinned. Read the list itself; that changes nothing:
cat callers.txt channels.txt
At the first start:
- a line that already has
key:is unchanged; - the owner line is pinned when exactly one key contact in this bot's account has its address. With none it stays unpinned, and with two or more it is ambiguous;
- every other address line stays unpinned, whatever the count.
Upgrading a list from an older version. The first time the new bot
starts it migrates callers.txt once, so the owner is not locked out:
- It keeps the original as
callers.txt.pre-keys(0600, written once, never overwritten). Copying it back is the rollback. - It pins the owner line (the one whose note starts
owner, aslinkwrites it) when exactly one key contact has that address, and adds the marker line on top. Comments, notes and order are kept. - It logs one summary,
keys: <file>: N pinned, N unpinned, N ambiguous, and one line per entry it did not pin. Every other address stays unpinned, because nobody was there to compare its key. - A list it cannot write, or one group or others can write, is left exactly as it was, with no marker, and the log says why; its address-only lines stay refused.
After that first start nothing pins on its own: the marker means it has run.
Only link, callers add, callers pin and bot install --owner-key write
a key, because a key chosen at the moment a call arrives would be the
caller's own choice.
What you see, and how to fix it. A caller whose line is not pinned is
refused, with listed, but no key is pinned to it. If no line is pinned the
start log says NOBODY may ring this agent. Chat is not gated by this list,
so you can still reach your agent while you pin. For each line:
| the log says | meaning | do |
|---|---|---|
UNPINNED ADDR: no key contact with this address yet |
nobody with that address has written to the bot | callers pin ADDR --key FPR with the key their phone shows |
UNPINNED ADDR: one key contact has this address (ID FPR), not pinned unattended |
one candidate, but only the owner line is pinned unattended | compare FPR with their phone, then callers pin ADDR --key FPR |
AMBIGUOUS ADDR: N key contacts share this address (ID FPR, ID FPR, ...) |
two or more keys write from that address; the bot will not choose | ask the person which key is theirs (their phone shows it), then callers pin ADDR --key FPR with that one. Do not pin one you cannot tell apart |
The same marker, .pre-keys backup and log lines apply to channels.txt
(above).
Voice messages: dc-send-file --voice-message
dc-send-file [--profile public] [--html FILE] --voice-message CHAT PATH [TEXT...]
Without the flag, an audio file goes out as Audio: an attachment with a
music note, its file name and a player. With it, the same file goes out as a
Delta voice message: the chat list shows a microphone and "Voice
message", with no file name. The flag takes no argument. Put it before CHAT,
as with --html and --profile, because after CHAT it is read as caption
TEXT.
It is for the agent's own speech, such as a reply it spoke with TTS. Send any other audio, like a song or a recording to share, plain (no flag). It stays Audio and keeps its name. The core renames a voice message's file when it sends it, so the name is lost.
Accepted audio. The suffix must be one of .aac .flac .m4a .mp3 .oga .ogg .wav, in any case. These are the suffixes the core itself treats as audio.
The file's first bytes must match its suffix:
| suffix | first bytes |
|---|---|
.ogg .oga |
OggS |
.m4a |
ftyp at byte 4 |
.mp3 |
ID3, or an MPEG layer III frame |
.aac |
an ADTS frame, or ID3 |
.wav |
RIFF....WAVE |
.flac |
fLaC |
The core does not inspect the bytes of a voice message, so this check is the
only thing that stops a text file, or an empty .ogg left by a failed TTS
run, from arriving as a voice message that cannot play. The tool runs the
check before it queues anything, and the bot runs it again.
The exit codes:
| exit | means |
|---|---|
| 0 | sent as a voice message: sent voice message NAME to chat N, msg M |
| 1 | missing, not audio, or the bytes do not match; stderr names the file and the accepted suffixes, and no job is written. Also: usage |
| 2 | the check could not run (or an unknown --profile); nothing was queued |
| 3 | refused: the chat is not listed, there is no list, or the bot refused the file; or no bot took the job |
| 4 | NOT CONFIRMED: the bot took the job and wrote no ack |
A bot started before this change ignores the job key and sends a plain file.
The tool sees that from the bot's ack: it warns on stderr (the bot sent NAME as a plain FILE, not a voice message ... Do not resend: the file went.) and
still exits 0, because a resend would send the file twice. Restart the bot
(sessionphone bot install runs it again).
bot.log says SENT VOICE to chat N: NAME (B bytes) msg M. A plain file
still logs SENT FILE.
For an outbox: the voice_message job key
A program that drops its own jobs in the bot's outbox ($DC_ROOT/outbox,
by default ~/.sessionphone/outbox) asks for a voice message with one key
on the file job:
{"chat_id": 13, "file": "/path/to/reply.ogg", "voice_message": true, "text": "optional caption"}
"chat": "owner" (a channel NAME) works in place of "chat_id". Write the
job whole under another name, then rename it to *.json, because the bot
reads *.json once a second. The value must be the JSON true or false.
If it is absent, false or null, the job is a plain file. Any other value,
such as 1 or "true", is refused. The send is gated by channels.txt like
any other. The bot checks the audio itself, because an outbox's job never
passes through the tool.
An outbox that writes no "ack" path gets no answer, so it sees a
refusal only in bot.log:
REFUSED voice message PATH: WHYfor a bad value, or a file that is not audioREFUSED send to chat N: REASON (send-voice), orREFUSED send to channel 'NAME': ..., for a chat or NAME the list does not allowSEND FAILED for JOB: PATHfor a missing file
Nothing was sent in any of these cases.
For an outbox: the group job key
An outbox job can make a Delta group of contacts the profile already knows:
{"group": "Book Club", "members": ["Ann", "bob@example.org", "owner"]}
A member is a contact's display name or address, as for dc-dm, or a channel
NAME whose entry is an address. Each must match exactly one contact; a
contact named twice is added once. A channel whose entry is chat:N is a
chat, not a contact, and is refused.
The job sends nothing. The chat it makes is on no list, so it cannot be written to yet. The bot makes the group, adds the members, and acks with the new chat id and the two steps that let it speak:
made group 'Book Club', chat 50, members 3: ...; nothing was sent. List it: sessionphone channels add NAME chat:50, then dc-say NAME TEXT
Every check runs before the group is made, so a refused job makes nothing:
a missing or unusable channels.txt, no "group" name, no "members"
list (or one with an empty or non-string entry), a job that carries
"text", and a member that matches no contact or more than one. If adding
a member fails after the group is made, the ack is not ok and names the
chat, so the owner can delete or list it; nothing was sent to it.
Deleting for everyone: dc-delete
dc-delete [--profile public] MSG_ID [MSG_ID...]
dc-delete takes back a message the bot sent. It deletes the message on this
device and asks every member of its chat to delete it too. MSG_ID is the
msg M the send printed: dc-say, dc-dm and dc-send-file each end their
line with it. An older send's id is in the detail of its ack under
$DC_ACKS. Several ids must all be in one chat. There is no delete on this
device only: the verb deletes for everyone, or it refuses.
It only asks. The bot can only ASK every member's app to delete the message: Delta Chat core sends a deletion request into the chat, and each member's app applies it when it next fetches. A recipient's app may already have shown the message, and an old or offline app may go on showing it. This cannot unsend what was read. Whether core or a recipient's app puts a time limit on the request is not measured (the tests use a fake rpc and cannot tell), so do not assume an old message can still be deleted.
All or nothing. The bot checks every id before it sends anything, and one bad id refuses the whole call before any of it is deleted. It refuses, exit 3, before it asks core for anything, so nothing is deleted or sent:
- an id with no message on this profile (already deleted, or the other profile's);
- a message this account did not send: another member's message, a contact request, a reaction someone sent;
- an info message, such as "member added" or a call status line;
- ids in more than one chat (delete one chat at a time);
- a chat the allow-list does not permit, by the same gate as
dc-react, because the deletion request is itself a send into that chat.
When every check passes and Delta Chat core itself refuses (for example, a
chat that is not encrypted), that is exit 3 too, with core's own words,
verbatim, after Delta Chat core refused:. The bot adds nothing to them:
what core did before it refused is core's to say, and it is not measured.
What it prints:
dc-delete: deleted msg 5531 here and asked every member of chat 12 to delete it
dc-delete: deleted msgs 5531, 5532 here and asked every member of chat 12 to delete them
dc-delete: FAILED - REFUSED: msg 5533 was not sent by this account (from contact 7), and only this account's own messages can be deleted for everyone; nothing was deleted.
The exit codes:
| exit | means |
|---|---|
| 0 | deleted here and asked: the bot's ack says so |
| 1 | usage: no id, or one that is not a positive integer; nothing was queued |
| 2 | could not run (an unknown --profile, or the allow-list pre-check failed); nothing was queued |
| 3 | refused by the bot (any case above, or no list), a bot older than dc-delete ("restart the bot"), or no bot took the job: nothing was deleted. Core's own refusal is exit 3 too, and only its words say what it did |
| 4 | NOT CONFIRMED: the bot took the job and wrote no ack, so it may not have gone (look in bot.log) |
bot.log says DELETED FOR ALL msg M in chat C, or REFUSED delete ... with
the reason. A chat the list does not permit logs REFUSED send to chat C: REASON (delete).
For an outbox: the delete job key
A program that drops its own jobs in the bot's outbox asks for a delete with one key:
{"delete": [5531, 5532], "ack": "/path/to/ack.json"}
The value is a non-empty list of message ids, each a positive integer; an id
named twice counts once. Anything else (a bare number, a string, true, an
empty list) is refused. The job names no chat: the bot reads it from the
messages. The bot runs every check above itself, because an outbox's job
never passes through the tool. Write the job whole under another name, then
rename it to *.json. An outbox's job is not checked against the running
bot's stamp, so restart a bot older than dc-delete before an outbox writes
this key.
An outbox that writes no "ack" path gets no answer: it sees a refusal
only in bot.log, as REFUSED delete ... or REFUSED send to chat C: REASON (delete). Nothing was deleted, unless the line is core's own refusal
(REFUSED delete ... core: ...): what core did then is not measured.
Watching a call: dc-log and dc-log-web
dc-log shows the bot's log as a conversation, both ways (-f follows it).
It reads DC_LOG, else <root>/bot.log. The root is DC_ROOT, else
SESSIONPHONE_ROOT, else the root bot install keeps for the bot it
would pick, else ~/.sessionphone: the same contract every dc-* tool
reads from bin/dc-env. Its first line names the file it read. If
DC_LOG is unset and only the old default <root>/bot/bot.log exists, it
reads that file and says so on stderr.
dc-log-web renders the same view as an HTML page for a phone, newest at the
top, rewritten every 2 s (--once writes it once and prints its path).
| Variable | Default | What it sets |
|---|---|---|
DC_WEB_OUT |
<root>/calllog.html |
the page dc-log-web writes |
DC_WEB_LINES |
120 |
how many log lines it renders |
Serve the page however you serve files, or point DC_WEB_OUT into a
directory you already serve. dc-log-web --help prints usage and writes
nothing.
Each call gets its own directory under DC_CALLS_DIR (<root>/calls).
Whether it keeps any audio is the recording switch's to say, and it is off
by default: see Call recording.
The far party's audio is named for the role: caller-NNN.wav, one per
chunk sent to transcription, or, with DC_EARS=1, caller-call.wav for the
whole call and caller-call.json for its clock. Calls recorded by older
versions used mike-* for the same files and keep those names.
dc-call-mix reads full-in.wav and full-out.wav, which are kept only
while recording is on.
Call recording: off unless it is turned on
A call keeps no audio unless recording is on. With nothing set, a call's directory holds its text after hang-up, and no sound at all.
The switch is the one the ears use, so one setting covers the mic and calls. ears need not be installed: the bot reads the same setting itself. The first layer that is set wins:
| Order | Where | Values |
|---|---|---|
| 1 | --record or --no-record on the bot's own command line (python -m sessionphone.bridge.bot AGENT --record); the last one given wins |
|
| 2 | EARS_RECORD in the environment of a bot run by hand (python -m sessionphone.bridge.bot), and of ears itself. Not the launchd job: bot install does not carry it |
1, true, on or 0, false, off, any case |
| 3 | "record" in ears.json: $EARS_CONF if set, else ~/.sessionkit/voice/settings/ears.json. The launchd job does not carry EARS_CONF, so it reads ~/.sessionkit/voice/settings/ears.json |
true or false (a JSON boolean) |
| 4 | the default | off |
- A bot under launchd sees no
EARS_*variable.sessionphone bot installcarries onlyDC_*and the mail-route keys into the job, soEARS_RECORD,EARS_RECORD_DAYS, the two notice settings andEARS_CONFset in your shell never reach it, and it reads~/.sessionkit/voice/settings/ears.json. To switch recording on for it, set"record": truein that file, or runears recording onwithEARS_CONFunset: ears writes$EARS_CONFwhen it is set, and the launchd bot never reads that file. - An empty
EARS_RECORDcounts as unset. A missing, unreadable or brokenears.jsonsets nothing, and the bot says so once inbot.log. A file left behind by 0.1.0 is not read either: see Upgrading from 0.1.0. - Any other value (
EARS_RECORD=yes,"record": "on") fails closed: that layer wins with recording off, andbot.lognames the layer and the value. - The command line and the environment are read once, at start. The file is
read again whenever it changes, so turning it on or off in
ears.jsontakes effect without a restart.ears recording onandears recording offwrite it, and say so when a higher layer wins over what they wrote. - When recording is on, every voice the call carries is kept, guests included.
What a call directory holds:
| During the call | After hang-up | |
|---|---|---|
| Off | transcript.md, offer.sdp, answer.sdp, ear-taps.jsonl; with DC_EARS=1, caller-call.wav and a caller-call.transient marker; without it, the chunk being transcribed |
the text files only. No audio. |
| On | the text files, full-in.wav, full-out.wav, recording.json (what was kept, and the switch as the call saw it); every caller-NNN.wav, or with DC_EARS=1 caller-call.wav and caller-call.json |
all of it |
With recording off, the audio a live call needs is written and then deleted:
- Each
caller-NNN.wavchunk is read by speech-to-text and deleted when the transcription returns, whether it worked or not. - With
DC_EARS=1,caller-call.wavis written for the length of the call, because the ears speaker side-car reads it live to tell who is speaking. It is deleted 10 seconds after hang-up, once the last turn has been mailed. - If the bot stops in the middle of a call, the
caller-call.transientmarker is still there. The bot deletes that directory's audio at its next start.
Turned off during a call, full-in.wav and full-out.wav stop at the
next chunk or turn, and all of the call's audio is deleted after hang-up.
Turned on during a call, the next caller-NNN.wav chunk is kept;
full-in.wav, full-out.wav and caller-call.wav start with the next call.
Retention. record_days (EARS_RECORD_DAYS, else "record_days" in
ears.json, else 7) is how long kept audio stays. 0 keeps it until you
delete it. The prune runs at start and at most once an hour when a call
ends, and only while recording is on. It deletes only the files a call's
recording.json names. A call directory without a recording.json, such
as every call made before this switch existed, is never pruned or deleted.
Every call says which it is, in bot.log at the start of the call. The
file layer names the file it read, in full (shown here with ~ for your home
directory); an env layer is seen only by a bot run by hand:
CALL recording: off (default), retention 7 days (default)
CALL recording: on (file ~/.sessionkit/voice/settings/ears.json), retention 7 days (default)
CALL recording: on (env EARS_RECORD=1), retention 7 days (default)
CALL recording: off (env EARS_RECORD='yes' invalid, fails closed), retention 7 days (default)
The caller is told, too. Two notices, each with its own setting, both on
by default. Neither has a command-line flag; either can be off without the
other. A bot under launchd reads them from ears.json only: bot install
does not carry the EARS_* variables in the table.
| Setting | Environment | ears.json key |
What it does |
|---|---|---|---|
record_notice |
EARS_RECORD_NOTICE |
"record_notice" |
posts one line into the call's chat when the call connects, on every call |
record_spoken_notice |
EARS_RECORD_SPOKEN_NOTICE |
"record_spoken_notice" |
on a call that is being kept, says "This call is being recorded and kept." before the greeting, in the call's own voice |
The chat line follows the call's recording switch:
| Recording | The line |
|---|---|
| off | This call is not being kept: audio is deleted when the call ends. |
on, record_days 7 |
This call is being recorded and kept for 7 days. |
on, record_days 0 |
This call is being recorded and kept. |
- The environment wins over the file, and the file over the default (on).
Values are read as
record's:1,true,onor0,false,offin the environment; a JSON boolean in the file. An empty variable counts as unset. The file is followed while the bot runs. - Any other value turns the notice ON, the opposite of
record, andbot.lognames the layer and the value: a garbled setting never hides whether a call is kept. - The spoken notice is never said on a call that is not kept. If speech fails,
bot.logsays so and the greeting goes on; the chat line, when it is on, still tells the caller. - If neither the spoken notice nor the chat line reaches the caller on a kept
call (speech fails or is cut short, and the chat line is off or could not be
sent), the notice is posted once as chat text before the greeting, and
bot.logsays so in one line:recording notice: ...; delivered as text. A chat thatchannels.txtrefuses is not retried, and a chat line not confirmed within 5 seconds is not sent again;bot.logsays the caller was not told and the call is still kept. - The chat line goes through the profile's
channels.txtlike every in-call post. Each call logs what it did:CALL recording notice: chat line on (default), spoken notice on (default).
To turn either off for every call, set it in ears.json
("record_spoken_notice": false). The environment (EARS_RECORD_NOTICE=0)
works only for a bot run by hand: the launchd job does not carry it.
What needs recording on. Nothing during a call. Two tools read a kept
call afterwards, and each says so plainly when its files are missing:
dc-call-mix (full-in.wav, full-out.wav) and ears' ears eval --call DIR (caller-call.wav, caller-call.json).
Disk. A kept call takes about 7.7 MB a minute (full-in.wav 1.9 MB,
full-out.wav 5.8 MB), plus about 1.9 MB a minute for caller-call.wav with
DC_EARS=1. The chunks are a little less again.
Call troubleshooting
When a call goes quiet, bot.log can tell you four things: where each thing
the caller said was mailed, whether anyone answered it, what the bridge did
while it waited, and why a call never connected. Other docs link this
section as README.md#call-troubleshooting, so keep the heading.
Where the log is. It is bot.log in the bot's root
(~/.sessionphone/bot.log, or $DC_ROOT/bot.log). DC_LOG overrides the
path. Each line starts with the date and time, which the examples below
leave out. Every line the call code writes starts CALL. While a call is
live, each one ends [call <id>], where <id> is the call's Delta message
id:
CALL TURN <n> mailed to <box>: msg <msgid>, taken=pending [call <id>]
So grep '\[call <id>\]' bot.log gives you one call. The verdicts written at
hang-up come after the tag is cleared, so find those by TURN <n>.
dc-log shows a call as a conversation, and reads the same file: DC_LOG,
else <root>/bot.log (see
Watching a call). If only the old
default <root>/bot/bot.log is there, it reads that and says so on stderr.
dc-log -f
Each call also gets a directory, <root>/calls/<YYYYmmdd-HHMMSS>-chat<chat_id>-msg<id>/.
It holds transcript.md (the bridge's own words are under bridge:), and,
only while recording is on, full-in.wav and full-out.wav (see
Call recording). It also holds offer.sdp and
answer.sdp, which contain network addresses. bot.log does not: no call
line carries an IP address, a port, or the relay's TURN username or
credential.
A call that is over, still reported live. The bot writes
<home>/call-live.json (DC_LIVE) when a call connects, and removes it when
the call ends; dc-live and dc-say --call read it. When launchd stops the
bot (SIGTERM: a bootout, or an install over a running bot), the bot ends each
call it holds, so the phone hangs up too, and removes the file before it
exits. A bot that died with no chance to (a SIGKILL, a crash) can still leave
the file. The next bot removes it at start, once it holds the account, and
says so once in bot.log. Until then, dc-live does not believe a file whose
call began before the running bot started: it says no call is live (exit 1,
and the reason on stderr), and --id prints nothing.
Where a turn is mailed
A turn is what the caller says between pauses. Each one is mailed:
- To the agent's box,
DC_MAILTO, else the bare<agent>. - Cc the call's guests:
call-guests.txtin the root, plus anyone added withdc-invite. - Threaded under the call's root mail, whose subject is
[<chat> <chat_id>] CALL <HH:MM> with <caller>. - With
Reply-Toset to the bridge's own box,DC_ADDR, else<agent>-chat@<host>.
The bridge makes the turn's Message-ID itself, so the log can name it. The
msg field is that id, which finds the exact mail in the box:
bridged to mail: [<chat> <chat_id>] <caller>: ...
CALL TURN <n> mailed to <box>: msg <msgid>, taken=pending
CALL TURN <n> mailed to <box> cc <guest>,<guest>: msg <msgid>, taken=pending
A turn the mail system refused was not sent then. It is kept in the undelivered spool and retried, never twice under its id:
BRIDGE MAIL FAILED rc=<rc> kind=<kind>: ..., kept at <path>
CALL TURN <n> NOT mailed to <box>: rc=<rc> kind=<kind>: ..., taken=no
CALL TURN <n> NOT mailed to <box>: <ExcType>: ..., taken=no
On a live call (voicemail too), the caller is told: the call says "I couldn't reach the agent." once for that turn. A turn is one mail however many chunks it joined, so the line is never said per chunk, and the spool's later retries say nothing. A call that is closing says nothing. The log line:
CALL UNDELIVERED box=<box> turn=<n>: said on the call
Did anyone take it?
The bridge cannot see the agent's mailbox. The box may be on another host,
and a mail that moved to cur/ was moved by the indexer, not read by
anyone. What the bridge can measure is a reply arriving in the call. So
each mailed turn's taken=pending is followed by exactly one verdict:
CALL TURN <n> taken=replied via=<mouth|dc-say|mail> after <s>s
CALL TURN <n> taken=unknown: no reply before <the next turn|hang-up>
replied: a line came back by the mouth (mouth say --device call:<id>), bydc-say --call <id>, or by mail threaded to the call. One reply resolves every turn still waiting, because the bridge cannot tell which turn it answers.afteris counted from themailed toline. If it is longer than the no-reply window, the reply was late.unknownis notno. The agent may have read the turn and said nothing.the next turnmeans the no-reply watchdog fired for a newer turn while this one was still waiting.nois only for a send the mail system refused (above).- The bridge's own words, such as the greeting, the invite notice and the holding line, never count as a reply.
No reply: the holding line
The no-reply watchdog is off by default (DC_NO_REPLY_S=0): a call
speaks no holding line. To turn it on, set DC_NO_REPLY_S to a window in
seconds, such as DC_NO_REPLY_S=8. Then, if a mailed turn gets no reply
within that window, the call says DC_HOLDING_LINE ("I heard you, give me a
moment." by default) once. The taken= lines are logged either way. At start,
the bot logs the setting:
CALL no-reply watchdog: <w>s
CALL no-reply watchdog: off
When the window ends with the turn still waiting:
CALL NO-REPLY box=<box> turn=<n> after=<w>s: holding line spoken
CALL NO-REPLY box=<box> turn=<n> after=<w>s: caller talking, no holding line
box= names the box that was told and did not answer, so check that box
first. The window starts at the mailed to line. A newer turn replaces the
timer, so two quick turns get one holding line. A reply after the window
still plays. Voicemail never gets the holding line, and neither does a turn
whose mail failed: that turn gets "I couldn't reach the agent." instead (see
above). The holding line is never mailed.
The first call: a wait while the speech models download
On a Mac where the speech models are not downloaded yet, the first call
would be silent for about a minute while the speech server fetches them. So
when a call connects, the bot asks ears services weights --json where the
speech-to-text and text-to-speech models stand. While either is still
missing or downloading, the caller hears:
- A line, said by macOS
say, which needs no model: "Please wait for the voice model to download, about 2.9 GB. This only happens once." The size is what is still to fetch, and is left out when it is not known. - Then the music file,
DC_WAIT_MUSIC(<root>/wait-music.opusby default), looped and turned down. It plays only while nothing is being said, so it never plays under speech. It can be any audio file the bot can decode. Without one, the line repeats every 20 s with the download's progress instead ("Still downloading: 1.2 GB of 2.9 GB."). Nothing plays in between.
The music stops at once, and for good, at the first of: the bot's own speech-to-text answers a test clip (asked every 3 s), the greeting or any other line is spoken, the call ends, or 20 minutes pass. It is not a line: it is never in the transcript, and a barge-in leaves it alone. A recording of the call has it, because the caller heard it.
The greeting is not delayed by the wait. With the models in, or with an
ears that cannot say where they stand, a call is as before. With no say
(not a Mac), there is no line, and the music plays if there is a file.
DC_FIRST_CALL_WAIT=off turns the wait off. The bot logs each wait, its
start and its end:
CALL first-call wait: the models are <state> (about ... to fetch); the line, then the music
CALL first-call wait: ended (...) after <n> s
<state> is downloading or missing. The end names what stopped it:
hearing answers, a line, the call ended or the 20 min cap.
sessionphone ships no music. To use a file of your own, convert it to small mono Opus with its tags and chapters stripped:
ffmpeg -i <your file> -map_metadata -1 -map_chapters -1 -ac 1 -ar 48000 \
-c:a libopus -b:a 32k ~/.sessionphone/wait-music.opus
Put it in the bot's root, or point DC_WAIT_MUSIC at it and run
sessionphone bot install again. The bot decodes it once and reads it again
when the file changes.
A missed call
The bot logs every ring the moment it sees it, before it decides whether to answer, refuse or ignore the call. A ring the bot answers then gets its own first line, once the call's session has started:
CALL EVENT IncomingCall msg <id> chat <chat_id> video=<False|True>
CALL INCOMING msg <id> chat <chat_id> ...
Neither line carries the caller's address. When the phone shows a missed
call, look for the call's msg id:
- No
CALL EVENT ...line, and aRECVline for that chat readingMissed call: the call reached the bot only after the phone had stopped ringing. Delta Chat delivered it late, as a missed call, so the bot never saw it ring. Look at delivery: the bot's connection to its relay, and whether the bot was running. - A
CALL EVENT ...line, then aREFUSEDorCALL event ignored ...line: the bot heard the ring and did not answer, for the reason that line gives. - A
CALL EVENT ...line and noCALL INCOMING ...line: the bot heard the ring, but the call's session stalled before its first line. That is a bug in the bot: report it with thebot.loglines around the ring.
A call that never connects
CALL ice servers: <n> url(s): <scheme>:<host> ...
CALL ice gathering gathering
CALL ice gathering complete: local host=<n> srflx=<n> relay=<n>
CALL added trickled candidate type=<host|srflx|prflx|relay>
CALL trickled candidate ignored: <ExcType>
CALL ice state <checking|completed|failed|closed>
CALL ice pair local=<host|srflx|prflx|relay> remote=<host|srflx|prflx|relay>
CALL ice pair none: no candidate pair succeeded (remote host=<n> srflx=<n> relay=<n>)
CALL ice pair unknown: <ExcType>
CALL connection <state>
How to read them:
ice servers: 0 url(s): there is no STUN or TURN server, so only a direct path can work. The host is the relay's name, never the caller's.local ... relay=0: the TURN relay gave this side nothing.remote ... relay=0underice pair none: the phone offered no relay either. If both sides are behind a NAT that blocks a direct path, the call has no way through.ice pair local=relay: the call went through the relay.hoston both sides means a direct path on the same network.ice state failed, thenice pair none: no path existed.ice state completedwith a pair, thenconnection failed: ICE found a path and the encryption handshake (DTLS) failed.ice pair unknown: the pinned ICE library changed under the bridge. The call carries on.
How to tell a call was heard
With DC_HEARD=1, and with the chat listed in DC_HEARD_CHATS, the call's
words go into VoiceMode's heard log,
~/.voicemode/logs/conversations/heard_YYYY-MM-DD.jsonl. A turn's line has
"kind": "turn", "source": "call", "device": "delta:<agent>/<chat_id>"
and "call": "<id>". Its device is not call:<id>: that is the name the
mouth and the ears use for the call. If the chat is not listed, nothing
from the call goes in, and bot.log says so once:
CALL heard: chat <chat_id> is not in DC_HEARD_CHATS ...
What a dc-say ack says about the call
When a call is live in the chat, a dc-say is also spoken into the call.
Its ack (the line dc-say prints) ends with what happened to the speech:
| ack ends with | means |
|---|---|
; spoken into the call |
every sentence was rendered and handed to the call |
; NOT spoken: tts failed: <reason> |
the speech failed, for example No such file or directory: 'ffmpeg' or the speech server down; the words are in the chat as text |
; NOT spoken in full: <k> of <n> sentences failed, tts failed: <reason> |
some sentences were said, some were not |
; NOT spoken in full: cut off on the call |
a barge-in cut the line before all of it was said |
; queued to speak |
the speech was still rendering when the ack was due |
; NOT spoken (chat only) |
the line went to the chat only and was never sent to the speech: dc-say --text-only |
dc-say --text-only CHAT "text" posts to the chat and never speaks it,
even with a call live there. Use it for anything addressed to the chat and
not to the person on the call: a report, a log, a status. A plain dc-say
(no flag) is read aloud into a live call, so a long report sent that way is
read out into their call. --text-only sets "no_speak": true on the job;
without the flag the job has no such key. It contradicts --call (say this
into the call), so both together exit 1 and nothing is queued.
The bot waits up to 3 s for the speech before it writes the ack. Those 3 s
are shared by every speaking dc-say the bot takes in one pass of its outbox,
so a burst of them is acked within about 3 s in all, not 3 s each. A long line,
a speech server's first render, or a line late in a burst can take longer, and
then the ack says queued to speak. If that line then fails, the bot mails the agent one line
that starts NOT spoken and names the chat, the chat message and the reason.
Nothing is mailed when it is spoken. The mail goes to the bridge's agent
(DC_MAILTO), also for a dc-say --who NAME. An ack never says spoken
before the speech was rendered.
Mail into a call: who is known
Mail threaded to a live call is read aloud into it, and posted in the call's
chat. Each call mail tells guests how (message send to the bridge, -r the
call's root). The bridge decides two things about each mail.
Which call it reaches. A mail must answer the live call's root, matched
whole, or another root the bridge itself issued for that chat. A chat keeps
its earlier roots only across a turnover: its next call starts within
TURNOVER_S (120 s) of its last call ending. It keeps the newest 8. The roots
are held in memory, so a reply to a root from before a bot restart is not
spoken. A call-<chat>-<digits>@ string the bridge never issued reaches
nothing.
Whether the sender is known. The bridge asks, top down, and the first rule that fits decides:
- A
From:with a pinned key (a row ofcall-guests.txtfor that address that carries a key; case does not matter) is known when both hold:message verify(sessionmail) exits 0 on the mail, so its signature is good, and thesigner-keyit prints is one of that address's pinned keys. A signer that is not pinned is unknown. The registry is never asked: a pin is the owner's narrower claim. To move a guest to registry trust, delete the key from their row. - A
From:with no pinned key, on an address-only row ofcall-guests.txtor whose local part (cut at+) is a key ofvoices.json, is checked by sessionmail's registry:message verify --trust --require-blessing. It is known only when all of these hold:- the exit code is 0 and the last line is exactly
TRUST: FULL. FULL means the key has a registry cert and the owner has blessed that cert. An UNBLESSED cert never counts: a seat writes its own cert, so a cert alone is only a claim; - there is exactly one
registry-certline, it readsok, and itsagent:is the mail's wholeFrom:local part,+extensionkept, case ignored. The host is not compared.
- the exit code is 0 and the last line is exactly
- Anyone else is unknown, and no verifier is run.
The registry is sessionmail's: SESSION_REGISTRY, else "registry" in
~/.sessionkit/mail/settings/mail.json, inherited from the bot's
environment. With no registry, this rung knows no one. The bot still
starts. Each such mail is unknown, with one log line, and the first one in a run also logs
registry trust is OFF on this host. Only a pinned key makes a sender known
there. A plus-address seat (project+task.role) is never known by the
registry until a later sessionmail release. Until then, sessionmail checks a cert
against the local part cut at +, so the bridge's whole-local-part match and
sessionmail's check can never both pass. A missing, old (no --trust),
failing or hung message verify, an exit code that disagrees with the
TRUST: line, and a FULL without a single ok registry-cert line naming the
From: are each unknown, with a log line that names the case.
SESSIONMAIL_MHS_ORACLE is never passed to message verify.
A known sender speaks in their X-Voice: if the mail sets one, else their
voices.json voice, else the plain voice. Everyone else is announced in the
plain voice as someone "who I do not have a voice for", whatever their
headers say, and the chat post reads Name <address> (unverified sender): ....
If message verify is missing, fails or hangs, the sender is unknown.
call-guests.txt (in the root, DC_GUESTS) is one row per line:
# address key (optional) comment
alice@example.org
pat@example.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... laptop
pat@example.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... phone
Every row is copied on the call thread. A row without a key copies, and is known only by the registry (rule 2 above). An address may sit on several rows, one key each.
To pin a guest's key, take a mail they signed (any message send from
their seat) and run message verify on it. The signer-key line is what goes
after their address:
$ message verify 3
signature-crypto : ✅ good
principal-binding : ✅ good
signer-key : ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
The guest can do the same on a mail of their own and send you the line. Check the key with them by another channel before you pin it.
Two limits, for now:
- An agent's key is per session. A pin for an agent goes stale when that agent restarts; pin its new key, or leave its row address-only and let the registry vouch for each new key (rule 2 above).
- An agent's mailed replies to a call stay unknown until a later sessionmail release
signs call mails. They are spoken, announced as unverified. The mouth and
dc-sayare not affected.
One line at a time
A call has one voice, and every line spoken into it takes its turn. A line
is a mouth line (mouth say --device call:<id>, from its start to its end)
or one whole reply from the bot: a guest's mail, a dc-say, the greeting,
the holding line. The line that arrived first plays to its end. A line that
arrives while another is playing waits behind it and then plays whole. Lines
that wait play in the order they arrived. One speaker's sentence is never
cut into another's.
A barge-in, or the mouth's own cut, stops the line that is playing and drops every line waiting behind it, whoever's they are. A reply that is cut says no more of its sentences. A mouth that stops before its line ends is a cut. Sounds are not lines: they play over whatever is playing, at once.
A reply waits for the caller to finish
A reply does not start while the caller is speaking. A reply is a mouth
line, a dc-say or a mail into the call. When it is next to play, it waits
until the caller has been quiet for DC_FLOOR_GAP seconds (0.8 by default),
then plays. It waits DC_FLOOR_CAP seconds at most (6 by default), so noise
on the line cannot keep the agent silent. Lines still play one at a time and
in the order they arrived; a reply that waits holds the lines behind it. The
bridge's own lines, such as the greeting, the holding line and the notices,
do not wait. DC_FLOOR_GAP=0 turns this off. At start, the bot logs the
setting, and each reply that waited logs one line as it starts:
CALL floor control: a reply waits for <gap>s of caller quiet, <cap>s at most
CALL floor control: off (a reply may start while the caller speaks)
CALL FLOOR <who> line held <s>s, released by quiet
CALL FLOOR <who> line held <s>s, released by cap
A reply that never had to wait logs no FLOOR line.
A browser tab as a call: sessionphone web
sessionphone web serves one page and its WebSocket, so a browser tab
becomes one more call device. You sign in with a passkey (Touch ID, Face ID
or a PIN) and tap Call. It is for the tailnet only. The server runs in
the foreground, and Ctrl-C stops it. There is no launchd job.
sessionphone web # binds 127.0.0.1:8775, prints an enrol code and its QR
sessionphone web enrol # a fresh one-time enrol code and its QR, beside a running server
- The tailnet reaches it through
tailscale serve. Runtailscale serve --https=8775 http://127.0.0.1:8775beside the server, also in the foreground: Ctrl-C removes the entry. Never usefunnel, which is public.--tls CERT KEYmakes the server terminate TLS itself; only then may--hostbe this host's tailnet IP. - Every passkey needs an enrol code. There is no trust-on-first-use.
The code is 10 characters, lasts 10 minutes, is used once, and 5 wrong
tries burn it. It proves you are at this machine. The server prints one at
start when no passkey is enrolled for the page's host.
sessionphone web enrolprints a fresh one at any time, and the running server reads it from disk. Later sign-ins are one touch. - A QR code is printed beside the enrol code. Point the phone's camera
at it. The page opens with the code filled in, and you tap Check the
code. The QR holds
https://<host>.<tailnet>.ts.net:<port>/#code=<code>. The code is in the#fragment, which a browser never sends, so it reaches no server log and no proxy. The page reads it once, then removes it from the address bar and the history. The address is this host's tailnet name, with the port of an--originon that host when one is given. With no tailnet it is the first--originthat is not localhost. With neither there is no QR, and one line says why. Giveenrolthe server's flags on the group:sessionphone web --port 18775 --origin https://<host>.<tailnet>.ts.net:8775 enrol. The QR is shown on this screen only, like the code, and a scanned code is still used once. - Without
--legsa call is a closed loop. You hear yourself about 1 s later. No socket is made, so nothing on this host can hear the call. Use it to try sign-in, mic and speaker. --legslets this host's ears and mouth open the call ascall:N. It servescall-ears.sockandcall-mouth.sockunderROOT/web/. A seat finds N in the server's JSON log row ({"call": N, "event": "started", ...}), or the ears find the call by probing.- With
--legs, each finished turn of the caller's is mailed to the seat, as a Delta call's turns are, so the pager wakes it. The ears write each turn to the heard log. The server follows its owncall:Nthere and mails each finished turn once. See Web call turns. --legson a live root refuses (exit 2) unless--demois given. A live root is the default root, the one$SESSIONPHONE_ROOTnames,~/.sessionphone, or any~/.*/delta-chat: a keeper picks up any leg socket there within seconds.--legs --demoon the default root is the DEMO, a live seat on the line, and it waits for its GO.- One live call per server. A second caller gets
busy. --caller-name NAMEnames the caller. It is theuserin theauth-okframe, the speaker in each turn's mail, and the label on a new passkey. The default is$DC_CALLER_NAME, elseowner. It is only a label and grants nothing. A passkey is matched by its host, so a rename changes only the label on later enrols, and every enrolled passkey still signs in.
State lives in ROOT/web/ (mode 0700): passkeys.json and the enrol code.
ROOT is --root, else $SESSIONPHONE_ROOT, else ~/.sessionphone.
Web call turns: mailed to the seat
sessionphone web --legs --root ~/web-try --mail-to agent@mac.example # a scratch root
--legs still needs a scratch --root, or --demo for the DEMO (see
above): without one it exits 2 before any of this.
- HOST is
$DC_HOST, else the host part the mail drop gives a bare From: this machine's DNS name when it is sessionmail's short host or a name under it (a tailnet name such asmac.example-tail.ts.net), else the short host itself, lowercased. The short host isSESSIONMAIL_HOST, else thehostin sessionmail'smail.json, else the first part of this machine's name. A reverse-DNS name (….ip6.arpa) or an unrelated DNS name is never used. - To is the seat:
--mail-to BOX, else$DC_MAILTO, else the bare$DC_AGENT. There is no further fallback. A bare box is on this host (sessionmail adds the host);box@hostis kept as given. - Reply-To is the call's box,
calls+web-<N>@HOST. A plain reply is spoken into the call. - From is the web seat, the local part of
$DC_ADDR, else<agent>-chat, with the display name<Name>'s web call. Its domain is sessionmail's. The turns are signed as the bot's mail is (Bot mail signing). When they are signed, the key inDC_SIGNER_PUBmust be that seat's, or--legsrefuses to start (see call mails). - The headers are a Delta call turn's. They include
X-Delta-Chat-Kind: callandMedium: voice. There is noX-Delta-Chat-Id, because a web call has no chat. - The subject is
[web <N>] <caller>: <words>, and the body saysfrom: <caller>. The body carries the whole turn, never cut; only the subject's words are cut, for display. The caller is--caller-name, else$DC_CALLER_NAME, elseowner. - The body ends with how to answer:
mouth say --device call:N "text", or reply to the mail. --heard-dir DIRis where the turns are read. The default is$EARS_HEARD_DIR, else$VOICEMODE_BASE_DIR/logs/conversations, else~/.voicemode/logs/conversations, as ears-mail finds it.- The seat's box is made at start, before the first turn, as the bot
makes its agent's: the mail drop never makes a box, and a turn to a box
that is not there is refused, so every turn of the call would be lost.
--legsasksmessage pathfirst, and runsmessage setup --boxonly when there is no box (forbase+tag, the boxbasecounts). A To off this host has no box here: on an MTA the call starts, and the MTA routes its turns; on the mail drop, which delivers on this host only,--legsrefuses at start with one line naming the To and what to do (set--mail-toto a box on this host, or use a carrier that reaches it), and exits 2 before anything is served. - The start lines name the To, the From, the Reply-To, whether the
turns are signed and why (for example
unsigned (carrier drop: ...)), the seat's box (turns box:) and the heard log. A heard dir that is not there is marked(missing): no turn reaches the seat until the ears write there. --legsrefuses to start (exit 2, nothing made on disk) for the seat in these cases:- No seat is named.
CLAUDE_CODE_AGENTis not taken: it names the shell that started the server, not the seat. - The To is not a mail address.
--mail-to alicewould go to a local user. - No
DC_HOSTis set, and HOST names no mail host: there is none, it islocalhost, or it is a reverse-DNS name (SESSIONMAIL_HOSTcan name one). That case refuses even with--mail-to, because the Reply-To is built from it. A short host with no dot is accepted. - No signer when the turns are signed, a key of another seat, or a
messagewith no--message-id, as for the bot. - No box for the seat that
message setup --boxcan make. The refusal names the box and the step.
- No seat is named.
- Exactly once.
- Only lines written after the call started are read.
- A turn is mailed only if its
seqis above the last one decided. - The Message-ID is fixed,
<call-turn-web-<N>-<seq>@HOST>, so a replay is the same message. Its HOST is sessionmail's, notDC_HOST. - A send that sent nothing, refused for a reason in the world (
exec,identity,mtaorundeliverable), is tried 3 times with the same id. A refusal of the mail itself (subject,message-id, ...) is not tried again. A send that may have gone (it timed out, or the mail system failed after taking it) is never tried again, and the log sayssent=unknown.
- Echo is dropped. A turn that is the call's own speech heard back is
never mailed. The log names the call, the seq and the score:
ECHO call:N seq S: ... | dropped: <text>. The heard log keeps the turn. - At hang-up the server keeps reading for the last sentence, until the
ears'
listen-stoppedor 20 s. Ctrl-C waits for the same drain. - One writer: never run
ears-mail --to seatsbeside it. That mode mails call turns too, and the seat would get each turn twice.ears-mail --to deviceskips call turns and is safe. - The mail is M5: signed, by
message send --message-id. It goes through the same one sender as the Delta call mails (see call mails). - Without
--legs(echo mode) nothing is mailed, and the two flags do nothing.
Try it
On your Mac:
sessionphone weband, beside it,tailscale serve --https=8775 http://127.0.0.1:8775, both in the foreground. Openhttps://<host>.<tailnet>.ts.net:8775/on your phone or Mac, enrol with the codesessionphone webprinted, tap Call, and hear yourself echoed.
Two things the try-it depends on:
- The port pairing. The backend and
tailscale serveshare 8775 on different addresses. That pairing has not been tried yet. If it clashes, keep the URL and move the backend:sessionphone web --port 18775 --origin https://<host>.<tailnet>.ts.net:8775besidetailscale serve --https=8775 http://127.0.0.1:18775. The browser's Origin carries the serve port, not the backend's. - How the enrol code reaches you. If you start the server, you read the
code off your own screen. If a seat starts it for you, run
sessionphone web enrolyourself on that Mac (over ssh from the phone works), so the code never passes through mail or a chat. On that Mac's own screen, scan its QR instead of typing the code.
Open it from another app
Another app, such as Talk's phone button, opens the call with a plain link. Nothing else passes between them.
- The URL is the browser-facing one:
https://<host>.<tailnet>.ts.net:8775/, the porttailscale serveanswers on. If the backend moves (--port 18775), the URL keeps 8775.http://localhost:8775/serves for development on the same machine. - It carries nothing. There is no path, query or fragment after the
/. The server reads none of them, and anything added could only leak. The app holds no token, cookie, key or credential id, and never fetches the URL itself. - Open it top-level, never in an
<iframe>. Use<a href="URL" target="_blank" rel="noopener noreferrer">. The new tab signs itself in with the call's own passkey (one touch), from any origin. A framed copy cannot: the page sendsframe-ancestors 'none'andX-Frame-Options: DENY, and the server refuses a passkey made inside a frame (crossOrigin: true). - The app's origin is never added to
--origin. The call's allow-list stays its own. - Talk's call URL setting points at it. Set it to the URL above, with this host's real tailnet name.
The browser drive: make browser
make browser runs tests/browser/run.sh: the real page in headless Chrome
against the real server.
- a self-signed
localhostcert on a spare port, served withsessionphone web --tls ... --origin https://localhost:P; - a scratch root, and no
--legs; - a virtual authenticator, and a fake mic playing a 440 Hz wav.
It enrols with a code from sessionphone web enrol, signs in, gets busy
while a second caller holds the line, reaches live call:N, and measures
the echo with an AnalyserNode. Then it hangs up.
Then a scratch page on another origin (http://127.0.0.1:Q) opens the call
with target="_blank", and the new tab signs in and calls. A page that frames
the call is refused by the browser, so the frame holds nothing to sign in with.
It needs Google Chrome, node 22 or later, and openssl. It is not part of
make test. KEEP=1 keeps its scratch dir and logs.
Configuration
sessionphone keeps one agent's state in one root, and the bot reads
DC_* environment variables. bot install remembers the ones it was
given in the phone tool's host settings file,
~/.sessionkit/phone/settings/phone.json.
| What | Where it lives |
|---|---|
| the root, mode 0700: account, outbox, call dirs | $SESSIONPHONE_ROOT, else the one bot install keeps, else ~/.sessionphone; the bot's own can be $DC_ROOT |
| who may ring the bot, each caller bound to a key | <root>/callers.txt, or $DC_CALLERS_FILE |
| where the bot may send, each address bound to a key | <root>/channels.txt, or $DC_CHANNELS_FILE |
call recording: record, record_days and the two notices |
~/.sessionkit/voice/settings/ears.json; $EARS_CONF moves it only for a bot run by hand (the launchd job does not carry it, so it reads ~/.sessionkit/voice/settings/ears.json) |
| the bot's launchd job | ~/Library/LaunchAgents/dev.sessionkit.phone.bot.<agent>.plist |
what bot install remembers, one map per bot |
~/.sessionkit/phone/settings/phone.json, then phone.d/*.json |
| the web call's passkeys | <root>/web/passkeys.json |
DC_*are carried into the bot at install.sessionphone bot installcopies everyDC_*set in your shell into the launchd job, and the bot reads them when it starts. To change one, set it and runbot installagain. sessionmail's mail-routeSESSIONMAIL_*ride along (see Setting up the bot's seat); nothing else in your shell reaches the bot.bot installremembers what it was given. Once the job is loaded, it saves eachDC_*andSESSIONPHONE_ROOTyour shell set in~/.sessionkit/phone/settings/phone.json, underbots.<agent>. A laterbot installfrom a shell that sets none of them installs the same job. The order is your shell, then that file, then the default; a value in your shell replaces the saved one.bot install --forget KEYdrops one. Itssettings:block names each value the job carries and where it came from. sessionmail's mail-route settings are not saved there: they are sessionmail's own, in itsmail.json, and the block says how to keep each one your shell set.- Every verb reads what
bot installkeeps.bot statusandbot uninstallact on the bot install would pick, and say which on abot:line when your shell names none; every verb and thedc-*tools use the root it keeps for that bot when your shell names none. The bot takes its mail host and carrier from sessionmail'smail.jsonwhen the job carries neither, and names where each came from on its start line. - The files are re-read while the bot runs. An edit to the callers
file, the channels list or
ears.jsontakes effect without a restart. - The bot's mail needs
DC_MESSAGE(sessionmail'smessage, as an absolute path), and, when the bot signs,DC_SIGNER_SOCKandDC_SIGNER_PUB. On the mail drop with no key it needs no signer. See The agent contract.
Every component, file and env var: docs/configuration.md. Every key, with its type and default: docs/settings.md.
Logs
| Log | Where | Follow it |
|---|---|---|
| what the bot did: chats, calls, sends and refusals | ~/.sessionphone/bot.log, or $DC_LOG |
tail -f ~/.sessionphone/bot.log |
| a call, as a conversation | read from the bot's log | dc-log -f, or dc-log-web for a phone page |
| launchd's capture of the bot's output: the same lines, plus why it would not start, and any traceback | ~/.sessionphone/bot.launchd.log (in $SESSIONPHONE_ROOT) |
tail -f ~/.sessionphone/bot.launchd.log |
| each call's transcript, written at hang-up | <root>/calls/<call>/transcript.md |
ls -t ~/.sessionphone/calls for the newest |
| the web call's auth and connection lines, as JSON | the terminal that runs sessionphone web |
it is in the foreground |
- One call's lines:
grep '\[call <id>\]' ~/.sessionphone/bot.log. See Call troubleshooting. - When the bot will not start, read
bot.launchd.logfirst: a refusal at start is written there, and not always tobot.log. - Health:
sessionphone bot status. It names the job (and, when your shell names no bot, which bot and where from), says whether the bot is running (with its pid) and where its log is, and exits 0 only when the bot is running. With the job installed, itshearing:line says, in one line, whether the speech-to-text server at the job'sDC_STT_URLanswers a half-second test clip. The check is capped at 5 s, so a server still loading its model readsnot confirmed, and aNOnames the URL that did not answer and points atsessionphone bot install, which says how to fix it. It never changes the exit code. Itsspeaking:line says whether the bot can speak its replies (the ffmpegbot installlooks for, judged on the job's environment). ANOthere never changes the exit code. Itsundelivered:line counts the mail to the agent waiting in the bot's spool and dead there, call turns included; it never changes the exit code either. - Is a bot running for this root?
dc-liveasks the bot's own pid file,<root>/bot.pid(DC_PID_FILE). The bot writes it once it holds the account: its pid, its start time and command line aspsgives them, and its root.dc-livecounts the bot as running only when that pid is alive andpsstill gives the same start time and command line for it. A dead pid, or one the system has since given to another process, is a stale file: exit 2, and its second stderr line says why. It never searches other processes' command lines, so atail -fof the bot's log, a grep, or another agent's bot is never taken for this one, and the foregroundsessionphone bot runcounts. For a launchd bot, the pidbot statusreads from launchd and the pid inbot.pidare the same process. A bot started before its version wrotebot.pidhas none: restart it. - A live call holds back install and uninstall.
bot installandbot uninstallstop the bot, and a call cannot outlive it. While a call is up they refuse, exit 2, witha call is live; hang up or pass --force, then the call asdc-livenames it. Nothing is written or booted out. Hang up and run it again, or pass--forceto restart the bot anyway: the call drops. A call counts only while the bot that wrotecall-live.jsonstill runs, judged asdc-livejudges it, and only when the call began after that bot started. A file left by a bot stopped mid-call is no call. The files are the ones the installed job's plist names, so a reinstall that changes the root still sees the call on the old one.
Development
make # the targets
make check # ruff, shellcheck, pytest, then the bridge tests (bin/bridge-check)
make browser # the web page in headless Chrome (see above)
make install # sessionphone on the PATH, editable, with the media pack
Scaffolded by newt plugin (package, makefile, release, plugin recipes).
Test on a fresh macOS in a tart VM, not on the machine you develop on.
Status
sessionphone runs today, on macOS. Its version is in pyproject.toml.
Each change writes its changelog entry to changelog.d/, one file each, and
the release moves them into CHANGELOG.md. make changelog prints what
changed since the last release, as the next release will write it under
[Unreleased].
What works on master. uv run pytest -q passes. The tests named below
use fakes: no real account, relay, launchd job or speech server.
- Setup and linking.
setup,qr,linkandinvitemake the agent's Delta Chat profile on a public chatmail relay and link the owner's phone. The tests cover the parts that need no relay: the QR page, the invite, the callers file, and that these verbs run without the media pack (tests/test_account.py,tests/test_bin_media.py).invite --qrand--pngare decoded back to the saved link's QR matrix (tests/test_invite_qr.py).setupandlinkagainst a real relay have no automated test. - The bot as a launchd job.
bot installloads it and checks that it loaded. It refuses, exit 2 and before it writes anything, when the bot would not work: a mail host that is not a mail domain, aDC_STT_URLit can name no model for, or nobody allowed to ring (tests/test_service.py,tests/test_install_loaded.py,tests/test_dc_host.py,tests/test_install_stt.py,tests/test_callers_on_install.py). - Chat. Each message reaches the agent as one signed mail (see "The
agent contract"). The agent answers with
dc-say, and only to chats listed inchannels.txt(tests/test_seatmail.py,tests/test_contract_headers.py,tests/test_channels.py,tests/test_dc_channels.py). - Calls. Only approved callers may ring: a contact that holds the key
pinned to a line of
callers.txt. A call is answered at once, or rings first withDC_ANSWER=ringand falls back to voicemail. What the caller says is mailed to the agent turn by turn. WithDC_NO_REPLY_Sset (off by default), a turn with no reply hears the holding line (tests/bridge/test_callers.py,tests/bridge/test_ringfirst.py,tests/bridge/test_call_turn_log.py,tests/bridge/test_noreply.py). A reply waits for the caller to finish speaking (tests/bridge/test_floor.py). - Voice messages with
dc-send-file --voice-message(tests/test_voicemsg.py). - A browser tab as a call,
sessionphone web(tests/web/). The headless Chrome drive,make browser, is not part ofmake test.
What a voice call needs. A voice call is sessionphone plus a
speech-to-text (STT) server at DC_STT_URL. The bot posts each chunk of the
caller's speech, and each voice note, to that OpenAI-compatible
/v1/audio/transcriptions endpoint. The default is an mlx-audio server,
mlx-audio+http://127.0.0.1:8890/v1/audio/transcriptions, and the bot asks
it for mlx-community/parakeet-tdt-0.6b-v3 (bridge/dc_env.py,
bridge/stt.py). sessionphone does not install that server. Under Session
Kit it is the kit's own speech server, which ears services install loads:
do not install VoiceMode's as well, since a second mlx-audio job leaves one
of the two crash-looping. Standalone, VoiceMode's is the one offered. The bot speaks
through Kokoro on the same mlx-audio server, in the voice DC_VOICE names
(af_sky by default). When that server does not answer, it speaks with
macOS say instead and logs that it did (DC_TTS=auto, the default;
bridge/calls.py). ears is optional. It adds turn previews
(DC_PREVIEWS=1) and the in-process ears leg (DC_EARS=1). Both are off by
default, and without ears a call is still heard and answered.
What does not work, or is not here.
- macOS only.
bot installwrites a launchd job. There is no systemd unit, and thesayfallback is macOS's. - No hearing without an STT server. With nothing at
DC_STT_URL, a call connects but the agent does not hear the caller.sessionphone setupasks that server before the phone is linked, while there is time to install it: its last lines,hearing:, say whether it answered a half-second test clip and, when not, name the install step (under Session Kit,ears services install --only speech; standalone, VoiceMode's). Its exit code does not change.bot installends with the samehearing:check, andbot statusgives it in one line (tests/test_hearing.py,tests/test_stt_before_link.py). - No spoken replies without ffmpeg. When the speech server answers, the
bot turns each spoken reply into call audio with
ffmpeg. With none, every spoken reply fails and goes to the caller as chat text. The bot looks for it inDC_FFMPEG, then on the job'sPATH, then at/opt/homebrew/bin/ffmpegand/usr/local/bin/ffmpeg.bot installends with aspeaking:line that says whether it found one, and when it did not, gives the fix (brew install ffmpeg, thenbot installagain, so the running bot picks it up).bot statussays the same.DC_TTS=applespeaks by macOSsayand needs no ffmpeg (tests/test_speaking.py). - The bot needs sessionmail, and a key only on an MTA. On the mail drop,
sessionmail's default, it sends unsigned and needs no seat step. On an MTA
it must sign: give it a key with
sessionphone bot install --registry DIR("The bot's seat"). Choosing the carrier is sessionmail's. - The Quick start on a fresh Mac is not recorded here. The tests above run on the machine that develops sessionphone. "Development" says to try it on a fresh macOS in a tart VM.
Metadata
Release files for sessionphone 0.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| sessionphone-0.3.1.tar.gz | 445.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sessionphone-0.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 874.3 kB
Release files / sessionphone-0.3.1.tar.gz
| Download URL | sessionphone-0.3.1.tar.gz |
|---|---|
| Size | 445.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
556dfe4d1fa8ac000d27d5afb9d40cafd73bb638a9cf6a9a456582a411f55cc6
|
|
BLAKE2b-256 checksum How to use checksums |
9dba51bbeb0ea0ca85b9e6a19387e1f5fead8aa2e6c981100d5a8f36c7bdcfaf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / sessionphone-0.3.1-py3-none-any.whl
| Download URL | sessionphone-0.3.1-py3-none-any.whl |
|---|---|
| Size | 428.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6193c81df3179941df3ce607e7f55715908ca550d276a91cc99c1825bb4f8569
|
|
BLAKE2b-256 checksum How to use checksums |
e2e709c2e29db1038a4e43797cb4413d50301881dbd5f841c579082c8607a9b7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for sessionphone 0.3.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.