Skip to content

Latest commit

 

History

History
185 lines (155 loc) · 9.1 KB

File metadata and controls

185 lines (155 loc) · 9.1 KB

Parley v1 — Spec

Parley lets your friends' agents ask your agent questions, with you in the loop. Your agent runs on your machine with your local context; every agent-authored message requires its owner's explicit approval (via Discord DM buttons) before the other human sees it. The service is a thin ephemeral relay on Cloudflare; it stores nothing beyond in-flight requests.

Pitch in one line: your friend runs parley ask @rhys "how's your executor setup wired?", your local agent drafts an answer from your real dotfiles, you tap Approve on a Discord DM from your phone, they get the answer in their terminal.

Non-goals for v1 (explicitly cut, do not build)

  • No auto-approve / trust tiers. Every agent-authored message is gated.
  • No inbox, no history, no threads. Requests are ephemeral request/response; nothing persists after delivery (or timeout/decline).
  • No Edit button on approvals. Approve / Deny / Deny-with-reason only.
  • No free-text conversation through Discord. The bot never reads messages (no gateway connection exists; interactions come via HTTP webhook only).
  • No offline queueing. Recipient's responder offline → asker fails instantly.
  • No Discord friendship-graph reading (no relationships.read scope). Contacts are Parley-native via invite codes.
  • No MCP server, no web UI, no rooms/groups/broadcast, no E2E encryption.

Roles of each component

  • Discord — two jobs only: identity (OAuth sign-in) and approval broker (bot DMs with buttons). It never carries conversation content authored by humans and is never a chat surface.
  • Relay (Cloudflare Workers + Durable Objects) — auth, contacts, invite codes, in-flight request routing, Discord webhook handling, Discord REST calls. Sees plaintext (accepted tradeoff for v1; approval gates are the security model, not encryption).
  • CLI (Bun, single binary target) — everything the human and their agent touch: login, contacts, asking, and the background responder daemon.

Identity

  • parley login opens Discord OAuth (scopes: identify only). The relay mints an opaque API token bound to the Discord user id (snowflake). Token stored at ~/.parley/credentials.json (0600).
  • A user's display identity everywhere = their Discord username + avatar. No self-asserted names anywhere (v0 lesson).

Contacts (invite codes, not Discord friendship)

  • parley invite → relay returns a single-use code (format: 6 chars, unambiguous alphabet), expires 48h. Printed as both code and link (https://<relay-host>/i/<CODE>; the link is informational in v1 — joining happens via CLI).
  • The code is a claim ticket, not a bearer credential: redeeming it only creates a pending connection.
  • parley join <CODE>: redeemer must be logged in. On redemption the inviter gets a bot DM: " redeemed your invite — Connect / Ignore" (buttons). Connect → mutual contact, both notified. Ignore → nothing; redeemer sees "pending" then "not accepted" after the invite expires.
  • Only explicit mutual contacts can ask each other. There is no way to reach a non-contact. parley contacts lists contacts + responder online status. parley remove <name> severs the connection for both sides (silent — the other side simply can no longer ask; they see "not a contact" if they try).

Asks (the core loop)

parley ask @<contact> "<question>" — synchronous, blocks with a spinner until terminal state. Terminal states: answered, declined (with optional reason), recipient offline, timeout (default 10 min), not-a-contact.

Flow:

  1. Asker CLI → relay. If the recipient has no live responder connection, fail immediately: rhys's agent is offline right now — try again later. Nothing is stored.
  2. Relay creates an in-flight request in a Durable Object and pushes the question down the recipient responder's WebSocket.
  3. Gate 1 (inbound): recipient's bot DM: asker identity + verbatim question + [Run] / [Deny] / [Deny with reason] buttons. The responder does NOT invoke the local agent until Run is tapped. (The inbound prompt is untrusted input; the human sees it before any agent executes it.)
  4. On Run, the responder invokes the configured handler command locally with the question + configured context. Handler output = draft.
  5. Gate 2 (outbound): draft goes to relay as pending; recipient's bot DM shows the draft + [Approve] / [Deny] / [Deny with reason]. A pending draft is never deliverable to the asker.
  6. Approve → answer delivered to the asker's blocked CLI call; request is deleted from the DO. Deny → asker gets "declined" (+ reason if given); request deleted. Timeout → both sides notified, request deleted.

Symmetry rule (the invariant): human-typed content is pre-approved by construction; agent-authored content always requires its owner's button press. In v1 the ask itself is typed by the asker (or composed by their agent into the CLI — treated as typed; the asker's own approval gate on outbound asks is deferred with auto-approve, since v1 ask takes the literal string given).

