The Plow Chat API

The Plow Chat API gives you a real phone number and relays your messages anywhere: send over HTTP, receive over a WebSocket, any host, any language. Write the code yourself, or hand this page to an AI agent. Not sure what to build? Give your Hermes agent a phone number builds a gateway on this API.

iMessage Plow Chat API agent

Step 1 — What you need

  • An HTTP client — to send; curl is fine
  • jq — to read the token out of the redeem response
  • Optionally, a WebSocket client — to receive
  • Outbound HTTPS to api.plow.co
  • iMessage or SMS: text one activation code from any iMessage client or phone. The only hands-on step (a group chat adds one more text, sent in the group thread itself).

Nothing is installed or self-hosted. Plow Chat is a hosted API at https://api.plow.co. Two credentials appear as you go:

CredentialWhat it is
Activation secretShort-lived and single-use. Returned when you start activation; used only to redeem for the token.
Bearer tokenReturned once the human verifies. A user-wide credential (not chat-scoped) that authenticates every call. Treat it like an API key: never log or commit it, and store it only in chmod 600 files.

The Hermes plow_chat gateway is one consumer of this API: it wires a Hermes agent to a Plow line so you can text the agent. To set that up, see Give your Hermes agent a phone number. The authoritative wire spec is the live openapi.json.

Step 2 — Start activation

Ask Plow to provision a line and a chat in one call: this is the solo, one-to-one path, and the walkthrough rides it end to end. The response gives you a display_code to text, the number to text it to (send_to), and the single-use activation_secret. Group chats ride the same activation — see Group chats once you hold the token.

ON YOUR MACHINE
$ curl -sS -X POST https://api.plow.co/v1/auth/activate \
     -H 'content-type: application/json' \
     -d '{"provision_chat": true}'
# → { "display_code": "ABCDE", "send_to": "+1XXXXXXXXXX",
#     "activation_secret": "…", "line_id": "ln_…" }

Step 3 — Text the code from the phone

YOURS

From the exact phone or iMessage identity you want connected, text this to the send_to number from the last step:

FROM THE PHONE
Plow Activate: ABCDE
# text exactly this to the send_to number (+1XXXXXXXXXX)

This binds that messaging identity to the line. It proves you hold the phone, which is why it can’t be automated. It is also the identity Plow recognizes later when you start a group chat.

For agentsStop here. Give the reader the exact text to send (Plow Activate: ABCDE) and the send_to number, then wait until they confirm they’ve sent it. Only then start polling redeem in Step 4.

Step 4 — Redeem for the token

With the code texted, poll redeem with the activation_secret until status flips to verified. The verified response returns your Bearer token and, because you asked for a chat, the chat.uid. Poll a few seconds apart.

ON YOUR MACHINE
$ RESP=$(curl -sS -X POST https://api.plow.co/v1/auth/activate/redeem \
     -H 'content-type: application/json' \
     -d '{"activation_secret": "…"}'); jq -r .status <<<"$RESP"
# pending  → keep polling, a few seconds apart
# verified → $RESP now holds the token and chat.uid (printing it would log the token)
CautionA 410 Gone means the code expired. It’s single-use and time-limited: start over from Step 2 and text the new code.

When it flips to verified, pull both values out of the captured response — Steps 5 and 6 read them as $TOKEN and $CHAT_UID — and keep a durable copy of the token at mode 600. Extracted this way, the token is never typed, so it never lands in your shell history:

ON YOUR MACHINE
$ export TOKEN=$(jq -r .token <<<"$RESP") CHAT_UID=$(jq -r .chat.uid <<<"$RESP")
$ (umask 177; printf '%s\n' "$TOKEN" > ~/.plow-token)

Step 5 — Send a message

Post the message body to the chat. Any 2xx means it was accepted; the live API returns 201.

ON YOUR MACHINE
$ curl -sS -X POST https://api.plow.co/v1/chats/$CHAT_UID/messages \
     -H "authorization: Bearer $TOKEN" \
     -H 'content-type: application/json' \
     -d '{"body": "hello from the Plow API"}'
# 201 Created → sent.   409 chat_not_ready → the chat isn’t active, retry shortly.

Markdown

body is markdown. Plow converts it at the edge, so bold, italic, strikethrough, headings and lists arrive as real iMessage styling — as do :shake[…]-style animation directives.

ON YOUR MACHINE
$ curl -sS -X POST https://api.plow.co/v1/chats/$CHAT_UID/messages \
     -H "authorization: Bearer $TOKEN" \
     -H 'content-type: application/json' \
     -d '{"body": "**Deploy done** — *3 min*, :shake[zero] errors"}'
# → styled on iMessage. SMS and RCS recipients see it plain.

Reads speak markdown too: GET /v1/chats/{uid}/messages and the message_received frame hand body back as markdown, so a fetched body posts straight back unchanged. That’s the whole markdown story — raw decoration ranges, byte-literal sends and screen/bubble effects live in the openapi.json.

