A generic multi-persona Signal bot platform. One Signal account (configurable
display name) joins any number of group chats; each registered group gets its
own service — a persona (member label + system prompt) and its own API keys.
An HTTP server exposes a per-service API (bearer-key auth, wrapped by the
installable sigbot-client wheel) plus a login-protected admin
dashboard where services are registered and keys are minted.
Bots built on the service API live in their own repos — e.g. marketplace-finder-bot, which drives its group through a service registered with reply policy "never". Session handoff state: WORKLOG.md.
CI publishes a multi-arch (amd64/arm64) image to ghcr.io/fughilli/sigbot
that bundles the whole stack: sigbot plus signal-cli-rest-api/signal-cli
(built on the upstream bbernhard image), supervised by one entrypoint.
On any box with docker:
curl -fsSL https://raw.githubusercontent.com/fughilli/sigbot/master/deploy/install.sh | bash(While this repo is private: docker login ghcr.io first, and fetch the
script with an authenticated clone instead of raw curl.)
The script pulls the image, runs commissioning — prompts for the bot
number/name, Anthropic API key, and dashboard admin credentials, then writes
~/.config/sigbot/sigbot.yaml plus a mode-600 .env with the secrets — and installs
the sigbot-d command. Nothing starts until you run:
sigbot-d # start detached (default dir ~/.config/sigbot)
sigbot-d status # …plus stop / logs / passwd (admin password)
sigbot-d /path/to/other-dir # or point any command at another instancesigbot-d runs the container in the background with docker's restart policy;
the data dir holds everything (config, secrets, SQLite DB, signal account
data), so several instances with different data dirs can coexist
(SIGBOT_PORT/SIGNAL_PORT env vars pick the ports). The installer prints
the account-provisioning steps (register a number, or QR-link an existing
account) and the dashboard URL. Re-running it keeps existing config.
With bazelisk set up, the same image CI publishes is a target:
bazel run //sigbot:image_load # loads sigbot:latest into docker
docker run --rm --env-file .env -p 8100:8100 -p 127.0.0.1:8080:8080 \
-v "$PWD/data:/data" -v "$PWD/data/signal-cli:/home/.local/share/signal-cli" \
sigbot:latest(//sigbot:image is the raw OCI layout. The entrypoint supervises the
bundled signal-cli-rest-api and sigbot_server --workdir /data; mount your
data/ dir — with sigbot.yaml inside, signal.api_url: http://127.0.0.1:8080
— at /data, and the signal account data as shown. The admin CLI ships at
/sigbot/admin.) Or skip containers entirely and
bazel run //sigbot:sigbot_server as below.
Bazelisk and Determinate Nix; everything else is hermetic via Bazel. The signal-cli-rest-api service runs either way:
- With docker (home box):
docker compose up -d. - Without docker (e.g. a dev container that can't nest containers):
nix build .#signal-services -o .nix-services— signal-cli from nixpkgs plus signal-cli-rest-api built from source, both pinned byflake.lock— thenscripts/signal_api.py start --mode json-rpc.
scripts/setup_signal.py auto-detects which runtime is present.
bazel test //... # green out of the box
docker compose up -d # OR: nix build .#signal-services -o .nix-services
# && scripts/signal_api.py start --mode json-rpc
bazel run //scripts:setup_signal # bot-account registration, first time only
cp sigbot.example.yaml sigbot.yaml # fill in the bot number
export ANTHROPIC_API_KEY=...
export SIGBOT_ADMIN_PASSWORD=... # or _PASSWORD_HASH (see `admin hash-password`);
# else one is generated + printed once
bazel run //sigbot:sigbot_server -- --workdir "$PWD"--workdir anchors everything that must survive restarts (sigbot.yaml,
data/ incl. the SQLite DB and Signal account data); point it at a
bind-mounted/persistent path.
The server binds one port (default 8100) carrying both surfaces:
-
Dashboard (
/, login required): lists the group chats the bot account has been added to; for each you register a service — persona label, system prompt, reply policy (every message / mention-only / never), optional model override — and mint/revoke API keys (shown once, stored hashed). -
Service API (
/api/v1/*,Authorization: Bearer sb_...): each key is scoped to its service's group — read the message log (GET /api/v1/messages, incremental viaafter_id), post into the group as the bot (POST /api/v1/messages, optionalattachments_b64), react to an incoming message (POST /api/v1/messages/{id}/reactionswith{"emoji": "👀"};DELETEthe same path — no body — to clear it), download incoming attachments (GET /api/v1/attachments/{id}), inspect the persona (GET /api/v1/service).Reactions let a bot acknowledge without adding to the transcript — mark a message 👀 on receipt and ✅ when the work is done, instead of posting two more messages into the group. Signal allows one reaction per author per message, so reacting again replaces rather than accumulates, and clearing needs no emoji: sigbot records the one it placed. Only incoming messages are reactable: Signal addresses a reaction by the target's author and timestamp, which sigbot records on receive but has no equivalent for its own sends (those return
409, as do messages received before sigbot started recording timestamps).
Personas answer in their group using the service's system prompt, with the
group's message history (including messages injected through the API) as
context. Services with reply policy "never" are transport-only: an external
bot process drives the group through the API. Admins and keys can also be
managed headlessly: bazel run //sigbot:admin -- add-admin|set-password| list-services|mint-key|revoke-key (run with the workdir as cwd).
External bots consume the service API two ways:
-
pip:
pip install sigbot-client(published on version tags; the same wheel is attached to the GitHub release), or build it yourself withbazel build //client:sigbot_client_wheel. See client/README.md. -
bazel module: depend on this repo and use
@sigbot//client:sigbot_client_lib:bazel_dep(name = "sigbot", version = "0.0.0") git_override( module_name = "sigbot", remote = "git@github.com:fughilli/sigbot.git", commit = "...", )
bazel test //...— full suite.- Python deps: edit
requirements.in, thenbazel run //:generate_requirements_lock, commit both.