
{"id":2141,"date":"2026-10-03T18:00:00","date_gmt":"2026-10-03T16:00:00","guid":{"rendered":"https:\/\/www.pcjv.de\/?p=2141"},"modified":"2026-10-04T14:58:50","modified_gmt":"2026-10-04T12:58:50","slug":"go-signal","status":"publish","type":"post","link":"https:\/\/www.pcjv.de\/?p=2141","title":{"rendered":"go-signal: Signal on the command line, in pure Go"},"content":{"rendered":"\n\n<p class=\"wp-block-paragraph\">I wanted to send and read Signal messages from a script, and let an AI agent do the same, without running a Java runtime next to it. <a href=\"https:\/\/github.com\/AsamK\/signal-cli\" target=\"_blank\" rel=\"noopener\">signal-cli<\/a> is the established tool, but it needs a JVM. So on 25 September I started <a href=\"https:\/\/github.com\/CWBudde\/go-signal\" target=\"_blank\" rel=\"noopener\">go-signal<\/a>, a Signal client for the command line written in Go. It ships as a single static binary for Linux, macOS and Windows, and since the second release it contains no Rust or C either.<\/p>\n\n\n<!--more-->\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img loading=\"lazy\" decoding=\"async\" width=\"506\" height=\"512\" src=\"\/wordpress\/wp-content\/uploads\/go-signal-gopher.png\" alt=\"The Go gopher in a go-signal hoodie, holding a laptop and a phone with Signal\" class=\"wp-image-2140\" style=\"width:240px\" srcset=\"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/go-signal-gopher.png 506w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/go-signal-gopher-296x300.png 296w\" sizes=\"auto, (max-width: 506px) 100vw, 506px\" \/><\/figure>\n<\/div>\n\n\n<h2 class=\"wp-block-heading\">What it looks like<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">go-signal runs as a linked device next to your phone, like Signal Desktop. You link it once by scanning a QR code; afterwards contacts and groups sync, and it can send and receive on its own:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>go-signal link --name laptop          # scan the QR code on your phone\ngo-signal send +491234567890 -m \"Hello from go-signal\"\ngo-signal send self -m \"Note to self\" --attach notes.pdf\ngo-signal receive --follow -o json    # one JSON document per event\ngo-signal groups create \"Weekend\" --member @alice.42<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Besides text and attachments it handles replies, mentions, edits, reactions, remote deletes, read receipts, stickers, polls and pinned messages. It lists contacts and identities, with trust on first use: if someone&#8217;s safety number changes, sending to them stops until you run <code>identities trust<\/code>. Over the last few days the group commands caught up with the phone app. You can create, rename and update groups and change avatars, admins, bans and invite links, and you can join, accept or cancel your own requests. The commands are noun\u2013verb (<code>groups ban<\/code>, <code>profile update<\/code>) with plain and JSON output and documented exit codes. Exit code 3, for example, means the phone has unlinked the device. It is deliberately not a drop-in replacement for signal-cli; I use signal-cli&#8217;s source as the reference for protocol behaviour, not for the interface.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Signal for agents<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The part I use most is <code>go-signal mcp serve<\/code>. It makes the account available to AI agents such as Claude Code over the <a href=\"https:\/\/modelcontextprotocol.io\" target=\"_blank\" rel=\"noopener\">Model Context Protocol<\/a>. While the server runs, it receives into an inbox, so the agent can look up contacts and groups, list messages, wait for new ones and fetch attachments.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># read-only: the agent can read but not send\nclaude mcp add signal -- go-signal mcp serve --read-only\n\n# may message one person and one group, attachments only from ~\/signal-out\nclaude mcp add signal -- go-signal mcp serve \\\n  --allow-recipient +491234567890 --allow-recipient group:&lt;id&gt; \\\n  --attach-dir ~\/signal-out<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Giving an agent your messenger needs some care, so the defaults are strict. The tools that send reject every recipient unless you allow them with <code>--allow-recipient<\/code> (<code>'*'<\/code> allows everyone). <code>--read-only<\/code> drops those tools completely, and <code>--confirm<\/code> has the client ask before every send. The server also tells the agent that message content comes from other people and is data, not instructions. <code>--on-message<\/code> starts a program when a message arrives in a chat you choose, and <code>mcp doctor<\/code> checks the setup. For scripts that aren&#8217;t agents there is <code>daemon serve<\/code>, a local HTTP JSON API with a server-sent event stream and a bearer token.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The first night<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">go-signal did not have to implement the Signal protocol from scratch. The <a href=\"https:\/\/github.com\/mautrix\/signal\" target=\"_blank\" rel=\"noopener\">mautrix-signal<\/a> Matrix bridge contains <code>signalmeow<\/code>, a Signal client library in Go. go-signal wraps it behind its own small facade, so changes upstream stay in one package and the tests can use an in-memory fake client. That made fast progress possible. The first commit was on Friday, 25 September, at 21:10. By 1:30 that night, linking, multiple accounts, sending, receiving, attachments, reactions and receipts worked. The next morning contacts, groups, identities and the first version of the MCP server followed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There was one catch. signalmeow does its cryptography through <code>libsignalgo<\/code>, cgo bindings to Signal&#8217;s own <a href=\"https:\/\/github.com\/signalapp\/libsignal\" target=\"_blank\" rel=\"noopener\">libsignal<\/a>, which is written in Rust. Every build therefore needed a Rust toolchain, a C compiler and a static library pinned to exactly the version the bindings expect. Cross-compiling for six targets turns that into a chore. It also undoes the main advantage of a Go tool over a Java one.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Going pure Go<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Earlier this year, <a href=\"https:\/\/github.com\/GoCodeAlone\/libsignal-go\" target=\"_blank\" rel=\"noopener\">GoCodeAlone\/libsignal-go<\/a> appeared, a pure-Go reimplementation of the libsignal protocol core. It covers PQXDH sessions, the double ratchet with the post-quantum SPQR extension, sender keys and sealed sender, and it is checked byte for byte against the Rust implementation. A real client needs more than that, though. The groups run on zero-knowledge credentials (zkgroup), and contact discovery talks to an SGX enclave, which first has to prove what code it runs. In <a href=\"https:\/\/github.com\/CWBudde\/libsignal-go\" target=\"_blank\" rel=\"noopener\">my fork<\/a> I added those parts over two days, about 35,000 lines:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>zkgroup: profile key and UID encryption, the poksho proofs, generic zkcredentials, group and profile credentials, and group send endorsements<\/li>\n\n\n\n<li>Noise NK and NKhfs handshakes, SGX DCAP remote attestation (quotes, PCK certificate chains, CRLs, TCB policy) and the CDSI client state for contact discovery<\/li>\n\n\n\n<li>the HSM enclave client and device transfer<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The other half is a shim in a <a href=\"https:\/\/github.com\/CWBudde\/mautrix-signal\" target=\"_blank\" rel=\"noopener\">fork of mautrix-signal<\/a>. Behind a build tag, <code>libsignalgo<\/code> provides the same API backed by libsignal-go instead of Rust. That is about 11,500 lines, and none of its functions returns &#8220;not implemented&#8221;. Differential tests run both backends side by side. Both run on every pull request, every parser in the fork is fuzzed, and an opt-in live suite talks to the real Signal servers with a dedicated test account. On 28 September I made the pure-Go backend the default. Since v0.1.0 every release has been a static binary of about 30 MB (10\u201311 MB compressed). Building it needs nothing but Go:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>go install -tags libsignal_go github.com\/cwbudde\/go-signal@latest<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Two bugs worth telling<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Before the switch I had the crypto code reviewed for constant-time behaviour. The most interesting finding wasn&#8217;t in the cryptography at all, it was in the build tag. The pure-Go backend was first selected with <code>-tags purego<\/code>. Go&#8217;s standard library also honours that tag: it is how you ask for the portable generic code instead of the assembly. So the tag quietly replaced the hardware AES, which is constant-time on CPUs with AES-NI or the ARM AES instructions, with a table-based AES whose lookups depend on secret data. All the tests still passed. The tag is now called <code>libsignal_go<\/code>, a build with the old tag fails with a message pointing to the docs, and CI checks that every release target links the AES assembly. On CPUs without AES instructions go-signal logs a warning at startup, and <code>mcp doctor<\/code> reports it too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second bug was in the original cgo bindings, and the pure-Go backend exposed it. Three calls passed <code>time.Now().Unix()<\/code>, in seconds, to libsignal, which expects milliseconds. Unacknowledged sessions were therefore stored as created in January 1970. The Rust backend never considered any of them stale, the pure-Go one considered all of them stale, and switching backends would have restarted every such session. Both builds now pass milliseconds, and a test checks the stored time in each.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Ten days, three releases<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">v0.1.0 (29 September) shipped the pure-Go binaries, the MCP server, release automation with checksums and build provenance, man pages, shell completions, a container image and a systemd timer. The timer matters because Signal removes a linked device that hasn&#8217;t connected for about 30 days. v0.2.0 (30 September) added the daemon API, stickers, polls, pins, profile updates and group creation. v0.3.0, which wraps up this weekend, is mostly about groups. It adds settings, avatars, admins, bans and invite links, and joining through a link, accepting invitations and cancelling requests. It also brings mentions rendered as <code>@Name<\/code>, and outgoing messages now inherit each chat&#8217;s disappearing-message timer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I built it together with Claude Code. The larger features went through a written design and plan first; both are in the repository under <code>docs\/<\/code>. Whenever a feature needed something new in signalmeow, it went into the fork first and was reviewed and tagged there, and go-signal only then pinned the new version. That is slower than patching with a <code>replace<\/code> directive, but each dependency change can be traced to a release.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Trying it<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">On Linux and macOS the install script fetches the latest release, checks it against <code>SHA256SUMS<\/code> and puts the binary into <code>~\/.local\/bin<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -fsSL https:\/\/github.com\/cwbudde\/go-signal\/releases\/latest\/download\/install.sh | sh\ngo-signal link --name laptop<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Windows zips, the container image <code>ghcr.io\/cwbudde\/go-signal<\/code> and the documentation are on <a href=\"https:\/\/github.com\/CWBudde\/go-signal\" target=\"_blank\" rel=\"noopener\">GitHub<\/a>. The project is young: much of the newer group functionality is tested offline but still waits for its check against the live servers. It is licensed under the AGPL-3.0, like signalmeow, and is not affiliated with Signal.<\/p>\n\n\n","protected":false},"excerpt":{"rendered":"<p>I wanted to send and read Signal messages from a script, and let an AI agent do the same, without running a Java runtime next to it. signal-cli is the established tool, but it needs a JVM. So on 25 September I started go-signal, a Signal client for the command line written in Go. It [&hellip;]<\/p>\n","protected":false},"author":0,"featured_media":2167,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[41],"tags":[],"class_list":["post-2141","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-go"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.6 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>go-signal: Signal on the command line, in pure Go - Christian&#039;s private site<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/www.pcjv.de\/?p=2141\" \/>\n<meta name=\"twitter:label1\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data1\" content=\"7 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2141#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2141\"},\"author\":{\"name\":\"\",\"@id\":\"\"},\"headline\":\"go-signal: Signal on the command line, in pure Go\",\"datePublished\":\"2026-10-03T16:00:00+00:00\",\"dateModified\":\"2026-10-04T12:58:50+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2141\"},\"wordCount\":1263,\"image\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2141#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.pcjv.de\\\/wordpress\\\/wp-content\\\/uploads\\\/teaser-go-signal.jpg\",\"articleSection\":[\"Go\"],\"inLanguage\":\"en-US\"},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2141\",\"url\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2141\",\"name\":\"go-signal: Signal on the command line, in pure Go - Christian&#039;s private site\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2141#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2141#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.pcjv.de\\\/wordpress\\\/wp-content\\\/uploads\\\/teaser-go-signal.jpg\",\"datePublished\":\"2026-10-03T16:00:00+00:00\",\"dateModified\":\"2026-10-04T12:58:50+00:00\",\"author\":{\"@id\":\"\"},\"breadcrumb\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2141#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/www.pcjv.de\\\/?p=2141\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2141#primaryimage\",\"url\":\"https:\\\/\\\/www.pcjv.de\\\/wordpress\\\/wp-content\\\/uploads\\\/teaser-go-signal.jpg\",\"contentUrl\":\"https:\\\/\\\/www.pcjv.de\\\/wordpress\\\/wp-content\\\/uploads\\\/teaser-go-signal.jpg\",\"width\":480,\"height\":360},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2141#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/www.pcjv.de\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"go-signal: Signal on the command line, in pure Go\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/#website\",\"url\":\"https:\\\/\\\/www.pcjv.de\\\/\",\"name\":\"Christian&#039;s private site\",\"description\":\"Private site of Christian-W. Budde\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/www.pcjv.de\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"go-signal: Signal on the command line, in pure Go - Christian&#039;s private site","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/www.pcjv.de\/?p=2141","twitter_misc":{"Est. reading time":"7 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/www.pcjv.de\/?p=2141#article","isPartOf":{"@id":"https:\/\/www.pcjv.de\/?p=2141"},"author":{"name":"","@id":""},"headline":"go-signal: Signal on the command line, in pure Go","datePublished":"2026-10-03T16:00:00+00:00","dateModified":"2026-10-04T12:58:50+00:00","mainEntityOfPage":{"@id":"https:\/\/www.pcjv.de\/?p=2141"},"wordCount":1263,"image":{"@id":"https:\/\/www.pcjv.de\/?p=2141#primaryimage"},"thumbnailUrl":"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/teaser-go-signal.jpg","articleSection":["Go"],"inLanguage":"en-US"},{"@type":"WebPage","@id":"https:\/\/www.pcjv.de\/?p=2141","url":"https:\/\/www.pcjv.de\/?p=2141","name":"go-signal: Signal on the command line, in pure Go - Christian&#039;s private site","isPartOf":{"@id":"https:\/\/www.pcjv.de\/#website"},"primaryImageOfPage":{"@id":"https:\/\/www.pcjv.de\/?p=2141#primaryimage"},"image":{"@id":"https:\/\/www.pcjv.de\/?p=2141#primaryimage"},"thumbnailUrl":"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/teaser-go-signal.jpg","datePublished":"2026-10-03T16:00:00+00:00","dateModified":"2026-10-04T12:58:50+00:00","author":{"@id":""},"breadcrumb":{"@id":"https:\/\/www.pcjv.de\/?p=2141#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/www.pcjv.de\/?p=2141"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.pcjv.de\/?p=2141#primaryimage","url":"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/teaser-go-signal.jpg","contentUrl":"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/teaser-go-signal.jpg","width":480,"height":360},{"@type":"BreadcrumbList","@id":"https:\/\/www.pcjv.de\/?p=2141#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/www.pcjv.de\/"},{"@type":"ListItem","position":2,"name":"go-signal: Signal on the command line, in pure Go"}]},{"@type":"WebSite","@id":"https:\/\/www.pcjv.de\/#website","url":"https:\/\/www.pcjv.de\/","name":"Christian&#039;s private site","description":"Private site of Christian-W. Budde","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/www.pcjv.de\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"}]}},"_links":{"self":[{"href":"https:\/\/www.pcjv.de\/index.php?rest_route=\/wp\/v2\/posts\/2141","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.pcjv.de\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.pcjv.de\/index.php?rest_route=\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/www.pcjv.de\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=2141"}],"version-history":[{"count":1,"href":"https:\/\/www.pcjv.de\/index.php?rest_route=\/wp\/v2\/posts\/2141\/revisions"}],"predecessor-version":[{"id":2142,"href":"https:\/\/www.pcjv.de\/index.php?rest_route=\/wp\/v2\/posts\/2141\/revisions\/2142"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.pcjv.de\/index.php?rest_route=\/wp\/v2\/media\/2167"}],"wp:attachment":[{"href":"https:\/\/www.pcjv.de\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=2141"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.pcjv.de\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=2141"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.pcjv.de\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=2141"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}