Skip to content

Latest commit

 

History

History
245 lines (196 loc) · 15.8 KB

cli.md

File metadata and controls

245 lines (196 loc) · 15.8 KB

duoduo-tether, the client command line

duoduo-tether connects any agent that can run a shell command to a duoduo host as a connected assistant: Claude Code, Codex, an agent you built yourself. Each command is one MCP tool of the tether endpoint (see protocol.md), so the agent needs no MCP client, one connection and one credential. listen is a command that exits, or prints a line, when mail arrives, so an agent can be woken by it.

It ships in the same package as the channel:

npm install -g @openduo/channel-tether
duoduo-tether login https://tether.example.com

The channel's plugin entry (which duoduo starts) and this command are separate entry points of the package; installing the package to use the command starts nothing.

Logging in

duoduo-tether login <public url> [--name <name>] [--via <private origin>]
  1. The command listens on 127.0.0.1 at a free port and builds an authorization request for the client built into every tether host, duoduo-tether (see protocol.md): PKCE S256, resource set to the public URL, all scopes, a random state, and the return address http://127.0.0.1:<port>/callback.
  2. It opens the authorize page in a browser when it can start one, and always prints the URL.
  3. The owner approves with a passkey and names the connection. The name field starts empty; --name fills it in advance, and the owner can change it.
  4. The browser returns to the loopback address. The command exchanges the code, reads the grant id from the host, stores the token, and prints where the token file is.

Approving on another device. When the agent's machine has no browser the owner uses (a VM, a server, a remote session), open the printed URL on any device and approve there. That device's browser then fails to load http://127.0.0.1:<port>/callback?..., since the listener is on the agent's machine. Copy that address from its address bar and paste it into the waiting login as one line on standard input. The command accepts whichever comes first, the callback or the pasted address. An address whose state is not the one it sent ends login with exit 1, and nothing is exchanged.

login has no time limit of its own. The authorize page and the authorization code expire on the host (ALADUO_TETHER_CHALLENGE_LIFETIME_MS, ALADUO_TETHER_CODE_LIFETIME_MS); after that, start again. A denial never reaches login: the page tells the owner, not the app. After a denial, or when the owner never approves, stop login by hand (Ctrl-C).

If the token is issued but the token file cannot be written, login revokes that token at once and says so, so no connection is left that nothing holds.

A private route (--via). A client on the same private network as the host (a tailnet, for example) can reach the host where the public URL is blocked or unreachable from it. --via <private origin> sends every request login and later commands make (/token, /mcp, /revoke) to that origin instead of the public URL. The authorize page still opens on the public URL, where the owner's passkey works, and the token, its resource, the client and the issuer stay bound to the public URL. The origin must be https: (any port, no path); http: is refused, since the token travels with every request. There is no fallback: when the private origin cannot be reached, a command exits 4 and names it, and the public URL is not tried. login prints where requests go, and so does status. To change or drop the route, log in again with or without --via. deployment.md shows a host-side setup.

Logging in again to a host that already has a token file replaces that file once the new token is issued. Whether the old connection stays depends on the name the owner gives: the same name replaces it (the page says so after the passkey), another name adds a second connection.

The token file

One file per host: $XDG_CONFIG_HOME/duoduo-tether/<host>.json, with XDG_CONFIG_HOME defaulting to ~/.config. The file is mode 0600, written to a temporary file and renamed into place; the directory is mode 0700. <host> is the public URL's host name. Two agents of one OS user hold separate connections to one host by running with different XDG_CONFIG_HOME values.

{
  "public_url": "https://tether.example.com",
  "grant": "1f0c…",
  "token": "…"
}

A login with --via adds "via": "<private origin>"; a file without it sends requests to the public URL.

The connection's name is not stored: the owner may change it, and context reports who this assistant is.

The token does not expire. Anyone who can read this file acts as this assistant until the owner revokes it.

Every command except login takes --host <host>. Without it, the command uses the only token file there is; with none, or several, it exits 2 and names the hosts it found.

Commands

Command Tool What it does
login <url> [--name <name>] [--via <origin>] (OAuth) Connect; see above
logout (/revoke) Revoke the token on the host, then delete the token file
status (resource list) The host and grant, with the token checked live
context [--conversation <id>] GetContext Who this assistant is, duoduo's board, and a conversation id
memory <path> ReadMemory One memory file, as the board names it
events [options] ReadEvents One day of duoduo's event log, external view
addresses ListAddresses Who this assistant can mail
mail [<id>] [--after <id>] ReadMail Unread mail (which then counts as read), or one mail by id
send [--to <a>] [--in-reply-to <id>] [--idempotency-key <k>] [<message>] SendMail Send mail; the message is the argument, or standard input when absent
record --conversation <id> --board-rev <rev> --said <t> --did <t> --outcome <t> [--from <t>] [--artifact <t>] [--model <id>] RecordExperience Record a turn for duoduo
listen [--follow] (push) Wait for mail; see Listening

events takes the tool's arguments as options: --date, --interval, --from, --to, --session, --types <a,b>, --kind, --after, --show, and the flags --unfiltered, --count-only, --sessions, --jsonl (the tool's json: one JSON object per row).

Options use the tool's argument names with - for _. Nothing is added: the command sends what you give it, once, with no retry and no idempotency key of its own. One-shot commands have no time limit; interrupt one that hangs.

logout keeps the token file when the host cannot be reached or answers an HTTP error (exit 4): the token was not revoked, so run logout again.

Output

  • By default a command prints the tool result's text, which the tool already writes for a reader.
  • --json prints the raw tool result (content, structuredContent, isError, _meta) as one JSON object.
  • A refusal (isError: true) prints its text to standard error, and the command exits 1. Its reason is in _meta["duoduo/reason"], shown with --json.

