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.

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. The response gives you a display_code to text, the number to text it to (send_to), and the single-use activation_secret.

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.

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 → not verified yet, retry shortly.

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 (chat_active, participant_verified, …)
  • error handling (409 chat_not_ready, 410 expired, non-2xx sends)
  • long-message chunking and markdown flattening
  • 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, plus the frames you’ll see on the WebSocket.

Endpoints

EndpointDoes
POST /v1/auth/activateBegin activation. Returns display_code, send_to, activation_secret, line_id. provision_chat:true also creates the first chat.
POST /v1/auth/activate/redeemPoll with the secret until verified. Returns token (+ chat.uid). 410 if expired.
GET /v1/linesList provisioned lines (ln_… uid + provider_key = the sender number).
POST /v1/chats · GET /v1/chats/{uid}Create / read chats. Lifecycle pending → active; terminal failed (recover by delete + recreate).
POST /v1/chats/{uid}/messagesSend a message ({body}). Success = any 2xx.
GET /v1/chats/{uid}/messagesRead history — used to backfill after a WebSocket reconnect.
POST /v1/ws/ticketMint a short-lived ticket for wss://api.plow.co/v1/ws?ticket=….

WebSocket frames

FrameMeaning
connectedStream is up.
chat_activeThe chat verified and is live (good moment to send a welcome).
participant_verifiedA member confirmed by text.
message_receivedA message. Body in message.body; act only on direction: "inbound".
message_status_updatedDelivery/read status change.
chat_activation_failedActivation failed — terminal for that chat.

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.
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.