JumpLink

Curlew

Community JumpLink
Updated

Postbote — mail, contacts and calendar for GNOME, as a CLI and an MCP server. Runs on GJS via gjsify.

Curlew

Formerly postbote. The GitHub repository is JumpLink/curlew (old URLs redirect). An existing ~/.local/share/postbote / ~/.config/postbote keeps being used as it is, and the old POSTBOTE_* variables still work.

Your GNOME mail, contacts and calendar — on the command line, and as anMCP server so an AI assistant can search yourmailbox for you.

Curlew reads the accounts you already configured in GNOME Settings → OnlineAccounts. There is nothing to log into and no password to store: credentialscome from GNOME Online Accounts at runtime and are never written to disk, neverlogged, and never returned by any command.

It runs on GJS (GNOME's JavaScript runtime) viagjsify — the same stack a GNOME desktop appis built on, which is where this is headed.

Status: early. The CLI and the MCP server work; the desktop app does notexist yet.

What it does

  • Mail (IMAP) — search across folders by sender, recipient, subject, daterange and full text; read a message; list its parts; save an attachment.

  • Contacts and calendar — read the address books and calendars thatEvolution Data Server keeps in sync for your online accounts.

  • Local index — an optional SQLite full-text index so repeated searches areinstant and work offline. Indexing ~1200 messages takes about half a minute;searching them afterwards takes under a second.

  • Telegram (optional, off by default) — your direct chats, groups andchannels in the same conversation view as mail, through Telegram's officialAPI (mtcute). See Telegram.

  • WhatsApp (optional, off by default, unofficial — risks your account) —your chats as a linked device through Baileys.See WhatsApp.

  • Signal (optional, off by default, not an official client) — yourchats as a linked device through libsignal,the library Signal's own apps are built on. See Signal.

  • XMPP / Jabber (optional, off by default) — direct chats with your rosterand the rooms you joined, read from the server's message archive (MAM) throughxmpp.js. See XMPP.

  • Matrix (optional, off by default) — the rooms you have joined, end-to-endencrypted ones included, throughmatrix-js-sdk and its Rustcrypto compiled to WebAssembly. See Matrix.

Everything is read-only. Messages are fetched with IMAP BODY.PEEK, soopening a mail through Curlew never marks it as read. Telegram is read thesame way: nothing is sent, edited, deleted or marked read. WhatsApp too: nomessage, no read receipt, no online presence (see WhatsApp for theone acknowledgement every linked device sends). XMPP likewise — Curlew neversends a presence, so contacts do not see it online and your offline messagesstay queued for your real clients. Matrix too: no presence, no readreceipt, no typing notice, no message, and no invitation is accepted. Signaltoo: no message, no receipt, no typing notice (see Signal for theacknowledgement and the two requests at link time).

Requirements

  • GNOME Online Accounts + Evolution Data Server (Fedora:gnome-online-accounts, evolution-data-server), and libgda-sqlite for theindex
  • A running user session D-Bus — the GOA and EDS daemons are reached over it, soa bare SSH session without one will report the backend as unavailable
  • GNOME accounts, contacts and calendar run on Node/Bun too (gi:// via @gjsify/node-gi);that is groundwork for a macOS/Windows port, not a supported target yet. The IMAP mailtransport is GJS-only, since it speaks IMAP over Gio TLS sockets. Without the GOA/EDStypelibs Curlew still starts: only the calls that need them fail, with a clear message.
  • An Email (IMAP/SMTP) account in GNOME Settings. Nextcloud/ownCloud accountsexpose files, calendar and contacts but no mail.
  • Implicit TLS (port 993). STARTTLS on port 143 is not implemented yet.

Install and run

gjsify install
gjsify workspace curlew-cli build
gjsify run app/dist/curlew.gjs.mjs accounts

The bundle resolves its native addon (libsignal, for the Signal backend) by theabsolute path it was built at, so do not move or copy a built tree — the copyfails at the first Signal command. curlew-cli test:relocation measures thisand says so out loud; the fix is tracked in gjsify.

Setup

curlew setup walks you through the whole thing — linking Signal and WhatsApp,accepting their terms, building the index, running the receiving daemon andinstalling its systemd user unit — one confirmed stage at a time:

curlew setup

It finds the checkout it is run from, and falls back to a published curlew onPATH when there is no tree. curlew setup --status reports what is done andwhat is left without changing anything, and curlew setup --only <stage>runs the named stages and nothing else — --status prints the name of everystage. --only may be repeated (--only terms --only link-signal) to pick morethan one.

Re-run it whenever: a stage that is already done says so instead of failing.

The QR code and every pairing code it prints stay in that terminal. curlewcalls the same account-adding command you would call by hand, with the sameprompter, and neither copies, captures, logs nor stores a pairing payload. Theterms are displayed before you are asked to accept them, and nothing accepts themfor you.

Use it

curlew check                              # which backends are reachable
curlew accounts                           # which online accounts are available
curlew folders                            # mailboxes, with their roles

curlew search "energieberater" --since 2025-01-01
curlew search --from berater --all-folders --limit 20
curlew message <uid> --account <id>       # one message: body + attachment list
curlew parts <uid> --account <id>         # what is attached, and how big
curlew save <uid> --account <id>          # write the attachment to disk

curlew sync                               # build the local index
curlew index status                       # what it holds, and how fresh
curlew index search "wärmepumpe"          # offline, no server contact

curlew daemon                             # receive Signal/WhatsApp until stopped

curlew conversations list --people-only   # threads with a person in them, newest first
curlew conversations show <id>            # its messages; bodies only with --bodies
curlew conversations classify <address> automated   # correct one sender (auto = undo)

curlew backends list                      # message backends, and which are enabled

curlew contacts --query maier
curlew calendar --from 2026-09-01 --to 2026-09-30

Every command prints JSON — the same shapes the MCP tools return.

search returns headers only, never bodies; reading one message is a separate,explicit call, and getting an attachment's bytes a third. That is not a policyyou can flip with a flag — the search path contains no code that can fetch abody.

--since and --before filter the message Date header, not its arrivaltime. After a mailbox migration every message's arrival timestamp is themigration date, which makes an arrival filter useless; --received-since isthere when you genuinely mean arrival.

Search folds diacritics, so marz finds März. (ß is a letter rather than adiacritic, so grusse does not find Grüße.)

sync also groups mail into conversations by Message-ID, In-Reply-To andReferences, and classifies each one: conversational (a known contact, or athread you replied in) or automated (List-Id, List-Unsubscribe,Auto-Submitted, Precedence, no-reply senders). A stranger nobody replied tois held back until you reply or classify the sender. This is the groundwork forchat backends (ADR 0001): eachbackend is enabled explicitly in the config, and one with a terms notice onlyafter curlew backends enable <name> --accept-terms.

Telegram

Telegram requires every third-party client to use API credentials of its ownuser. Curlew ships none, so the first step is yours:

  1. Create an app for yourself at https://my.telegram.org → API developmenttools. You get an api_id (a number) and an api_hash (32 hex characters).

  2. Enable the backend (this shows Telegram's terms once) and log in. The loginasks for the api_id and api_hash first (the hash without echo), then thephone number, the login code and the 2FA password if one is set:

    curlew backends enable telegram --accept-terms
    curlew accounts add telegram
    curlew accounts list --backend telegram
    curlew sync                      # mail and Telegram into one index
    curlew conversations list --people-only
    

    The api_id/api_hash are kept in the account's session file, not in theconfig (which is plain text in every backup; a config that carries them isrefused). To keep them in a password manager instead, setCURLEW_TELEGRAM_API_ID and CURLEW_TELEGRAM_API_HASH; the environmentwins over the stored pair and the login does not ask.

The first sync takes the newest 200 messages of every chat; later syncs walkforward from there, at most 5 000 messages per run (the rest follows on thenext). A contact whose phone number is in your address book becomes the sameperson as their mail address. Channels and bots are classified automated.

A message deleted on Telegram stays in the index until a full scan:curlew sync --full-scan re-reads each chat's newest window and removes everystored message in it that Telegram no longer has, and every chat that left yourlist. (Telegram reports deletions only as live updates, which a sync without adaemon does not receive.)

The login leaves a session file at$XDG_DATA_HOME/curlew/secrets/telegram/telegram-<user id>.db (mode 0600in a 0700 directory; override the base with CURLEW_SECRETS_DIR). Whoeverholds it can read your Telegram account (it also holds your api_id/api_hash):back it up like a password, never share it. A login killed halfway leaves alogin-*.pending.db there; the next accounts add or account listing removes itonce it is 15 minutes old. Telegram lists it under Settings → Devices as curlew, where you canend it; deleting the file ends it on this machine.

WhatsApp

Read this first. WhatsApp has no API for reading your own chats. Curlewuses Baileys, an unofficialreimplementation of the WhatsApp Web protocol. Using it violates WhatsApp'sTerms of Service, and WhatsApp bans accounts it sees using unofficialclients — temporarily or for good. The ban hits your phone number. Enablethis only if you accept that risk for that number.

Curlew joins your WhatsApp as a linked device, like WhatsApp Web:

curlew backends enable whatsapp --accept-terms   # shows the notice above once
curlew accounts add whatsapp                     # QR code, or a pairing code
curlew sync                                      # right away — see below
curlew conversations list --people-only

accounts add whatsapp asks for a phone number. Leave it empty and a QR codeappears in the terminal: on the phone, WhatsApp → Settings → Linked devices →Link a device, and scan it (a new code appears every ~20 s). Or type the number(international, +49…) and enter the 8-character pairing code it prints underLink a device → Link with phone number instead. The device shows up in thatlist as a browser session (Baileys' default, Chrome (Mac OS)); unlink it thereto end it.

WhatsApp keeps no archive. A message is gone from WhatsApp's servers once adevice has received it, so what curlew stores is the only copy it has —its part of the index is irreplaceable, not a cache. Consequences:

  • Run curlew sync right after linking. The phone hands the recenthistory (roughly the last months) to a new device once. sync connects,receives that history and everything queued while no device of curlew wasconnected, writes it, and disconnects once WhatsApp has nothing more to handover. If that does not happen within ten minutes, the run stops there and thatis not an error: everything received is written and the run counts as asuccess. The only sign in the output is that account's caughtUp: false(error stays null), and whatever WhatsApp still had queued arrives in thenext sync. Set backends.whatsapp.settings.fullHistory: true in the configbefore linking to ask for the full history instead — larger, slower.
  • Stay connected — the receiving daemon. WhatsApp unlinksa device that has not connected for about 14 days; after that, sync reportsthe logout and you link again (the conversations stay, under the same accountid). curlew daemon holds the connection, so that clock never runs out; async from a timer is the fallback for a machine where the daemon does notrun.
  • Back up the index ($XDG_DATA_HOME/curlew/index.db) like the config:with WhatsApp enabled it holds messages that exist nowhere else.

Deletions and edits are applied as they arrive: a message the sender deletedfor everyone, or you deleted or cleared on your phone, is removed from the index;an edited one gets the new text. Contacts are linked by phone number to youraddress book, like Telegram's.

What curlew sends: nothing you could see. It connects withmarkOnlineOnConnect: false, so it announces itself unavailable (never online)and your phone keeps its notifications; it never sends a read receipt (theblue ticks stay yours). It does acknowledge each delivered message — the greydouble tick every linked device sends, and the signal for WhatsApp to forget themessage.

The link leaves a session file at$XDG_DATA_HOME/curlew/secrets/whatsapp/whatsapp-<LID>.db (mode 0600): thedevice's Signal keys. Whoever holds it can read your incoming WhatsApp messages —back it up like a password, never share it. The account id is your LID,WhatsApp's privacy id, never your phone number.

Signal

Read this first. Signal offers no API and does not license third-partyclients. Curlew is not an official Signal client and Signal does notsupport it. It uses libsignal, Signal's own library, and links like SignalDesktop. Independent clients of this kind (signal-cli, Flare, Whisperfish) areused without known account bans, but Signal could block them at any time.

Curlew joins your Signal account as a linked device, like Signal Desktop:

curlew backends enable signal --accept-terms   # shows the notice above once
curlew accounts add signal                     # prints a QR code
curlew sync                                    # right away — see below
curlew conversations list --people-only

Linking, step by step:

  1. Run curlew accounts add signal. A QR code appears in the terminal.
  2. On the phone: Signal → Settings → Linked devices → Link new device (the +),and scan the code. If the terminal prints a fresh code, scan that one: Signalreplaces the connection behind a code after a while.
  3. The phone asks you to confirm linking a device named curlew (setbackends.signal.settings.deviceName in the config to change it). Confirm.
  4. The terminal says Linked. The phone now lists curlew under Linkeddevices; unlink it there to end it.
  5. Run curlew sync.

Signal runs on linux-x64 and macOS arm64 (libsignal is a native addon thatcurlew loads through gjsify's N-API host). On another platform the rest ofcurlew works; accounts add signal says libsignal did not load.

Signal keeps no archive. The server holds a message for a device only untilthat device receives it, so what curlew stores is the only copy — its partof the index is irreplaceable, not a cache. Consequences:

  • Curlew gets no history. A linked device receives what arrives after itwas linked; the phone's older messages stay on the phone.
  • Stay connected — the receiving daemon. syncconnects, receives what was queued, writes it and disconnects once Signalreports the queue empty. If the queue does not goempty within ten minutes, the run stops there and that is not an error:everything received is written and the run counts as a success. The only signin the output is that account's caughtUp: false (error stays null), andwhatever is still queued arrives in the next sync. Signal unlinks a devicethat stays offline too long; after that, sync reports it and you link again(the conversations stay, under the same account id), and curlew daemonholds the connection so it does not come to that.
  • Back up the index ($XDG_DATA_HOME/curlew/index.db) like the config.

What arrives: direct and group messages (sealed sender included), your ownmessages sent from the phone, edits, deletions (for everyone, and the ones youmake on the phone), read receipts for your messages, and the contact list whenthe phone sends it (it does after linking and when contacts change; thedownload needs Node for now, see below). Groups appear without their name —Signal keeps group names encrypted on its group server, which curlew does notquery. Reading a chat on the phone is not mirrored: messages arrive unread.Reactions, typing, calls and a disappearing-messages timer are read and droppedon purpose; they are settings, not messages.

Two things curlew reports instead of swallowing:

  • A changed safety number shows up in that contact's conversation as aSafety number changed notice — already read, never unread. Compare thenumber on the phone before you trust the conversation.
  • A message curlew decrypted but could not read (a message type a newerSignal added, or a bug in the decoder) is not thrown away: the rawplaintext goes into the session file, and sync reports how many. Nothing islost on Signal's side either — curlew only acknowledges an envelope oncethat plaintext is on disk. curlew deliveries set-aside lists them: whosent it, when, why curlew could not map it and how big the plaintext was —enough to look the message up on the phone or to report a decoder bug. Theplaintext itself is never printed, and no flag prints it.

What curlew sends: at link time, two requests — it registers the device withthe one-time code the phone sent, and publishes one batch of pre-keys socontacts can start encrypted sessions with it. During sync, only theacknowledgement of each received message (without it Signal would deliver itagain), and only after the message is written to disk. Never a message, a reador delivery receipt, a typing notice, a request to the phone, or a retry requestfor a message it could not decrypt — sync counts those and reports them.

The link leaves a session file at$XDG_DATA_HOME/curlew/secrets/signal/signal-<ACI>.db (mode 0600): youraccount's identity key and this device's keys. Whoever holds it can read yourincoming Signal messages — back it up like a password, never share it. Theaccount id carries your ACI (Signal's account UUID), never your phone number.

Known gap: the contact list is downloaded from Signal's CDN, which needs Signal'sown root certificate; on GJS, gjsify's node:https does not take one yet, sosync reports the contact list as not read and people appear by their Signal iduntil the next contact sync after that is fixed.

XMPP

Curlew reads XMPP history only from the server's message archive (MAM,XEP-0313), which Prosody (mod_mam, mod_muc_mam) and ejabberd offer. A serverwithout one is refused with an explanation: the alternative, receiving offlinemessages, would take them away from your other clients.

curlew backends enable xmpp
curlew accounts add xmpp        # JID, password (no echo), server address
curlew sync

The server address may stay empty: Curlew then looks up direct TLS(_xmpps-client._tcp SRV, XEP-0368), then WebSocket (host-meta, XEP-0156),then STARTTLS. The certificate is checked against your XMPP domain. All threeendpoint kinds work on GJS as of the gjsify release Curlew runs on (0.54.0):the raw TLS socket landed in gjsify#1837and the last piece, Readable.prototype.addListener aliased to on, ingjsify#1958 — without it @xmpp/tlssubscribed to the peer's bytes through addListener and the stream sat at"opening" until the login timed out.A server with its own CA: set backends.xmpp.settings.tlsCaFile to the PEM file.A publicly-trusted certificate is checked reliably; a custom one is checkedunreliably on GJS, because gjsify decides it in a JS accept-certificatecallback that GIO emits on its handshake thread and GJS blocks. Measured 1 of 20logins refused with a certificate error on 0.54.0 (2 of 12 before), so acustom-CA server may need a second attempt there.

The login uses SCRAM-SHA-1 and sends a password in the clear (PLAIN) only insideTLS. The password is kept in$XDG_DATA_HOME/curlew/secrets/xmpp/xmpp-<hash>.db (0600), never in theconfig — a config that carries one is refused.

Chats are your roster contacts and the bookmarked rooms you join automatically.Corrections (XEP-0308) replace the stored text, retractions (XEP-0424/0425)remove the message. OMEMO-encrypted messages are indexed without their text:Curlew cannot decrypt them yet.

Matrix

Matrix is an open protocol, so there are no terms beyond your homeserver's own.Log in with your homeserver, user and password:

curlew backends enable matrix
curlew accounts add matrix       # homeserver URL or server name, user, password
curlew sync

The login creates a new device named curlew (it appears in yoursession list in Element and every other client) and uploads its encryptionkeys. Only password login is supported: a homeserver that offers singlesign-on alone — matrix.org, since its move to the Matrix AuthenticationService — is refused with that reason for now.

Encrypted rooms are decrypted where this device holds the room key. That isevery message sent after the login: senders encrypt for the new device fromthen on, and its keys arrive with each sync. Messages sent before it show as[encrypted message: this device has no key for it]: reading them needs yourserver-side key backup or a verified session sharing its keys, and neither isbuilt yet. Curlew remembers such placeholders and tries them again on everysync, so a key that arrives later still turns them into text.

Curlew never shows you as online (every sync says set_presence=offline) andnever sends a read receipt, a typing notice or a message: a read-only gaterefuses every request outside login, the sync filter and the encryption keyexchange before it leaves the machine. The device's crypto store is saved afterevery sync step, before the server is told the keys arrived, so even a crashloses no key.

The first sync takes the newest 200 messages of every joined room, later syncswalk forward. Edits and redactions arrive as events of their own and are appliedon the next sync — a message redacted on the server is removed from the indexwithout a full scan. Rooms you are only invited to are left alone.

The login leaves an account file at$XDG_DATA_HOME/curlew/secrets/matrix/matrix-<hash>.db (mode 0600). It holdsthe access token and the device's crypto store (its identity keys and every roomkey it received): back it up like a password. Losing it means a new login, a newdevice, and no key for anything sent before it. To end the session, sign thecurlew device out in another client and delete the file.

Receiving daemon

Signal and WhatsApp have no server archive: a message is gone from the networkonce this device acknowledged it, so whatever curlew stores is the onlycopy. curlew sync from a timer narrows the window in which nothing isreceived; curlew daemon closes it.

curlew daemon                   # receive until stopped (SIGTERM/SIGINT)

It connects to every delivery-only backend you enabled — Signal andWhatsApp — for every account, all at once, and keeps receiving. Mail and chatbackends (IMAP, Telegram, Matrix, XMPP) stay on curlew sync: they arepull models with a cursor, and a daemon buys them nothing. A dropped connectionis retried with growing pauses (5 s to 5 min); a device the network logged outor unlinked is not retried — the daemon stops that account and says so, becausethe credentials are gone and every retry would fail the same way while messagesqueue up on the network.

Run it as a user service (the unit ships incontrib/systemd/curlew-daemon.service):

mkdir -p ~/.config/systemd/user
cp contrib/systemd/curlew-daemon.service ~/.config/systemd/user/
# point WorkingDirectory/ExecStart at your checkout if it is not ~/curlew
systemd-analyze --user verify ~/.config/systemd/user/curlew-daemon.service
systemctl --user daemon-reload
systemctl --user enable --now curlew-daemon
loginctl enable-linger "$USER"     # so it also runs while you are logged out
journalctl --user -u curlew-daemon -f

The unit deliberately does not stop when you log out — staying connected is its wholepoint, and the one thing it uses from your session (the address book, for the participantlink) is optional: without it it still receives, and the link appears on the next rebuild.loginctl enable-linger is what lets a user unit run at all while you are not logged in.

The daemon writes one line per state change to stderr, which journaldcollects — connected, a batch with its counts, a reconnect in N seconds, alogout, a stop. Never message text, chat titles, peer names or phone numbers:a log line is a file that gets copied and pasted around, and the same privacyrule that guards the index guards it.

And sync at the same time? Yes — and that is what the lease is for.Both take a lease on each delivery account, stored in the index itself: whoeverholds it refreshes it every 30 seconds and drops it when it stops. A sync thatfinds a live lease reports that account as received by the other run and moveson — not an error, because nothing failed. A daemon that finds one waits forit and takes the account as soon as it is free, rather than never receiving it:that includes a sync in the middle of a WhatsApp catch-up, which can run forten minutes. Two connected devices on one Signal account would each acknowledgehalf the messages, so this is what keeps the copy whole. A run that was killedleaves a lease behind, which expires by itself after 90 seconds.

Stopping is a normal end: SIGTERM (or systemctl stop) closes every session,writes what was in flight, rebuilds the conversations once and exits 0. It isnever a kill — an unacknowledged message is still on the network, and a writtenone must never be lost.

When the unit fails. If every account was logged out — WhatsApp unlinked thedevice after ~14 days, or Signal did the same — the daemon exits 2, the unitis not restarted (a restart cannot relink anything) and shows as failed:

systemctl --user status curlew-daemon   # "code=exited, status=2"
journalctl --user -u curlew-daemon -n 20
curlew accounts add whatsapp            # or: curlew accounts add signal
systemctl --user restart curlew-daemon

Anything else exits 0, including a plain systemctl stop.

What the daemon does not do: send anything, mark anything read, or touch aserver-archive backend. It is the same read-only curlew, connected all thetime. See ADR 0002 for why each piece isthe way it is.

As an MCP server

curlew mcp speaks MCP over stdio. Registered in an MCP client it exposesmail_search, mail_get_message, mail_list_folders, mail_list_parts,mail_save_attachment, mail_search_local, mail_sync_status,conversations_list, conversations_get, contacts_search,calendar_list_events and accounts_list.

The server is read-only by default and fails closed: a tool is registeredonly if it declares itself read-only, so a future tool that forgets theannotation is silently withheld rather than silently exposed.

See .mcp.json for a working registration.

Your data stays yours

The local index contains message headers and plain-text bodies. It lives at$XDG_DATA_HOME/curlew/index.db (mode 0600), never inside this repository,and only curlew sync and curlew daemon ever write to it — a search neverdoes. Attachmentsare saved to your download directory. Both locations are overridable viaCURLEW_DATA_DIR, CURLEW_DB_PATH and CURLEW_ATTACHMENTS_DIR.

Your decisions — enabled backends, accepted terms, per-sender classification —live in $XDG_CONFIG_HOME/curlew/config.json (mode 0600, override withCURLEW_CONFIG). Unlike the index they cannot be rebuilt from a server, soback that file up.

Chat sessions (Telegram, WhatsApp, Matrix — including Matrix's crypto store) andchat passwords (XMPP) are secrets, kept apart from the index under$XDG_DATA_HOME/curlew/secrets/ — no command or MCP tool ever returns one.The index can be rebuilt from the servers unless WhatsApp is enabled:WhatsApp keeps no archive, so its messages in the index are the only copy(curlew backends list shows this as storeTier: state).

Nothing is sent anywhere. Curlew talks to your mail server — and to Telegram,WhatsApp, your XMPP server or your Matrix homeserver if you enabled them — andto nothing else.

Releasing

A tag v* pushed to the repo (git tag v0.1.0 && git push --tags) builds everyinstallable format and attaches them to a GitHub release:.deb/.rpm and macOS (arm64 + x64) /Windows (x64) packages, viarelease.yml. workflow_dispatch re-cutsartifacts for an existing tag without moving it (tag + publish inputs).

GOA and Evolution Data Server are Linux-only. The macOS and Windowspackages still build and ship — they carry the same curlew CLI and MCPserver — but every account backend (mail, contacts, calendar; also thechat/delivery backends that depend on @curlew/gnome for credentials)reports itself unavailable on those two platforms, because the GObject-Introspection libraries GOA/EDS need do not exist there. Today that is thehonest state of the macOS/Windows artifacts: a working binary with noworking account source. Closing that gap — a platform-native credentialand sync layer for macOS/Windows — is open work, not a bug in thesepackages.

No Flatpak: GOA talks to the session bus directly, and a Flatpak sandboxcannot reach it without a portal this project does not implement, so aFlatpak build would silently ship with every account unavailable ratherthan failing loudly.

Local verification, mirroring what CI does on fedora:44:

gjsify workspace curlew-cli build:node   # darwin/win32 ship `dist/curlew.node.mjs`
gjsify install --os darwin --cpu arm64 --immutable
(cd app && gjsify ship darwin --arch arm64 --skip-build --target macos-app-zip)
gjsify install --os win32 --cpu x64 --immutable
(cd app && gjsify ship windows --arch x64 --skip-build --target windows-dir-zip,msi)
(cd app && gjsify ship linux --stage)   # needs gir1.2-*/typelib packages to assert .deb/.rpm fully
gjsify install   # back to the plain install afterwards

Unsigned on both macOS and Windows, by design (ADR 0024 § A13 in gjsify) —a legitimate deliverable, not a placeholder. Where signing would attach, ona runner that holds the identity:

  • macOS — gjsify ship darwin --arch <arch> --skip-build --target macos-app-zip --sign <identity> (Developer ID; --notarize <keychain-profile> on top)
  • Windows — same shape, --sign <certificate thumbprint or PFX path> reaching signtool (unverified upstream: no gjsify run has invoked it)

Development

See AGENTS.md.

License

AGPL-3.0-or-later © Pascal Garber.

The apps (app/) are AGPL-3.0-or-later. The reusable packages under packages/ areLGPL-3.0-or-later, each with its own LICENSE and COPYING, soother programs can link them. The exception is @curlew/signal, which stays AGPL-3.0-or-later:it builds on libsignal-client (AGPL-3.0-only) and contains code ported from Signal Desktop.

Free to use, modify and share. The AGPL adds one condition to the GPL: anyone whoruns this program as a network service must offer that service's users thesource of their version. Running it locally for yourself adds no obligation.

MCP Server · Populars

MCP Server · New