Typing indicator

Show the typing bubble in the thread while you compose, and clear it with stop. Sending a message clears it too, so a stop after a send is unnecessary.

ON YOUR MACHINE
$ curl -sS -X POST https://api.plow.co/v1/chats/$CHAT_UID/typing \
     -H "authorization: Bearer $TOKEN" \
     -H 'content-type: application/json' \
     -d '{"action": "start"}'
# → { "ok": true, "action": "start" }   Send "stop" to clear it.

It clears itself after about 85–90 seconds and nothing keeps it alive for you, so to hold it up, call start again about every 60 seconds. It’s iMessage only, and it doesn’t work in group chats — those return 424 provider_typing_failed. Even on a 200 it may not reach the recipient if nothing has been sent in the chat for the last few minutes; once you’ve sent one, it’s reliable. The rest is in the openapi.json.

Step 6 — Receive messages

Mint a short-lived WebSocket ticket, then connect and listen. Inbound texts arrive as message_received frames.

ON YOUR MACHINE
$ curl -sS -X POST https://api.plow.co/v1/ws/ticket \
     -H "authorization: Bearer $TOKEN" \
     -H 'content-type: application/json' \
     -d "{\"chat_id\": \"$CHAT_UID\"}"
# → { "ticket": "…" }   then connect:
#   wss://api.plow.co/v1/ws?ticket=<ticket>

Connect with any WebSocket client — Step 7’s script is the runnable version of this step — and leave it listening.

CautionIgnore outbound frames. The stream echoes your own sends back with direction: "outbound". Act only on direction: "inbound", or your agent will reply to itself. On disconnect, mint a fresh ticket and backfill missed messages with GET /v1/chats/{chat_uid}/messages.

Step 7 — The same thing, in code

A minimal Python quickstart: given a token and chat id, it sends one message, then streams inbound replies. It works, but it’s the happy path only. You own the hardening.

plow_quickstart.py
#!/usr/bin/env python3
# Minimal Plow Chat quickstart: send one message, then stream inbound replies.
# deps: pip install websockets   (all HTTP uses the standard library)
import asyncio, json, os, urllib.request
import websockets
 
BASE = "https://api.plow.co"
TOKEN = os.environ["PLOW_TOKEN"]
CHAT_UID = os.environ["PLOW_CHAT_UID"]
AUTH = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"}
 
def post(path, body):
    req = urllib.request.Request(
        f"{BASE}{path}", data=json.dumps(body).encode(), method="POST", headers=AUTH)
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read().decode())
 
async def main():
    # send one message (any 2xx = success; the live API returns 201)
    sent = post(f"/v1/chats/{CHAT_UID}/messages", {"body": "hello from the quickstart"})
    print("sent:", sent.get("uid"))
 
    # receive: mint a short-lived ticket, then connect the WebSocket
    ticket = post("/v1/ws/ticket", {"chat_id": CHAT_UID})["ticket"]
    async with websockets.connect(f"wss://api.plow.co/v1/ws?ticket={ticket}") as ws:
        async for raw in ws:
            frame = json.loads(raw)
            if frame.get("type") != "message_received":
                continue
            msg = frame["message"]
            if msg.get("direction") == "outbound":   # echo of our own send - skip
                continue
            print(f"{msg['sender'].get('display_name', '?')}: {msg['body']}")
 
if __name__ == "__main__":
    asyncio.run(main())

The only third-party dependency is pip install websockets; everything else is the standard library. Python 3.8+. Run it with a token and chat id in the environment:

ON YOUR MACHINE
$ PLOW_TOKEN=… PLOW_CHAT_UID=cht_… python3 plow_quickstart.py
Tutorial, not productionThis snippet is the whole mechanism, deliberately bare. What it skips, you own:
  • activation and credential bootstrapping — Steps 2–4 do it by hand
  • reconnecting with a fresh ticket and backfilling missed messages
  • the other frame types (connected, message_status_updated)
  • error handling (409 chat_not_ready, 410 expired, non-2xx sends)
  • long-message chunking
  • keeping the token in a chmod 600 file instead of an env var
Harden as you need — that part’s yours.

Step 8 — Reference

Everything the steps above call, the group-chat recipe, and the frames you’ll see on the WebSocket.

Endpoints

