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 the mail system (MTA) that sessionmail uses.
Quick start
sessionphone runs on macOS. It needs uv, and the bot needs a sessionmail
seat (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. "Status" at the end of this page says what works and what does
not.
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 a bare
sessionphone. Once a sessionphone release on PyPI carries the light core, a kit user'ssessionphone bot installrefuses (exit 2) until the kit asks forsessionphone[media]. That change is due in sessionkit, and it should ship with that release.
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.
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.- 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 700 px a side; overwritten if it exists) and prints its absolute path as the last line, forshow-me. 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 signed by the bot's own sessionmail seat. 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, through 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, and 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>@<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 Delta contact's address: the key Delta verified. The display name is only a claim | M1-M3. A tap has no sender |
X-Delta-Chat-Channel |
the channel NAME the chat is listed as in channels.txt |
M1-M4, only when the chat is listed |
Medium: voice, X-Call-Preview |
as before | call mails |
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.
Setting up the bot's seat: message seat init and message seat agent
The bot signs as its own sessionmail seat, never as a Claude session. Set it up once per bot, then install:
- Mint the seat's key:
message seat init --registry DIR <agent>-chat. - Run its key in an ssh-agent under launchd:
message seat agent <agent>-chat SOCKET. - Set the settings below in the shell, then run
sessionphone bot install. EveryDC_*set (and not empty) in that shell is carried into the launchd job, and so are sessionmail's mail-route settings:SESSIONMAIL_SENDMAIL,SESSIONMAIL_ORIGIN,SESSIONMAIL_RELAY_DOMAIN,SESSIONMAIL_HOME,SESSIONMAIL_ROOTandSESSIONMAIL_HOST. Nothing else from that shell is: not the installing seat's ownSESSIONMAIL_*(itsSESSIONMAIL_FROM_NAME,SESSIONMAIL_SUBSCRIBE, pager settings), and not the oldSESSION_MAIL_*spelling. launchd gives the job no other environment.
| Setting | What the child message send gets |
|---|---|
DC_SIGNER_SOCK |
CLAUDE_SESSION_AGENT_SOCK: the seat agent's socket |
DC_SIGNER_PUB |
CLAUDE_SESSION_PUB: a copy of the seat's .pub, 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_ADDR |
TM_AGENT_ADDRESS and EMAIL = its local part, so an EMAIL inherited from whoever ran install never becomes the From |
DC_AGENT_NAME |
SESSIONMAIL_FROM_NAME = <Name>'s Delta Chat bridge |
| (always) | SESSIONMAIL_REQUIRE_SIGNATURE=1. Every CLAUDE_SESSION_* the bot inherited is stripped |
No postfix? Export SESSIONMAIL_SENDMAIL=drop BEFORE bot install.
The bot sends by sessionmail's message send, and sessionmail picks its
carrier from SESSIONMAIL_SENDMAIL: drop writes straight into this host's
mailboxes, with no MTA; anything else names the MTA; unset, it is
/usr/sbin/sendmail. That file is on every Mac, but it delivers nowhere
without a configured postfix, so the call would speak and the caller's turns
would be lost. sessionphone does not choose the carrier; it carries the
setting from the shell that runs install:
export SESSIONMAIL_SENDMAIL=drop
sessionphone bot install
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.
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 signer:
DC_SIGNER_SOCKorDC_SIGNER_PUBis not set, orDC_SIGNER_PUBis not a file.
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.
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 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. The channels alert keeps its
own retry, and the call mails are not spooled (part 2b).
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-01-03T12: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.
- 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-01-03T12: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.- 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 logged, never kept. sessionmail has no duplicate check, so a retried call mail could land twice under one id. The Delta call path makes one try. 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:
# NAME TARGET # note
owner owner@relay.example # owner, joined 2030-01-03
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. sessionphone channelslists it;sessionphone channels add NAME TARGET [--note TEXT]adds a line. 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 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>@<host>) 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 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; without --owner it takes the callers file's owner line. 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, <DC_OUTBOX_PUBLIC's parent, else $DC_ROOT/public>/channels.txt
(chat ids belong to one account, so each profile has its own). 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
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.
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.
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 ~/.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. - 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.
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<agent>@<host>. - 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 never sent:
BRIDGE MAIL FAILED rc=<rc> kind=<kind>: ...; a call mail, not kept
CALL TURN <n> NOT mailed to <box>: rc=<rc> kind=<kind>: ..., taken=no
CALL TURN <n> NOT mailed to <box>: <ExcType>: ..., taken=no
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. The holding line is never mailed.
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 ...
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.
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.
- To is the seat:
--mail-to ADDR, else$DC_MAILTO, else$DC_AGENT@HOST. There is no further fallback. - 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 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 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 start lines name the To, the From, the Reply-To 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 three more 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 this machine's name is not a mail domain. That case refuses even with--mail-to, because the Reply-To is built from it. - No signer, a key of another seat, or a
messagewith no--message-id, as for the bot.
- 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. It does not read the kit's settings
cascade.
| What | Where it lives |
|---|---|
| the root, mode 0700: account, outbox, call dirs | ~/.sessionphone, or $SESSIONPHONE_ROOT; the bot's own can be $DC_ROOT |
| who may ring the bot | <root>/callers.txt, or $DC_CALLERS_FILE |
| where the bot may send | <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 |
| 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.- 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 three settings:
DC_MESSAGE(sessionmail'smessage, as an absolute path),DC_SIGNER_SOCKandDC_SIGNER_PUB. 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, says whether the bot is running (with its pid) and where its log is, and exits 0 only when the bot is running.
Development
make # the targets
make check # ruff, shellcheck, pytest
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, and
what changed since the last release is under [Unreleased] in
CHANGELOG.md.
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 call is answered at once, or
rings first with
DC_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). - 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 VoiceMode's mlx-audio,
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. 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.bot installends with ahearing:line that says so (tests/test_hearing.py). - The bot needs a sessionmail seat and the machine's mail system.
Without its seat the bot refuses to start (exit 2). With no MTA, export
SESSIONMAIL_SENDMAIL=dropbeforebot install, which carries it into the job ("Setting up 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.2.0
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.2.0.tar.gz | 314.9 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sessionphone-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 624.9 kB
Release files / sessionphone-0.2.0.tar.gz
| Download URL | sessionphone-0.2.0.tar.gz |
|---|---|
| Size | 314.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f9ccd3f9ede2b0aa5b11ba1f0e02a4b1cbc34cfb0f388063c4cfebb1508e48c2
|
|
BLAKE2b-256 checksum How to use checksums |
2e2a8544c74dc659fac25f5937961ef1b24d9271b34491263ea5b5d26ba93bbb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.9 {"installer":{"name":"uv","version":"0.11.9","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.2.0-py3-none-any.whl
| Download URL | sessionphone-0.2.0-py3-none-any.whl |
|---|---|
| Size | 310.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5b2d4668c45c8e15ed7f76b36325a5d7b6ff8ea78fa0e437ae40b8f9b0f9f130
|
|
BLAKE2b-256 checksum How to use checksums |
c66eba6c5ab3b3e35752402ac93e68868915c9ab4b9b7e954f39a0a77cc1d78b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.9 {"installer":{"name":"uv","version":"0.11.9","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.2.0
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.