Exit codes

Code Meaning
0 Done; for listen, mail is waiting
1 The tool refused (isError), the server answered a JSON-RPC error, or login could not keep the token
2 The command line is wrong, or no token file is chosen: not logged in, or several hosts and no --host
3 The host refused the token (HTTP 401): revoked, replaced, or issued under another public URL
4 The host could not be reached or answered another HTTP error; for listen, a connection attempt failed (see Listening)

Listening

listen opens subscriptions/listen (MCP 2026-07-28) on this assistant's mailbox, duoduo://mailbox/<grant>. Each time the stream is acknowledged and each time it rings, listen reads the mailbox resource, which lists the unread mail as id and sender, exactly the mail duoduo-tether mail would return, and acknowledges nothing. listen never reads mail: after it reports, the agent runs duoduo-tether mail.

Each report is one line on standard output, one per unread mail:

mail evt_…@2026-10-07 from tether:muse
  • Default. listen exits 0 after its first report. If unread mail is already waiting when it connects, it reports that at once and exits.
  • --follow. listen keeps running and prints one line per new mail, each mail once per process, even when the host rings again for it or the stream reconnects. Unread mail waiting when it connects is printed first.

Reading the mailbox on every acknowledgment reports mail that arrived while no stream was open: before listen started, or during a reconnect.

Reconnecting. A stream that drops after reaching the host, a 5xx answer, or 45 seconds of silence (no answer to the request, or no keep-alive on the stream) is reconnected inside the process; it never ends the command. A connection attempt that fails before any answer (DNS, TLS, a refused or timed-out connection) ends listen with exit 4 and a line on standard error, on the first attempt or on any reconnect: the host cannot be reached, so waiting would never wake the agent. The host sends a keep-alive every 15 seconds, so 45 seconds of silence is three missed keep-alives: a relay can leave a dead connection half open, and silence is then the only sign. The delay before each attempt is random below a ceiling that starts at 1 second and doubles per failed attempt up to 60 seconds; an acknowledged stream resets it. Each reconnect writes one line to standard error.

Ending. The host refusing the token (HTTP 401: revoked, replaced, the public URL changed) ends listen with exit 3 and a line on standard error. When the connection is revoked or replaced while the stream is open, the host ends the stream, and the reconnect gets the 401. Any other 4xx ends listen with exit 4, without reconnecting; a refused listen (a JSON-RPC error, such as a grant without the mail scopes) with exit 1.

Waking an agent

listen holds no state. Anything that can run a process and react to its exit or its output can be woken by mail.

Claude Code, background command. Run duoduo-tether listen with Bash in the background. When mail arrives the command exits, and Claude Code is told the background command finished, with its output. The agent reads the mail with duoduo-tether mail, answers, and starts listen again. On exit 4 the agent reports that the host is unreachable rather than restarting listen straight away.

Claude Code, Monitor. Start duoduo-tether listen --follow with the Monitor tool. Each mail is one output line, and each line is an event for the agent; the process stays up across mails.

A runtime that tracks a foreground process (an agent you built, like one that runs a command and waits for it): run duoduo-tether listen as the waiting step of the agent's loop. Exit 0 means read mail; exit 3 means the connection is gone, and the owner has to approve a new one; any other exit is a fault to report.

In every recipe the mail itself is read by duoduo-tether mail; listen only says that there is some.

Handling mail without a session open

An agent with no session running can still answer mail: a loop waits with listen, then starts the agent once per wake. Tether does not run this loop and ships no command for it. It is a recipe that you assemble and keep alive with your own supervisor; tether's part ends when listen exits.

#!/bin/sh
# tether-loop.sh: wait for duoduo mail, then let Claude Code handle it.
cd "$HOME/work/assistant" || exit 1
while :; do
  duoduo-tether listen
  case $? in
    0) ;;
    3) echo "tether connection is gone; log in again with duoduo-tether login" >&2; exit 0 ;;
    *) exit 1 ;;
  esac
  claude -p "You have duoduo mail. Read it with duoduo-tether mail, handle it, and answer with duoduo-tether send." \
    --allowedTools "Bash(duoduo-tether:*)" || exit 1
done
  • The agent reads the mail itself with duoduo-tether mail; the loop passes it nothing. Unread mail stays unread until the agent reads it, so a run that fails before reading is not lost.
  • Exit 0 means stop for good (the connection was revoked); exit 1 means a fault, listen's exit 4 (host unreachable) included, and the supervisor restarts the script with its own delay. Do not loop straight back after a failed run: listen reports waiting unread mail at once, so a run that keeps failing would start again immediately and spend on every pass. The same holds for a run that succeeds without reading its mail: the prompt must make the agent read it.
  • --allowedTools "Bash(duoduo-tether:*)" lets the agent run duoduo-tether without a prompt. Add only what the mail it handles needs.
  • Run one listener per connection. Two loops on one connection both wake for the same mail and both start the agent; the second finds nothing to read and has spent for nothing. Nothing enforces this.

Mail never grants permission. A mail is a request in text from a duoduo session or another assistant (see security.md). An agent started by this loop runs with whatever permissions you gave it, unattended; any outward or irreversible step a mail asks for (sending to someone else, deleting, paying) still needs the owner's first-hand confirmation. Grant the loop's agent no permission you would not give it for a message from a stranger.

Keep the script running with your own supervisor:

  • launchd (macOS): a LaunchAgent with KeepAlive restarting it on failure only (SuccessfulExit false); set EnvironmentVariables so duoduo-tether and claude are on its PATH. Choose the restart delay by what a failed run costs.
  • systemd (Linux): a user service with Restart=on-failure. Choose RestartSec by what a failed run costs.