Bridge protocol, version 1
This protocol implements ADR-0002. The daemon
starts a separate bridge process. The bridge reads daemon messages on stdin and
writes its messages on stdout. Each line is one UTF-8 JSON-RPC 2.0 object.
Stdout is reserved for protocol messages; diagnostics go to stderr. The daemon
owns the channel bindings, sender allowlist, event log, files and approvals.
The Signal adapter runs as faztudo bridge signal in a separate process from
the daemon. It starts the user-installed signal-cli as another process. The
adapter does not link signal-cli code into faztudo.
The adapter reads [bridges.signal] from config.toml:
[bridges.signal]enabled = falseaccount = "+15551234567"signal_cli = "signal-cli"allow = ["+15551234567"]approvals = falsealerts = "self"
[bridges.signal.bind]self = "general"bind values are existing channel names or IDs. An enabled bridge requires
an account, signal-cli command, at least one allowed sender, and at least one
binding. alerts must name a bound conversation. doctor checks that
signal-cli and Java are on the path and checks for linked account files when
Signal is enabled. Install signal-cli and Java yourself, then run
faztudo bridge signal link in a terminal. Scan the QR code with Signal’s
Linked Devices screen. The command also prints the pairing URI and, after
linking, the account number or account ID. Set that value as account, set
allow to the Signal sender IDs or numbers allowed to post, then enable the
bridge. Account keys stay in $XDG_DATA_HOME/faztudo/bridges/signal (or
~/.local/share/faztudo/bridges/signal if XDG_DATA_HOME is unset), created
with private directory permissions. Keep that directory private.
The adapter uses signal-cli JSON-RPC on a second pair of stdio pipes. It
starts signal-cli with --config <dir> -a <account> --trust-new-identities on-first-use jsonRpc --receive-mode on-start.
signal-cli downloads inbound attachments into its data directory; the adapter
reads those bytes, enforces the configured upload limit, and sends base64 data
to the daemon. For outbound files it writes temporary private files and passes
their paths to signal-cli, then removes them. The daemon checks bindings and
the sender allowlist again after the adapter’s early filter.
The bridge calls hello first:
{"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocol":"1","bridge":"signal","version":"1","capabilities":["attachments"]}}The result contains bindings, a map from conversation key (self,
group:<id>, or direct:<id>) to channel ID, and allowed_senders, an array
of Signal sender IDs or numbers. These fields are information for the bridge;
the daemon checks them again on every inbound delivery.
inbound.message posts one message. Its params are:
{"conversation":{"kind":"group","id":"signal-group-id"},"sender":{"id":"signal-sender-id","number":"+15551234567"},"sent_at_ms":1760810400000,"external_id":"signal-message-id","body":"@coder check this","quote":null,"attachments":[{"name":"note.txt","content_type":"text/plain","data_b64":"aGkK"}]}conversation.kind is group, self, or direct. quote is null or
{"external_id":"...","author_id":"..."}. Attachments are optional;
their names are cleaned and their content types are sniffed from the decoded
bytes. Each file is capped by server.max_upload_mb (25 MB by default). The
body is capped at 1 MiB, the same limit as the HTTP message API; a longer
body fails with too_large. The
result is {"message_id":"...","duplicate":false}. A repeated delivery
uses the key signal:<sender.id>:<external_id>, returns the original ID with
duplicate:true, and starts no second agent run. Validation failures use a
JSON-RPC error with code:-32000, a plain message, and data set to one of
not_allowed, unbound, too_large, or bad_request.
The bridge sends a status notification, with no id, when its state changes:
{"jsonrpc":"2.0","method":"status","params":{"state":"connected","detail":"..."}}.
Other states are linking, disconnected, and error.
The daemon sends outbound.send with conversation, text, optional
quote_external_id, attachments, and ref containing either message_id
or approval_id. The bridge replies with {"external_id":"..."}. The
daemon stores that mapping and advances its outbound event cursor after the
reply. A newly enabled mirror starts at the current event, not old history.
The daemon never echoes a message whose event actor is bridge:signal.
If one outbound.send fails (an error reply, a timeout, a missing
attachment), the daemon retries a transport failure a few times, then skips
that event, posts a notice in the channel, and carries on with the next one.
A bridge must keep reading stdin while it writes stdout. The daemon never lets its reader wait on its writer, but a bridge that stops reading stdin while it blocks on stdout can still stall itself once the pipe buffer is full. Read stdin on its own thread or task.
On shutdown the daemon sends a shutdown notification, waits five seconds,
then kills the bridge process group.
Configured mirroring needs no per-message approval. An agent request to send
outward still needs human approval. Signal approval replies will only support
keep and discard; merge remains a web action. The daemon’s approval
method enforces this even if a bridge forwards merge.