herdr-link

Writing an adapter

herdr-link talks to chat apps through adapters: small programs, in any language, that speak a JSON-RPC 2.0 protocol on stdin/stdout. The bridge does everything herdr-specific (spaces, tabs, agents, prompts, the archive); the adapter only knows its chat platform. The built-in adapters, Discord (src/adapters/discord/main.ts) and Telegram (src/adapters/telegram/main.ts, plain fetch, no SDK: a good one to read first), use the same protocol as yours would.

The machine-readable reference is schema/chat.openrpc.json (OpenRPC). This page explains how to use it.

The model

herdr-link says means Discord Telegram Slack (for example)
room a herdr space a category none (rooms: false: topics named space / tab) a channel section
thread a herdr tab a text channel a forum topic a channel
direct the owner's private chat with the bot the DM the private chat the DM
action a button under a message a button an inline keyboard button a block button

Lifecycle

  1. The owner adds a section to ~/.config/herdr-link/config.toml:

    [adapters.telegram]
    command = ["herdr-link-telegram"]   # argv of your program
    env = ["TELEGRAM_TOKEN"]            # env vars your program may see
    owner = "123456789"                 # everything else is yours, sent in hello
    chat = "-1001234567890"
    
  2. The bridge starts command with only the listed env variables plus PATH, HOME, USER, LANG… Secrets come from ~/.config/herdr-link/.env.

  3. Messages are JSON-RPC 2.0, one per line, UTF-8, on stdin (from the bridge) and stdout (to the bridge). Both sides send requests and notifications; request ids are per sender. Write logs to stderr: the bridge copies them into its own log. Anything else on stdout breaks the protocol.

  4. The bridge sends hello first. Validate your config, connect to your platform, and answer only when you are ready. Don't send anything before that.

  5. When stdin closes, shut down. The bridge restarts an adapter that exits, with backoff, except after an InvalidConfig or UnsupportedProtocol error.

hello