Deny-with-reason opens a Discord modal; the typed reason is human-authored metadata on a rejection and is delivered to the asker verbatim.

Responder (recipient side)

  • Config at ~/.parley/config.toml:

    # command receives the composed prompt on stdin, writes the reply to stdout
    command = "claude -p"
    context = """
    You are answering questions asked by my friends via parley.
    You may reference ~/dotfiles and ~/agent-workspace/registry.toml.
    Do not reveal secrets, tokens, or anything outside those paths.
    """
  • The composed prompt to the handler includes: the context block, the asker's username, and the question. Handler stdout (trimmed) is the draft; nonzero exit or empty output → auto-deny with a generic "agent failed to draft" (no retry in v1), surfaced to the recipient in the DM.

  • parley install — registers a launchd user agent (macOS) running the responder; parley uninstall, parley status (online/offline, config summary, last activity), parley logs (tail). Linux/systemd may be stubbed with a clear "not yet" if time-boxed, macOS is the target.

  • Foreground equivalent: parley listen (same code path, terminal output).

  • Responder connects to its user's DO via WebSocket; that live connection IS the online/offline signal. Use WebSocket hibernation where natural.

Discord mechanics (stateless, no gateway)

  • Interactions endpoint URL on a Worker route; verify Ed25519 signatures; respond within 3s (defer + follow-up where needed).
  • Sending DMs: REST (POST /users/@me/channels, then channel messages) with the bot token.
  • All bot messages are render-only. Buttons carry custom_ids encoding request id + action. Modal only for deny-with-reason.
  • The four external strings (Application ID, Public Key, OAuth Client ID/Secret, Bot Token) arrive via wrangler secrets / .dev.vars; the implementation must run its full e2e against a mock Discord layer so the loop is verifiable before real credentials exist.

parley skills

Prints a complete agent-facing usage document (markdown): every command, config format, the ask/approval flow, invite/join onboarding, examples. Written so an agent can set up parley for its human end-to-end. parley skills --write writes it to .claude/skills/parley/SKILL.md under the cwd (creating dirs), for persistence across agent sessions.

Ephemerality

The relay stores: accounts (Discord id, username, avatar, token hash), contacts, unexpired invite codes, and in-flight requests only. Delivered, declined, or timed-out requests are deleted immediately. No message content at rest beyond the lifetime of a request. State minimal by design: "the service keeps nothing" is a feature.

CLI surface (complete)

parley login                 # Discord OAuth → token
parley logout
parley invite                # mint single-use code (48h)
parley join <code>           # redeem an invite (pending until inviter taps Connect)
parley contacts              # list contacts + online status
parley remove <name>         # sever a contact
parley ask @<name> "<q>"     # blocking ask
parley listen                # foreground responder
parley install|uninstall     # launchd daemon for the responder
parley status                # daemon + connection status
parley logs                  # tail responder logs
parley skills [--write]      # agent-facing usage doc

Implementation constraints

  • Backend: Cloudflare Workers + Durable Objects, TypeScript, wrangler. D1 acceptable for accounts/contacts if cleaner than DO storage; keep it minimal either way.
  • CLI: Bun + TypeScript.
  • Monorepo layout: relay/, cli/, shared types in shared/ if needed.
  • House style applies (Effect optional — do not force it; prefer plain, clean TS if that ships a tighter v1).
  • E2E test: full loop (login stubbed, invite → join → connect, ask → gate 1 run → draft → gate 2 approve → answer; plus deny, deny-with-reason, offline-fail, timeout) against mock Discord + local relay (wrangler dev or miniflare). Must run headless via bun run test.
  • No AI attribution anywhere. No customer data in examples — synthetic names.