gmcli serve exposes a local control surface over a unix domain socket
(default <store>/gmcli.sock, mode 0600). The protocol is newline-delimited
JSON-RPC 2.0 — one JSON document per line — chosen so any language (and any
agent runtime) can speak it without codegen. gmtui and gmcli mcp are the
first-party clients; this document is the reference for writing your own.
-> {"jsonrpc":"2.0","id":1,"method":"chats.list","params":{"limit":20}}
<- {"jsonrpc":"2.0","id":1,"result":[ ... ]}
<- {"jsonrpc":"2.0","id":2,"error":{"code":1002,"message":"conversation not found: x"}}
Requests may be pipelined; responses can arrive out of order (match on id).
A request without an id is treated as a notification and gets no reply.
Lines are capped at 4 MiB.
After a client calls subscribe, the server pushes events as JSON-RPC
notifications on the same connection:
<- {"jsonrpc":"2.0","method":"event","params":{"type":"message.new","data":{...}}}
| Code | Meaning |
|---|---|
| -32700 | parse error |
| -32600 | invalid request |
| -32601 | method not found |
| -32602 | invalid params |
| -32603 | internal error |
| 1001 | sends disabled (daemon started read-only) |
| 1002 | entity not found |
| 1003 | approval already resolved |
| 1004 | phone/relay unavailable (offline daemon, disconnected, or send failure) |
Read methods (always available):
| Method | Params | Result |
|---|---|---|
ping |
— | {pong, version, schema_version} |
status |
— | {connected, offline, auth_expired, send_mode, pending_approvals, conversations, messages, last_event_ms, last_connect_ms, updated_at_ms} — offline = deliberately no phone connection; auth_expired = phone reports the pairing logged out (gmcli auth required; daemon keeps serving the archive) |
daemon.shutdown |
— | {shutting_down: true}; the daemon exits. Used by gmcli auth to hand over a fresh session — on-demand clients spawn a replacement. |
auth.pair |
— | {started}; legacy QR pairing in the daemon. Emits pair.qr {url}, then pair.success/pair.error. Google removed the QR scanner from current Messages builds — prefer auth.pair.google. |
auth.pair.google |
{cookies_input} — raw Cookie header / Copy-as-cURL / fetch / JSON |
{started}; Google-account pairing. Emits pair.emoji {emoji} (tap it on the phone), then pair.success {phone_id} / pair.error {error}. On success the daemon restarts to load the new session. |
media.download |
{message_id} |
{path, bytes, mime_type}; downloads+decrypts the attachment into <store>/media/. |
alias.set |
{target_type, target_id, alias} — target_type: contact|conversation |
{set: true}; local rename, never sent to Google. |
alias.rm |
{target_type, target_id} |
{removed: true} |
send.react |
{message_id, emoji, remove?} |
{reacted: true}; add/remove a reaction. Respects send mode but skips the approval queue (low-risk, human-initiated). |
subscribe |
— | {subscribed: true}; events start flowing on this connection |
chats.list |
{limit?, unread_only?, pinned?} |
array of conversations |
chats.find |
{query, limit?} — person/group/number fragment |
array of conversations, matched on name, alias, participants, and contacts |
chats.show |
{conversation_id, limit?} |
{conversation, messages} (messages newest-first) |
messages.list |
{conversation_id?, sender_id?, since_ms?, until_ms?, limit?, order?} |
array of messages |
sync.refresh |
— | {started}; pulls the latest inbox conversations/messages from the phone in the background (rate-limited, 30s) and then broadcasts sync.status {state: "refreshed"}. Clients call it on connect. |
messages.search |
{query, conversation_id?, since_ms?, until_ms?, limit?} |
array of {message_id, conversation_id, conversation_name, sender_name, body, snippet, timestamp_ms, timestamp_iso, is_from_me} |
messages.show |
{message_id} |
message |
messages.context |
{message_id, before?, after?} (default 5/5) |
array of messages, oldest-first, anchor included |
contacts.search |
{query?, limit?} |
array of contacts |
contacts.show |
{id} — participant_id or phone number |
contact |
Phone-touching methods (need a non---offline daemon; sends additionally
need --read-only=false):
| Method | Params | Result |
|---|---|---|
history.backfill |
{conversation_id, requests?, count?} |
backfill report |
history.lookup |
{phone, requests?, count?} (E.164) |
{conversation_id, name, backfill} |
send.text |
{conversation_id, body, reply_to_id?, requested_by?} |
approval row (see below) |
approvals.list |
{status?, limit?} |
array of approvals |
approvals.approve |
{approval_id} — performs the send |
resolved approval |
approvals.deny |
{approval_id, reason?} |
resolved approval |
messages.search degrades gracefully so natural-language queries always
work: the query runs verbatim first (full FTS5 syntax — quoted phrases,
AND/OR/NOT), and if FTS5 rejects it, terms are cleaned (edge punctuation
trimmed, sub-3-character terms and stopwords dropped) and retried quoted
with AND semantics. When requiring every term finds nothing — and the query
contains no explicit FTS5 syntax — a final OR pass surfaces per-term
matches. Terms need 3+ characters to match (trigram index). Results are
newest-first.
Messages returned by chats.show, messages.list, and messages.context
are enriched with sender_name (alias > contact name > number) and
timestamp_iso; search hits additionally carry conversation_name.
The daemon's send mode is fixed at startup:
- off (default; daemon started without
--read-only=false):send.textandapprovals.approvereturn code 1001. - approve (
--read-only=false serve):send.textinserts apendingapproval row and returns it — nothing is sent. A human resolves it viaapprovals.approve(gmtuiy, orgmcli approvals approve). Approving performs the send and resolves the row tosentorfailed; a failed send leaves useful detail inerror. - direct (
--read-only=false serve --sends direct):send.textsends immediately; an audit row is still written to the approvals table.
Approval rows: {approval_id, conversation_id, body, reply_to_id?, requested_by, status: pending|sent|failed|denied|canceled, error?, message_id?, created_at_ms, updated_at_ms}. Double-approve races lose
cleanly with code 1003.
| Type | Data |
|---|---|
message.new |
{message, is_old} — after the row is persisted |
conversation.updated |
{conversation} |
approval.requested |
approval row |
approval.resolved |
approval row |
sync.status |
{state}: ready, refreshed (bulk import finished — refetch), refresh_failed, phone_not_responding, phone_responding, listen_temporary_error, listen_recovered, logged_out |
Slow consumers may have events dropped rather than stall the daemon; treat the stream as advisory and re-query when reconnecting.
- The socket accepts connections immediately on startup; the phone
connection and initial import proceed in the background. Query the archive
right away and watch
sync.status/statusfor connection state. gmcli serve --autois the on-demand mode used by gmtui,gmcli mcp, andgmcli approvals: approval-gated sends (unless--read-onlyis passed explicitly) and--idle-exit 10mby default — the daemon exits after ten minutes with no connected clients. Clients spawn it automatically when the socket is dead, so users normally never runserveby hand.--idle-exit <duration>works on any serve invocation;0(the default outside--auto) means run forever.- Auto-started daemons log to
<store>/daemon.log.
- Only run one connected session per store: the daemon owns the libgm
long-poll. Don't run
gmcli sync/send/historyCLI commands against the same store whileserveis up — use the socket instead. gmcli serve --offlineserves queries and the approval queue without a phone connection (useful for browsing an archive or developing clients). Auto-start falls back to offline mode automatically when the store has no paired session.- Two clients racing to auto-start is safe: the second daemon finds the socket live and exits; the winner serves both.