→ {"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocol":1,"config":{"owner":"123456789","chat":"-1001234567890"},"commands":[{"name":"status","description":"What every agent is doing","options":[]},{"name":"space new","description":"Make a herdr space","options":[{"name":"name","description":"Space name","type":"string","required":true}]}],"agents":[{"name":"claude","emoji":"✳️","logo":"claude"},{"name":"shell","emoji":"🐚"}]}}
← {"jsonrpc":"2.0","id":1,"result":{"protocol":1,"adapter":{"name":"telegram","version":"0.1.0"},"capabilities":{"rooms":true,"direct":true,"threadCreation":true,"actions":true,"reactions":true,"customEmoji":false,"commands":true,"edit":true,"files":true,"maxText":4096,"maxActions":8}}}
capability when false
rooms every tab is a top-level thread named <space> / <tab>; a closed tab's thread is deleted (no archive); no new tabs from new threads
direct no control room, no dashboard, no quick tasks
threadCreation the owner can't open tabs by making threads
actions permission prompts come as text (answer them in herdr); ask is still sent: offer its choices as reactions or numbered replies
reactions no 👀 on received messages, no answers by reaction
customEmoji agent logos are replaced by their Unicode emoji
commands no slash commands
edit no live dashboard; answered prompts keep their buttons
files attachments are neither sent nor received
maxText the bridge splits longer text into several messages
maxActions the most buttons the bridge puts under one message
status (optional) no thread.status notifications (chat apps don't need them: their messages say it)

Bridge → adapter

All of these but thread.status are requests: answer each with a result or an error.

method params result what to do
room.ensure room?, name room, created Make sure a private room (only the owner and the bot) exists with this name. With room: reuse it, rename if needed; if it is gone, create a new one.
room.delete room {} Delete a room you made. The bridge empties it first.
thread.ensure room?, thread?, name?, topic? thread, created Same for a thread in room: create, or reuse by id, move into room, rename and set the topic when given. name is only required to create.
thread.delete thread {} Delete a thread you made, with its history.
message.post place, text, files?, actions?, replyTo?, silent? message Post. text is short Markdown (bold, italics, code, code blocks, links): convert or strip it. files are local paths. actions are buttons. silent: true: the owner is at the computer, deliver without a push if your platform can (else post normally).
message.edit place, message, text?, actions? {} Replace the text and/or the buttons; actions: [] removes them.
react place, message, emoji {} Add the bot's reaction.
ask place, text, choices, silent? message Post a question and return at once. Report the answer later as action.pressed with the choice's id, whether the owner pressed a button or reacted with the choice's emoji.

One notification, only when your capabilities say "status": true:

method params what it says
thread.status thread, terminal, state, kind?, text? An agent in this thread's tab changed state: working, blocked (waiting for the owner; kind says for what: permission, question, auth…), done (finished its turn), idle, error, or gone (the terminal ended). text is the agent's own status line. Right after hello, every agent's current state. A split tab has several terminals, each reported on its own.

An emoji is {"kind": "unicode", "value": "👀"} or {"kind": "custom", "name": "claude"} (a custom emoji you know by name, such as an agent's logo).

→ {"jsonrpc":"2.0","id":7,"method":"message.post","params":{"place":{"kind":"thread","thread":"-1001234567890/45"},"text":"🔐 Permission: approve Bash: pnpm test","actions":[{"id":"prompt:3:allow","label":"Allow","style":"success"},{"id":"prompt:3:deny","label":"Deny","style":"danger"}]}}
← {"jsonrpc":"2.0","id":7,"result":{"message":"-1001234567890/45/9001"}}

Adapter → bridge

Report only what the owner did. Notifications are handled in order.

method kind params
message.received notification place, message, text, attachments, replyTo?
action.pressed notification place, message, action (the id the bridge gave the button or choice)
reaction.added notification place, message, emoji
thread.created notification room?, thread, name, agent?, task?
thread.deleted notification thread
room.deleted notification room
command.invoked request place, command, args → result {"text": "…"}: show it as the reply
space.create request name, folder, agent?, tab?, task? → result {"room?", "thread?"}
← {"jsonrpc":"2.0","method":"message.received","params":{"place":{"kind":"thread","thread":"-1001234567890/45"},"message":"-1001234567890/45/9002","text":"run the tests","attachments":[]}}

Driving agents from a program

An adapter needn't be a chat app. A program with its own interface (a pipeline that runs every step of a ticket in its own tab, a board, a script) is an adapter too: to herdr-link it is a chat that happens to be written by code. It can:

  1. make a space for a piece of work: space.create with the folder and the first step's agent and prompt;
  2. start the next step: thread.created in that room, with agent and task;
  3. follow it: thread.status (with "status": true in its capabilities) says when the agent works, waits for the owner, or is done; the agent's reply comes as message.post in the thread, prompts and questions with their buttons, which it may press (action.pressed) or leave to the owner;
  4. talk to it: message.received in the thread;
  5. clean up: thread.deleted closes a tab, room.deleted the whole space.

Run several adapters at once: the owner's Discord shows the same spaces and tabs, and can step in from the phone.

Errors

Standard JSON-RPC codes, plus:

code name use it when
-32001 Unsupported the method needs a capability you don't have
-32002 NotFound the room, thread or message is gone on your platform
-32003 InvalidConfig hello: your config section is wrong
-32004 UnsupportedProtocol hello: you don't speak this protocol version

A minimal adapter

test/fixtures/fake-adapter.mjs is a complete one in 50 lines of plain Node, used by the tests. The same skeleton in Python:

#!/usr/bin/env python3
import json, sys

def send(message):
    sys.stdout.write(json.dumps({"jsonrpc": "2.0", **message}) + "\n")
    sys.stdout.flush()

def log(text):
    print(text, file=sys.stderr, flush=True)

def hello(params):
    config = params["config"]
    if "owner" not in config:
        raise RpcError(-32003, "set `owner` in [adapters.mychat]")
    # connect to the platform here, then answer
    return {"protocol": 1, "adapter": {"name": "mychat", "version": "0.1.0"},
            "capabilities": {"rooms": False, "direct": True, "threadCreation": False, "actions": False,
                             "reactions": False, "customEmoji": False, "commands": False, "edit": False,
                             "files": False, "maxText": 4000, "maxActions": 0}}

def thread_ensure(params):
    return {"thread": params.get("thread") or "main", "created": "thread" not in params}

def message_post(params):
    log(f"post to {params['place']}: {params['text']}")
    return {"message": "1"}

class RpcError(Exception):
    def __init__(self, code, message):
        super().__init__(message)
        self.code = code

handlers = {"hello": hello, "thread.ensure": thread_ensure, "message.post": message_post}

for line in sys.stdin:  # stdin closes: the bridge wants us gone
    message = json.loads(line)
    if "method" not in message:
        continue  # a response to one of our requests
    handler = handlers.get(message["method"])
    try:
        if handler is None:
            raise RpcError(-32601, f"unknown method {message['method']}")
        result = handler(message.get("params", {}))
        if "id" in message:
            send({"id": message["id"], "result": result})
    except RpcError as error:
        if "id" in message:
            send({"id": message["id"], "error": {"code": error.code, "message": str(error)}})

# when the owner writes, from your platform's event loop:
# send({"method": "message.received", "params": {"place": {"kind": "thread", "thread": "main"},
#       "message": "42", "text": "hello", "attachments": []}})

Trying it

  1. Add your section to the config (above), then herdr-link doctor checks that your command and env resolve.
  2. Run the bridge in the foreground to see your stderr live: herdr-link run (stop the service first: herdr-link service uninstall, or it refuses to start a second bridge).
  3. Publish it as a package with a herdr-link-<platform> binary, and tell its users: install only adapters you trust. An adapter can type into their agents' terminals.
Edit this page on GitHubherdr-link · MIT