Architecture
August 19, 2026 · View on GitHub
The README is the entry point; this page holds the deeper technical documentation: how the ask flow works, the full configuration schema, the wire protocol, the security model, and the roadmap.
How it works
Every installation is both a potential client and a potential server — there is no hub, broker, or shared database. Peers are just configured addresses, each side keeps its own copy of the relationship, and the wire protocol is one JSON document both sides already speak.
Outbound. The model gets ask_peer(peer, question, contextFiles?) (plus
peers_list, ask_peers, ask_peer_async, and ask_result). The tool POSTs
the question to the peer's ask server over a small JSON protocol.
Inbound. The peer's server verifies the caller against the allowlist (a
shared token, or a signature against the stored friend sign), applies the
per-friend policy, then creates a fresh agent in the engineer's workspace,
feeds it the question, and streams the committed answer back. The answer is
session-level: the answering agent receives a copy of the targeted session's
recent conversation as context (explicit sessionId, or the latest session),
so it answers from accumulated knowledge. The live session is never modified
or interrupted, and answering agents cannot call ask_peer back — no ask
loops.
Consent. By default the answering agent is told to investigate and explain
without mutating anything, every permission request from an ask-owned agent is
auto-rejected, and its session runs under the harness read-only sandbox —
enforced by the same bash/filesystem sandboxes as the rest of dsh, so an ask
can never create or modify a file in the host's workspace. Set allowExecution: true only when you explicitly want remote agents to be able to act (that mode
is not wired yet; the knob is the future hook).
Discovery. Advertisements (GET /advertise) carry a description, topic
tags, workspace, and the agent's sessions; the asking side refreshes them on
an interval. The model calls peers_list to see who knows what, who is
reachable, and how fresh the metadata is — then picks deliberately instead of
guessing.
Friend discovery. When no current friend matches, the model can call
recommend_peer(peer, topic?): the peer's server scores its own friends
against the topic (live advertised tags/description weigh most), fetches the
best match's signed friend card from the friend itself, and returns it. The
asking side verifies the card and surfaces it to the owner as a
friend/recommend chat bubble (and a session-independent toast) with
Add/Decline. On Add, the card is re-verified and the recommended agent is
merged into the friend list (replacing by name) and persisted — the same
settings channel the Settings page uses.
Discovery is transitive but bounded: when the peer knows nobody matching
and hops remain (maxHops, default 1 via the tool, capped at 3), it forwards
the request to its own friends — each signed by the forwarder, with the
visited chain (path) attached so loops are impossible. Per-node fan-out is
capped (2), so the request tree stays small and a "who knows X?" never
becomes an asking storm. The response carries the via chain
(e.g. ["carol", "ada"]), which the bubble/toast renders as recommended via
carol → ada. Forwarding only adds attribution, never trust: the returned
card is always verified against the recommended agent's own signature.
Answering agents resolve their provider/model from the harness
agent-default-model service (the same default the Web/headless entry points
use); config.provider / config.model override it per deployment.
Configuration
The full schema lives in src/config.ts; the tables below are the summary.
The profile patch is the bootstrap; UI-owned knobs are persisted to
<keyDir>/settings-<callerName>.json and override these values live.
| Key | Type | Default | Meaning |
|---|---|---|---|
callerName | string | 'local' | Identity presented to peers on outbound asks; peers allowlist this name. |
keyDir | string | ~/.dsh-ask-peer/keys | Directory holding this agent's Ed25519 signing keys (private key never leaves). |
description | string | '' | Self-advertised description shown to asking agents (what you know). |
tags | string[] | [] | Self-advertised topic tags used by asking agents for peer selection. |
rosterRefreshMs | number | 60000 | Roster refresh interval in ms; 0 disables periodic refresh (initial fetch still runs). |
listen | boolean | false | Start the inbound ask server. |
listenHost | string | '127.0.0.1' | Bind host for the ask server; keep loopback unless you trust the LAN. |
listenPort | number | 3877 | Bind port for the ask server. |
peers | PeerConfig[] | [] | Friends we may ask; also the inbound caller allowlist. |
requireToken | boolean | true | Require a matching shared token on every inbound ask. |
workspace | string | process.cwd() | Workspace the answering agent runs in. |
provider | string | — | Provider route for answering agents; harness default when omitted. |
model | string | — | Model for answering agents; harness default when omitted. |
timeoutMs | number | 120000 | Default outbound ask timeout in ms. |
maxAnswerChars | number | 48000 | Answer character cap; longer answers are cut and marked truncated. |
approvalTimeoutMs | number | 120000 | How long an inbound ask in ask mode waits for the owner before failing closed. |
allowExecution | boolean | false | Let ask-owned agents request tool permissions (future execute-mode hook; not wired yet). |
Each PeerConfig entry:
| Key | Type | Default | Meaning |
|---|---|---|---|
name | string | — | Peer name, matching the peer's callerName on its own side. |
host | string | — | Host or IP of the peer's ask server. |
port | number | 3877 | Port of the peer's ask server. |
token | string | — | Optional shared secret; the peer requires it when requireToken is on. |
publicKey | string | — | The peer's sign (ed25519:...); replaces the shared token as the trust root (asks/adverts must be signed by it). |
mode | 'ask' | 'auto' | 'deny' | 'ask' | Inbound policy for this friend. |
description | string | — | Optional note describing whose agent this is. |
Wire protocol
POST /ask with:
{
"protocolVersion": 1,
"caller": "ada",
"token": "shared-secret-bob",
"question": "How do I stand up the local dev environment?",
"contextFiles": ["docker-compose.yml"]
}
200:
{ "ok": true, "answer": "...", "truncated": false }
Errors use { "ok": false, "error": { "code", "message" } } with 400/403/500.
GET /health→{ "ok": true, "peer": "<callerName>" }GET /advertise→ live roster metadata (name, description, tags, workspace, sessions,updatedAt)POST /recommend→{ "protocolVersion", "caller", token/sign, "topic"? }; authenticated like an ask; returns{ "ok": true, "from", "card" }with the best-matching friend's signed card. Matching scores each friend's LIVE advertisement (tags, description, session topics, signature-verified) with a fallback to the cached roster context; when no topic is given or nothing matches, it falls back to the best-known friend so a referral is still produced. OptionalmaxHops/pathfields enable bounded transitive forwarding; responses include aviachain when the card was discovered through other agentsGET /recommend/pending→ the recommendations waiting for the owner's decision (recId, from, card display fields, decision channel)POST /friend/decision→{ "recId", "token", "decision": "add"|"decline" }; onaddthe card is re-verified and the peer merged into the friend listPOST /ask/decision→{ "askId", "token", "decision" }; the browser posts the owner's approve/decline here (CORS-enabled), with the one-time token from theask/requesteventGET /ask/status?askId=→ ask lifecycle (pending → running → answered / declined / failed; results expire after 10 minutes)GET /sign/card/POST /sign/verify→ signed friend-card build/verify
The advertisement is unauthenticated roster metadata (low sensitivity); the ask/recommend endpoints stay protected by the allowlist and token/signature.
Event families
The replayable event families the Web client folds into conversation nodes:
ask/request→ask/decision→ask/result— inbound asks (pending → running → answered / declined)friend/recommend→friend/decision— friend-card recommendations (pending → added / declined); the decision event is emitted after the settings merge succeeds
Security model
- The server binds to
127.0.0.1by default; listen on the LAN only if you trust it (or sit behind a VPN). TLS is not implemented yet. - Inbound callers must be allowlisted by name, and every ask carries the
shared token when
requireTokenis on — or, when the peer entry has apublicKey, a signature over the canonical request verified against that key (impostors are rejected with 403). Advertisements are signed the same way. - Answer mode is read-and-explain with enforced read-only: the answering
agent's session runs under the dsh read-only sandbox (
sandbox/mode=read-only), so bash and filesystem writes are denied by the sandbox itself, not just by a prompt instruction. Permission requests are auto-rejected as a second, independent layer. - Inbound asks are gated by per-friend policy (
ask/auto/deny); inaskmode the owner approves or declines in the UI, and the ask fails closed when no approval answerer is available. - Each ask runs in a fresh, short-lived agent that is disposed afterwards; it never touches your interactive sessions or their model context.
Layout
| File | Role |
|---|---|
src/index.ts | Plugin entry: name, inject, Config, apply |
src/config.ts | Schemastery config schema (peers, listen, policy) |
src/protocol.ts | The JSON wire contract (/ask, /health, /advertise) |
src/registry.ts | Peer lookup + live roster state; discovery seam |
src/peer-client.ts | Outbound ask over HTTP (host half) |
src/events.ts | The replayable ask event family (ask/request / decision / result) |
src/roster.ts | Periodic advertisement refresh |
src/server.ts | Inbound server: validation, allowlist, token check, settings + friend-card endpoints |
src/run.ts | Runs one question in a fresh agent, collects the answer |
src/tool.ts | ask_peer, peers_list, and ask_peers tools |
src/card.ts | Signed friend cards (build/parse/verify for one-paste friend adding) |
src/client/ | Browser half: the ask conversation node + bubble renderer |
src/client/AskPeerSettings.tsx | The Ask Peer settings page (sign + friend card, local knobs, friends) |
src/settings.ts | The ask-peer settings namespace incl. UI-editable local knobs (hot-reloaded) |
tsdown.config.ts | Builds lib/client.js (the browser bundle) |
cordis.patch.yml | Bundle layer that inserts the plugin row |
Roadmap
- Discovery — mDNS/UDP broadcast so peers on a LAN find each other without manual address lists; later a company directory or DHT for the wide area.
- Execute mode — let the answering agent act on the asker's behalf with
explicit, visible approval per action (the
allowExecutionknob exists as the future hook). - Reputation — each asker privately tracks which peers give useful answers per topic and prefers them over time.
- Experience capsules — beyond ask/answer: agents publish condensed "how I did X" notes with tags, peers subscribe and search them, and questions can fall back to capsules when no live agent is available.
- TLS + identity — mutual TLS or signed peer keys instead of shared secrets.