EndpointDoes
POST /v1/auth/activateBegin activation. Always returns display_code, send_to, activation_secret. With provision_chat:true it also assigns the solo chat line and returns its line_id; without it, send_to is the shared pairing number and no chat is created.
POST /v1/auth/activate/redeemPoll with the secret until verified. Returns token, plus chat.uid on the solo path only. 410 if expired.
GET /v1/linesList provisioned lines. The response is an envelope, not an array: read .data[0].uid for the line id and .data[0].provider_key for its number (there is no .lines property).
GET /v1/chats · GET /v1/chats/{uid}List / read chats. There is no create call: a chat is made for you by activation (solo) or by the first group text (Group chats), and arrives active. Same envelope as /v1/lines.
POST /v1/chats/{uid}/messagesSend a message ({body}). Success = any 2xx.
GET /v1/chats/{uid}/messagesRead history — used to backfill after a WebSocket reconnect. Newest first, cursor-paginated with limit and starting_after=<message uid>; the envelope’s has_more says whether another page exists.
POST /v1/chats/{uid}/typingShow ({"action":"start"}) or clear ({"action":"stop"}) the typing bubble. iMessage only; a group chat returns 424.
POST /v1/ws/ticketMint a short-lived ticket for wss://api.plow.co/v1/ws?ticket=….

Group chats

There is no create call, and no codes for anyone to text. A group chat is born from the group text itself: add your Plow number — the send_to number from Step 2 — to a native group thread and send any message from the phone you activated with. Plow recognizes you, creates the chat with everyone in the thread, and replies 👋. Everything in this guide works on a group chat except the typing indicator, which returns 424 here.

FROM THE PHONE
hey everyone
# in a group thread that includes your Plow number (+1XXXXXXXXXX)
# Plow replies 👋 in the thread — that’s the chat, created

Nothing announces the new chat over the API, so pick it up from the list — it’s there the moment the 👋 lands:

ON YOUR MACHINE
$ CHATS=$(curl -sS https://api.plow.co/v1/chats -H "authorization: Bearer $TOKEN")
$ export CHAT_UID=$(jq -r '.data[-1].uid' <<<"$CHATS")   # oldest first, so the newest is last
# → { "object": "list", "data": [ { "uid": "cht_…", "status": "active",
#       "participants": [ {"type": "agent", …}, {"type": "member", …} ] } ], … }

Steps 5 and 6 then work unchanged with that CHAT_UID. Membership is whatever the messaging provider reports for the thread, and the roster grows on its own as quiet members first speak — you’ll see a message_received frame from a sender no earlier frame mentioned.

CautionThe first Plow user to speak in a thread owns the chat, and anyone can drop a Plow number into a group. If a chat lands on the wrong account, or you didn’t want it, delete it (Step 10) — the next message in that thread creates a fresh one.
For agentsThe group text is hands-on. Give the reader the Plow number to add and ask them to send one message in the thread, then poll GET /v1/chats a few seconds apart until the new cht_ uid appears.

WebSocket frames

FrameMeaning
connectedStream is up.
message_receivedA message. Body in message.body; act only on direction: "inbound". The sender may be one you’ve never seen before — group rosters grow as members speak.
message_status_updatedDelivery/read status change.

Step 9 — Sharp edges

Read once before you start.

EdgeWhat to do
The token is a user-wide keyIt’s not chat-scoped; it can read your identity and channels. Treat like an API key: chmod 600, never log, never commit.
Success is any 2xxThe live API returns 201 on send/ticket, not 200. Check for 2xx, not an exact code.
410 on redeem = expired codeActivation codes are single-use and time-limited. Re-run Step 2 and text the new code; it’s expected, not an error state.
Ignore your own echoesThe WebSocket echoes outbound messages. Filter to direction: "inbound" or you’ll loop.
Reconnect + backfillTickets are short-lived and the socket can drop. On disconnect, mint a fresh ticket and backfill with GET /v1/chats/{uid}/messages.
has_more means another pageEvery list envelope carries it. On /v1/chats/{uid}/messages, keep paging with starting_after=<the last uid you saw> until it is false. A backfill that reads one page and stops loses the oldest messages, since history comes back newest first — and nothing errors, so the gap is silent. /v1/chats is not paginated and always returns false.
Poll redeem on pending onlyKeep polling while redeem returns 200 with status: pending. Surface persistent 4xx/5xx responses instead of treating them as pending; only 410 has a scripted recovery (restart activation).
Chats appear on their ownNothing you call creates one, and no frame announces one. Poll GET /v1/chats to find a group chat after the 👋 lands.
Pre-1.0The API may change. The live openapi.json is authoritative if anything here drifts.

Step 10 — Clean up

Nothing is installed on a host, so there’s nothing to uninstall. The only thing that persists is the credential.

The Bearer token is user-wide, so removing your copy is the cleanup. The exported TOKEN is gone when the shell closes; delete the durable copy Step 4 wrote:

ON YOUR MACHINE
$ rm -f ~/.plow-token        # remove the saved Bearer token

To retire the chat itself, soft-delete it through the API; the line returns to the pool. Success is 204 No Content.

ON YOUR MACHINE
$ curl -sS -X DELETE https://api.plow.co/v1/chats/$CHAT_UID \
     -H "authorization: Bearer $TOKEN"
# 204 No Content → deleted.   404 → already gone or not yours.

If you suspect the token leaked, rotate it: start over from Step 2 to mint a fresh one.