A fractional power turned its base into a double first and refused a base outside the normal range of a double, so (2^1200)^0.5, (2^1024)^0.5, (2^-1200)^0.5 and 1e400^0.5 were refused although each answer is an ordinary double. Such a base is now brought into that range by square roots taken from its exact value in a big.Float, at most three under the 4096-bit limit, with the exponent doubled for each, and only a result that is not a normal double is refused. The tests give these powers and the edges of the range their values, and the bounded-work test covers a base that needs three roots. The README's refusal sentence and example are updated. Model: opus-5-5
simplexcalc
simplexcalc is a Go chat bot by @sneak for the
SimpleX Chat network: it accepts every contact request and answers
arithmetic such as 2 + 2 with the result.
Send it 2 + 2 and it replies 4; send 5 * 5/2 and it replies
12.5. It understands decimal numbers, + - * /, powers written 2^10
or 2**10, remainders written 7 % 3, signs and parentheses, and
computes exactly, so 0.1 + 0.2 is 0.3. Anything else gets a short
explanation instead of a result.
Getting Started
Build the image, write a random API credential onto a named volume, and run the bot with its SimpleX profile on the same volume:
git clone git@git.eeqj.de:sneak/simplexcalc.git
cd simplexcalc
make docker
docker run --rm -v simplexcalc-data:/var/lib/simplexcalc simplexcalc \
sh -c 'umask 077 && od -An -N32 -tx1 /dev/urandom | tr -d " \n" \
>/var/lib/simplexcalc/api-token'
docker run -d --name simplexcalc --restart unless-stopped \
-v simplexcalc-data:/var/lib/simplexcalc \
-p 127.0.0.1:8080:8080 -e API_TOKEN_FILE=/var/lib/simplexcalc/api-token \
simplexcalc
docker logs simplexcalc 2>&1 | grep '"msg":"ready"'
The credential is 32 random bytes written as 64 hexadecimal characters
to /var/lib/simplexcalc/api-token, readable only by the bot's user.
-p 127.0.0.1:8080:8080 makes the API reachable from this host only;
see API.
The ready log line carries the bot's contact address: address is the
short link to share, and full_address is the same address in the long
form that older SimpleX clients need. Open the link in any SimpleX Chat
app, or paste it into its "connect via link" screen; the bot accepts at
once, greets you, and answers every message you send it.
The address is stored on the simplexcalc-data volume. It survives
restarts, image upgrades and recreating the container, and is lost only
with the volume.
Messaging it from a terminal
The image carries the SimpleX Chat command-line client, so a throwaway
second client can talk to the bot without installing anything. Replace
ADDRESS with the bot's address:
docker run --rm -v simplexcalc-tester:/var/lib/simplexcalc simplexcalc \
simplex-chat -d /var/lib/simplexcalc/tester --user-display-name tester \
-e "/c ADDRESS" -t 30 --execute-log all
docker run --rm -v simplexcalc-tester:/var/lib/simplexcalc simplexcalc \
simplex-chat -d /var/lib/simplexcalc/tester \
-e "@calc 5 * 5/2" -t 20 --execute-log messages
docker volume rm simplexcalc-tester
The first command connects and prints the bot's greeting; the second
sends 5 * 5/2 and prints the reply, 12.5.
Configuration
All configuration is environment variables, read once at startup. A
.env file in the working directory is loaded automatically for
development.
A variable that is set to something unparseable aborts startup. It is never quietly replaced by the default. Defaults apply only to variables that are absent.
DATA_DIR— where the bot keeps what it must not lose: the SimpleX database, which holds the bot's profile, its keys, its address and its contacts, andwebhooks.json, which holds the webhooks registered through the API. Default./data; the image sets/var/lib/simplexcalc.DEBUG—trueorfalse, defaultfalse.truelogs every event the chat client sends.PORT— the TCP port the API listens on, on all interfaces: a whole number from 1 to 65535, default8080.API_TOKEN_FILE— path of a file holding the API credential, at least 32 characters not counting the whitespace around them. The file is read once, at startup; one that cannot be read, or holds a shorter credential, aborts startup. Absent, the API still listens but refuses every request, and startup logs a warning saying so.
API
An HTTP API beside the chat client lets another program read the bot's
chats, send messages in them and register webhooks on them. It speaks
JSON on PORT.
Authentication. Every request carries the credential from
API_TOKEN_FILE:
Authorization: Bearer {credential}
A request without it, or with a wrong one, gets 401 with
WWW-Authenticate: Bearer and {"error":"unauthorized"}. No path is
exempt. Without API_TOKEN_FILE, every request gets that answer. The
file is read at startup, so a new credential takes a restart.
Errors are JSON with a short explanation, such as
{"error":"not found"}; what went wrong inside goes to the bot's log.
GET /api/v1/chats
The bot's chats, ordered by id. The bot talks to people only one to
one, so each chat is one of its contacts.
TOKEN=$(docker exec simplexcalc cat /var/lib/simplexcalc/api-token)
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/api/v1/chats
{
"chats": [
{ "id": 3, "display_name": "tester", "contact_deleted": false },
{ "id": 4, "display_name": "tester", "contact_deleted": true }
]
}
id— the chat's number, which is its contact's number in the chat client.display_name— the name the contact gave themselves. Nothing makes it unique.contact_deleted—trueonce the contact has deleted their chat with the bot. The chat stays in the list, but nothing more reaches them.
GET /api/v1/chats/{id}/messages
The latest messages in the chat id, oldest first. count, a whole
number from 1 to 100 and 20 if absent, is how many of the chat's latest
items to read. The chat client also records events in a chat, such as
the contact connecting, and only messages are returned, so fewer than
count can come back.
curl -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:8080/api/v1/chats/3/messages?count=5'
{
"messages": [
{
"id": 9,
"direction": "received",
"type": "text",
"text": "2 + 2",
"time": "2026-09-29T03:14:34Z"
},
{
"id": 10,
"direction": "sent",
"type": "text",
"text": "4",
"time": "2026-09-29T03:14:35.101223457Z"
}
]
}
A message has:
id— its number, unique across all the bot's chats.direction—receivedfrom the contact, orsentby the bot.type—text, or another kind of SimpleX message, such asimage,fileorvoice.text— the text; for a message that is nottext, its caption, which can be empty.time— in RFC 3339, in UTC: for a received message, when it reached the SimpleX relay, to the second; for a sent one, when the bot sent it.
The answer is 400 if count is not a whole number from 1 to 100, and
404 if GET /api/v1/chats does not list id. A query the server
cannot read gets 400 with the query cannot be read; examples are a
query that holds a ;, a % not followed by two hexadecimal digits, or
more than 10,000 parts separated by &.
POST /api/v1/chats/{id}/messages
Sends the text in the body to the chat id, and answers 201 with
the message as sent. The answer comes once the chat client has taken the
message, before it reaches the contact.
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"hello"}' http://127.0.0.1:8080/api/v1/chats/3/messages
{
"message": {
"id": 12,
"direction": "sent",
"type": "text",
"text": "hello",
"time": "2026-09-29T03:14:43.519552587Z"
}
}
Nothing is sent, and the answer is:
400if the body is not JSON of that shape, ortextis empty;404ifGET /api/v1/chatsdoes not listid;409if the contact cannot receive messages: they have deleted their chat with the bot (contact_deletedistrue), or have not finished connecting;413if the body is over 64 KiB, or the text is too long for one SimpleX message, which holds about 15,000 bytes.
POST /api/v1/chats/{id}/webhooks
Registers the url in the body as a webhook on the chat id, and
answers 201 with the webhook. If the chat already has a webhook with
the same url, character for character, the answer is 200 with that
webhook instead.
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"url":"https://example.com/hook"}' \
http://127.0.0.1:8080/api/v1/chats/3/webhooks
{
"id": "5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80",
"chat_id": 3,
"url": "https://example.com/hook"
}
A webhook has:
id— 16 random bytes, written as 32 hexadecimal digits.chat_id— the chat it is registered on.url— the URL, as it was registered.
The bot keeps the webhooks in webhooks.json in DATA_DIR, with mode
0600, and reads that file at startup, so they survive a restart. Without
the file there are none; a file that cannot be read, or that holds
anything but webhooks as the bot writes them, aborts startup, as
configuration that cannot be parsed does. The bot does not post anything
to a webhook yet.
Nothing is registered, and the answer is:
400if the body is not JSON of that shape, orurlis not an absolutehttporhttpsURL with a host, at most 2048 bytes long;404ifGET /api/v1/chatsdoes not listid;413if the body is over 64 KiB;500ifwebhooks.jsoncannot be written.
GET /api/v1/chats/{id}/webhooks
The webhooks registered on the chat id, in the order they were
registered; 404 if GET /api/v1/chats does not list id. A chat's
webhooks stay after its contact deletes the chat (contact_deleted is
true), until they are removed.
curl -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8080/api/v1/chats/3/webhooks
{
"webhooks": [
{
"id": "5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80",
"chat_id": 3,
"url": "https://example.com/hook"
}
]
}
DELETE /api/v1/chats/{id}/webhooks/{webhook_id}
Removes the webhook webhook_id from the chat id, and answers 204
with no body.
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8080/api/v1/chats/3/webhooks/5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80
Nothing is removed, and the answer is:
404ifGET /api/v1/chatsdoes not listid, or the chat has no webhookwebhook_id;500ifwebhooks.jsoncannot be written.
Entrypoints
This repo adheres to the
Scripts to Rule Them All
standard. The script/ entrypoints, each with a thin make shim:
script/bootstrap(make bootstrap) — install build dependencies (git, make, go) idempotently. Linting additionally needs docker; markdown formatting needs docker or a localprettier.script/setup(make setup) —bootstrapplus the git pre-commit hookscript/test(make test) —go testwith the race detector and coverage,-count=1, 90 s timeout; quiet on success, verbose rerun on failurescript/lint(make lint) —golangci-lintvia docker only, against the digest-pinned image, thenscript/assert-step-ranandscript/assert-context-completeover the build log. Nothing is installed locally and nothing runs on the host.script/fmt(make fmt) — format Go withgofmtand everything else withprettier(writes)script/fmt-check(make fmt-check) — the same scope, read-onlyscript/check(make check) —test+lint+fmt-check. What the pre-commit hook runs. Never modifies files.script/docker(make docker) — build the image, tagged withscript/projectnamescript/cibuild(make cibuild) — the CI gate:docker build --progress=plain --no-cache-filter=lint --no-cache-filter=builder .followed by assertions that the lint step and the test step really ran, and that both stages really received the whole repository. The Gitea workflow runs this on every push.script/precommit— the hook body:go mod tidymust not changego.mod/go.sum, thencheckscript/install-precommit(make hooks) — installs the hookscript/projectname— prints the project name; other scripts call it so they can stay identical across reposscript/prettier,script/assert-step-ran,script/assert-context-complete,script/repo-source-manifest— helpers, not entrypoints
make build, make run, make dev, make deps and make clean are
the ordinary conveniences. make run and make dev run the bot on the
host, which needs the simplex-chat command-line client on PATH.
Why the assertions exist
A green docker build is not evidence that the checks ran. On an
unchanged tree every layer comes from cache and the build exits 0 having
executed nothing; BuildKit silently ignores a --no-cache-filter naming
a stage that no longer exists; and a .dockerignore entry can remove a
package from the build context, after which the linter genuinely runs,
genuinely examines what it was handed, and genuinely reports 0 issues.
over a repository with a violation in it.
So the build is not trusted. script/assert-step-ran requires the log
to show the step executing and the tool's own success line coming out of
it. script/assert-context-complete requires the file inventory each
stage emitted to match the git index — an expectation .dockerignore
cannot reach. Read the comments in those two scripts before changing
either; they document what they still cannot see.
Rationale
SimpleX Chat is an end-to-end encrypted messenger with no user identifiers: people connect through addresses they choose to share. simplexcalc is a calculator anyone can reach that way, and a small, complete example of a SimpleX bot in Go: profile and address set-up, automatic acceptance, and replies, with the chat client in the same container.
Design
- One process tree, one container.
simplexcalc runstarts the SimpleX Chat command-line client,simplex-chat, as a child process, with its database under$DATA_DIR/simplexand its WebSocket API on127.0.0.1:5225. That WebSocket has no authentication, which is why it is never exposed outside the container. - The protocol (
internal/simplex) is JSON over that WebSocket: a command carries a correlation id, its response carries the same id, and anything without one is an event. Only the fields the bot reads are decoded, so records that grow new fields in a later client release still decode. - Set-up on every start (
internal/bot): read the bot's profile, create its long-term address if it has none, and set the address to accept every contact request and to greet each new contact. The first start creates the profile itself, a bot profile namedcalc. The address is logged in thereadyline. - The API (
internal/api): an HTTP server, routed with chi, that starts once set-up is done; if it cannot listen, the bot exits with an error, as when the chat client fails. Every request must carry the credential, compared in constant time; every response carries headers that forbid framing, content sniffing, caching and referrers, and aPermissions-Policythat denies the camera, microphone and location; a request body is capped at 64 KiB and a request's work at 10 seconds. Handlers call the chat client on the request's own goroutine, never on the one that delivers events, which also delivers the chat client's answers. When the bot stops, requests in progress get 5 seconds to finish before the chat client is stopped. - Webhooks (
internal/api) are read from$DATA_DIR/webhooks.jsonbefore the chat client starts. Changes are made one at a time, and each rewrites the file whole: the bot writes a temporary file beside it, namedwebhooks.json.and digits, and renames it over the old one. A crash leaves the old file or the new one, never part of one, and at worst a stray temporary file, which the bot ignores. - Replies: for each text message a contact sends in a direct chat, the bot sends back the result, as a reply quoting the message. Group messages, files and the bot's own messages are ignored.
- Arithmetic (
internal/calc): a small parser of its own reads numbers,+ - * / % ^, signs and parentheses, and refuses anything else.go/constantcomputes with exact rationals.^, also written**, is a power: it binds tighter than*,/,%and a sign on its left, and groups to the right, so2^3^2is512,-2^2is-4,(-2)^2is4and2^-1is0.5.%is the remainder and ranks with*and/; its result takes the sign of the divisor, as in Python, so7 % 3is1,-7 % 3is2and7.5 % 2is1.5. A power with a whole exponent is exact, so0.1^2is0.01and2^-1400 * 2^1400is1, unlessgo/constantcould hold the result only rounded; that power, and one with a fractional exponent, is computed as a double, so2^0.5is1.4142135623730951. A negative number to a fractional power is refused, as having no real result. Numbers are read as decimal, so010is ten. Input over 256 bytes is refused, exact powers are capped, and every number is held as a fraction, whole numbers too, under the 4096-bit limit below, so a message cannot make the bot do unbounded work. Whole numbers below 1021 are written exactly. Other results inside the normal range of a double, about 2.2e-308 to 1.8e308 in magnitude, where a double keeps all its digits, are written in the shortest form that reads back as the same double, in exponent notation from 1021 up and below 10-6. Results outside that range are written from their exact value in exponent notation, rounded to 17 significant digits, the most the shortest form of a double takes, with trailing zeros dropped:2^1200is1.7218479456385751e+361,10^400is1e+400and2^-1074is4.9406564584124654e-324. Refused as too large or too small: any number whose numerator or denominator reaches 4096 bits, wherever it appears, asgo/constantrounds a fraction that grows that large (2^4095,1e-1300 + 1); and a power computed as a double whose result is outside the normal range of a double (2^1500.5). The base of such a power may be outside that range: square roots taken from its exact value bring it inside first, so(2^1200)^0.5is4.149515568880993e+180and1e-400^0.5is1e-200. - Failure is an exit. If the chat client exits or the connection to
it drops, the bot exits with an error and the container's restart
policy starts both again.
SIGTERMstops the bot, which stops the chat client withSIGTERMand kills it if it has not exited within 10 seconds. - The image: a Go build stage, then an Ubuntu 22.04 runtime — the
release the chat client is built for, which already has every library
it links — with the
simplex-chatv7.0.2 binary downloaded by anADDwhose checksum BuildKit verifies. It runs as an unprivileged user and is x86_64 only.
Operating it
Backup. Everything durable is on the volume: the SimpleX database,
simplex_chat.db and simplex_agent.db; the API credential,
api-token; and the webhooks, webhooks.json. Stop the container
before copying the database, since a copy taken from under the running
client can be inconsistent. The database holds the bot's keys, so a copy
lets its holder answer as the bot; the credential lets its holder use
the API; and a webhook's URL can hold a secret of the program it points
at. Keep all three as private as the running instance.
Upgrade. Rebuild the image and recreate the container with the same
volume. The chat client migrates its database on start. A newer
simplex-chat release is a change to the URL and the checksum on the
ADD line in the Dockerfile.
TODO
See docs/TODO.md and the repository's issue tracker.