Very Happy owns Agent Teams coordination; organization-specific integrations remain outside the core: the server and CLI expose a small set of stable public surfaces, and external adapters (bots, IM bridges, schedulers) compose them. This document is the contract for those surfaces. The in-app summary lives at Settings → Channels.
There are two directions:
- Outbound — the server calls your HTTPS endpoint when a session needs attention (account webhook).
- Inbound — external automation drives sessions through the CLI on the
machine that runs your daemon (
very-happy spawn/very-happy send), plus an optional stdio MCP server (very-happy mcp) that gives a plainclaudeacopy_to_clipboardtool.
| You want to… | Configure here |
|---|---|
| Receive completion / permission notifications | Account webhook, or Settings → Channels |
| Show an external task list in the Todo panel | Todo provider, in ~/.happy/settings.json on that machine |
| Dispatch work from a script, scheduler, or IM bridge | very-happy spawn / very-happy send |
| Let that script see and steer what it dispatched | very-happy sessions |
| See every session on the account, and which ones are waiting on a human | very-happy sessions list --all |
| Approve or deny a pending permission request from a script | very-happy sessions approve / deny |
| Let Very Happy's coordinator dispatch Claude sessions | Web Assistant / meta-agent |
| Add clipboard handoff to a plain local Claude | very-happy mcp |
| Organize Claude, Codex, and managed pi as a team | Agent Teams getting started and migration |
| Run something on a schedule, or start / continue a session from an external event | very-happy auto — Automations |
MCP is a handoff surface into the Web workspace, not a claim that every runner has the same tool set:
| Runtime path | Tool surface |
|---|---|
| Base managed Claude session | change_title, copy_to_clipboard, open_preview, report_progress |
| Managed Codex / Gemini / ACP bridge | change_title, copy_to_clipboard, open_preview |
Managed Claude (in-process), Codex / Gemini / ACP (the stdio bridge forwards them) and pi (HAPPY_MCP_URL, discovered via tools/list) additionally |
team_* (Agent Teams) and the B-496 Automations tools: automation_list, automation_get, automation_create, automation_update, automation_pause, automation_resume, automation_delete, automation_run, automation_fire, automation_runs, automation_report, automation_ack — account authority, same trust level as the session; and the B-497 session peer tools session_message, session_peers (message / list the other sessions on this machine — and, since B-506, on the account's other machines: session_message to a foreign session is routed to its machine, session_peers takes machineId; see very-happy sessions peers / message) |
| Voice Assistant / legacy assistant variant additions (Claude, in-process) | sessions_list, session_read, session_send (both follow the B-506 route for sessions another machine spawned; optional machineId), session_spawn, session_kill, session_archive, terminals_list, terminal_read, terminal_send, memory_update, journal_append |
User-scoped very-happy mcp (plain claude, pi, …) |
copy_to_clipboard only |
User-scoped very-happy mcp inside a vh web terminal (VH_TERMINAL_ID set by the daemon's tmux terminal) |
+ change_title, open_preview (titles/previews for that terminal via authenticated daemon IPC) |
User-scoped very-happy mcp inside a meta-agent session of a non-Claude runner (HAPPY_SESSION_VARIANT=assistant, legacy compatibility only) |
copy_to_clipboard + sessions_list, session_read, session_send, session_spawn, session_kill, session_archive |
The first two paths are injected by their managed runners. The assistant-only
additions can read and mutate sessions, terminals, memory, and journals; treat
that variant and its prompt/tool permissions as a high-privilege machine
control surface. The standalone very-happy mcp command is narrower: outside a
meta-agent session it is clipboard-only, and even inside one it never exposes
terminal management, memory, journal, provider routing, or progress
tools. A terminal context additionally enables title and file-preview tools. It also stays clipboard-only under a happy-managed Claude
(HAPPY_MANAGED=1), so a Claude assistant that happens to have the user-scoped
registration too does not see the session tools twice. External automation
should use the explicit CLI contracts below.
OUTBOUND (notifications)
┌──────────┐ session events ┌─────────────┐ POST {"title","message"}
│ sessions │ ────────────────► │ happy-server│ ─────────────────────────►┐
└──────────┘ done/permission/ └─────────────┘ (account webhook, │
question last line `session: <id>`)
▼
┌────────────────────┐
│ your adapter / │
│ notify gateway │
│ (e.g. Tanka bot) │
└─────────┬──────────┘
│ forwards to group chat;
INBOUND (control) │ user quote-replies
┌──────────┐ spawn-session / ┌─────────────┐ very-happy spawn │
│ daemon │ ◄──────────────── │ very-happy │ ◄──────────────────────┤
│ (per │ clipboard │ CLI │ very-happy send │
│ machine)│ └─────────────┘ (adapter shells out) │
└────┬─────┘ │
│ persists session keys parses `session: <id>`┘
▼ from the quoted notification
~/.happy/sessions.json
One deployment adapter pattern is an IM bridge: webhook notifications land
in a group chat, a [happy] <task> message spawns a new session, and a
quote-reply to any notification is piped straight back into that session.
One webhook per account. When a session event fires — agent turn finished, permission request, or clarifying question — the server POSTs a small generic JSON to your endpoint. Designed for notify-gateway style receivers, but any HTTPS endpoint accepting the JSON works.
Web terminals (bare tmux claude, no session channel) feed the same webhook:
the daemon watches each terminal's agent state at list-track cadence and, on a
stable working→idle / →needs_input transition (2-tick debounce, ≥60s
per-terminal cooldown, only after the terminal has been seen working), POSTs
/v1/webhook/notify with event + a /terminal/<machineId>?tid=<id> link —
so the account's events toggles gate these exactly like session events.
Terminal notifications carry no session: trailer (there is no session).
All three endpoints require the account bearer token
(Authorization: Bearer <token>).
| Method | Path | Body | Semantics |
|---|---|---|---|
GET |
/v1/webhook |
— | {"webhook": {"url", "events"} | null} |
POST |
/v1/webhook |
{"url": "...", "events": ["completed","permission"]} |
Create or replace (an account has at most one webhook). events optional; defaults to both. 400 with {"error": "..."} on invalid URL. |
DELETE |
/v1/webhook |
— | Remove the webhook. |
POST |
/v1/webhook/notify |
{"title": "...", "message"?: "...", "sessionId"?: "...", "taskId"?: "...", "event"?: "completed"|"permission", "link"?: "/<web path>"} |
Notification forwarder: the server pushes {title, message} through the account's webhook. Without event it is a MANUAL notification (the web's "mark done" ✓ uses this — ✅ 已完成 · <名>), not gated by events (an explicit user action is always wanted). With event it is an AUTOMATIC one (the daemon's web-terminal agent-state notifications use this) and IS filtered by the webhook config's events — unsubscribed events return delivered:false without sending. Returns {"ok": true, "delivered": bool}; delivered:false when no webhook is configured, the event is unsubscribed, or delivery failed. Rate-limited per account (30/min → 429). sessionId adds the link line + session: <id> trailer; taskId adds a task: <id> line before the session trailer; link (a web-app path starting with /, ≤300 chars, e.g. /terminal/<machineId>?tid=<terminalId>) appends a 链接:<HAPPY_WEB_URL><link> line right after the message (omitted when HAPPY_WEB_URL is unset). Old clients send neither new field and keep the legacy behavior. |
Event categories:
completed— the agent finished its turn and the session is idle (push kinddone).permission— needs attention: permission requests and clarifying questions (push kindspermissionandquestion).
URL validation (SSRF guard, enforced on save and re-checked at send time):
https:// only, max 2048 chars, no userinfo, and literal
loopback / private / link-local / CGNAT hosts are rejected. Redirects are
refused at delivery time.
Delivery is best-effort: 5s timeout, no retry, failures only logged. Never build anything that depends on guaranteed delivery.
{
"title": "✅ 任务完成 · <session title, ≤60 chars>",
"message": "<headline>\n会话:<session title>\nAgent:<provider>\n链接:<web url>/session/<id>\nsession: <id>",
"sessionId": "<id>"
}title— heading, prefixed by event emoji:✅ 任务完成(completed),⏸ 需要确认(permission),❓ 等待回答(question).message— plain-text lines. TheAgent:line only appears when the event carries a provider; the链接:line only appears when the server hasHAPPY_WEB_URLset (see below).sessionId— duplicated as a top-level field for receivers that parse JSON. Generic text-only gateways drop it — which is exactly why the id is ALSO embedded in the message text:
The session: trailer (stable contract). When the event has a session id,
the last line of message is always exactly:
session: <sessionId>
This line is fixed and machine-parseable, and it survives any text-only relay
(gateway → IM → quoted reply). Adapters should extract the session id from
this trailer, not from the JSON field. Regex: /^session: (\S+)$/m (take the
last match).
The server does not reliably know its own public web origin, so clickable
session links are an explicit opt-in: set the env var HAPPY_WEB_URL (e.g.
https://happy.example.com) on happy-server to get a
链接:<base>/session/<id> line in every webhook message. Unset, the message
still carries the bare session: <id> trailer.
External automation talks to sessions through two CLI subcommands
(v0.2.28+), running on the machine where the Happy daemon runs. No extra
credentials — the CLI reuses the daemon's. Under the hood, spawn rides the
daemon's local control server (POST /spawn-session on 127.0.0.1), and
message delivery encrypts the user envelope with the session key from
~/.happy/sessions.json and POSTs it to the server's
/v3/sessions/:id/messages outbox — the exact same path the web client uses.
very-happy spawn --dir <path> [--prompt <text> | --prompt-file <file>] \
[--spawned-by <name>] [--permission-mode <mode>] [--agent <name>] \
[--model <id>] [--env KEY=VALUE]... [--json]
very-happy spawn --fork <sessionId> [--prompt …] [--permission-mode <mode>] \
[--model <id>] [--spawned-by <name>] [--json]Defaults match the Web launcher (B-492). Permission mode and the first
message's model come from the same table the launcher uses
(AGENT_CODE_DEFAULTS in packages/happy-wire/src/agentCodeDefaults.ts):
claude / codex / pi start in yolo, gemini / openclaw in default; a claude
session's first message pins claude-opus-5-5 (or the machine default when
the wrapper does not advertise claude-opus-5-5-v1) with effort: null,
exactly as resolveMessageModeMeta does for a fresh Web session. Account-level
overrides from the Web settings page are not applied: they are encrypted
with the account content key, which dataKey CLI credentials do not hold.
--dir, -d <path>— working directory for the new session (required; must already exist — spawn refuses to create directories).--prompt, -p <text>/--prompt-file <file>— optional first user message (mutually exclusive; file is read as UTF-8). Without either, the session is spawned idle.--spawned-by <name>— the spawn origin. The session is born carrying it as its tag: a chip in the Web list, searchable as#<name>, so dispatched work stays distinguishable from sessions the user opened by hand. The value must read as a tag — 1-24 chars of[a-z0-9]plus-/_, starting with a letter or digit — and the CLI rejects anything else up front rather than handing an unattended adapter a healthy but silently untagged session. Omit it and the session is simply untagged. A daemon predating the field strips it and still spawns, so a missing tag is not a spawn failure.--permission-mode <mode>—default,acceptEdits,plan,yoloorbypassPermissions. Omitting it gives the launcher default (yolo for claude / codex / pi — before B-492 it wasdefault); a fork keeps its source's mode. Adefaultsession stops at the first tool that is not already allowed, waiting for a human to approve in the Web UI. For an unattended dispatcher that is a hang, not a prompt: watch for thepermissionwebhook / pollsessions list --alland answer withsessions approve/deny. The CLI rejects an unknown mode instead of passing it on, because the daemon's own behaviour for an invalid mode is to drop it and spawn without the flag — silently giving youdefaultagain.--agent <name>—claude(default),codex,gemini,openclaworpi.piruns through the pi-acp adapter (very-happy pi= the generic ACP runner withpi-acp), so the daemon machine needs bothpiandpi-acpon the daemon's PATH (npm install -g @earendil-works/pi-coding-agent pi-acp@0.0.33). The daemon reports that ascliAvailability.pi; the Web launcher offers pi only when a daemon reports the field, and enables it only when it istrue, and the daemon's/spawn-sessionrefusesagent: 'pi'while it isfalse—spawn --agent pithen exits 1 with the install hint instead of starting a wrapper that dies onspawn pi-acp ENOENT(invisible from the daemon). pi has no permission layer of its own and pi-acp exposes no ACP mode selector, so--permission-modenever reaches pi-acp; the runner sanitizes it (default | acceptEdits | plan | bypassPermissions,yolo→bypassPermissions, anything else dropped) and hands it to the pi side out-of-band:HAPPY_PERMISSION_MODEin the pi-acp child env at spawn, and for live switches (Web picker /sendmeta)<happy home>/session-modes/<HAPPY_SESSION_ID>.json({ "permissionMode": "...", "updatedAt": ms }, 0600, atomic rename; written on start and on every switch, removed when the wrapper exits). The Web picker switches through the same file whether the session is idle (messagemeta) or working (set-permission-modeRPC). What is written there is also published asmetadata.permissionMode, so the Web shows the effective value. Enforcement is provided by the CLI's official pi runtime extension, which re-reads the mode file before each tool call.defaultasks for non-read-only tools;planblocks writes;acceptEditspermits edit/write but still asks for shell and other mutations;bypassPermissionsis honored only when explicitly selected. User-installed extensions may enforce additional rules. The runtime does not remove them or require a private supervisor wrapper. pi's approvals surface as ACPrequest_permissioncards in the Web UI (a pi extension callingctx.ui.confirm()produces one), andPI_ACP_PI_COMMANDin the daemon's environment lets you point the adapter at a user wrapper. The official launcher preserves this override, so private wrappers and user-configured extensions can still impose extra permissions or models. Runvery-happy teams doctor --host piin the daemon environment to inspect the command source before migration. Remove obsolete private bindings deliberately after auditing their settings; the installer never removes them. pi-acp prints pi's startup banner (version, skills, extensions) as the first assistant message of every session; setquietStartup: truein~/.pi/agent/settings.jsonon the daemon machine to silence it. What a pi tool call carries in the Web (B-353): thetool-callevent'stoolNameis still the ACPkind(execute/read/edit/other, so older Web builds render as before), and itsargsadditionally carry the ACPacpTitle,acpKind, the pass-throughrawInput(pi's tool arguments; dropped withrawInputTruncated: trueabove 64 KiB) and a derivedpiTool(execute→bashwithcommand= the title; otherwise the title when it is a plain tool identifier such asread,edit,write,session_spawn).acpTitle/acpKind/rawInputare generic ACP passthrough (Gemini, OpenCode,acp -- <cmd>get them too);piTool/commandare only derived when the agent ispi, so the Web may treatpiToolas evidence that a session is pi. bash output streamed via pi-acp's_meta.terminal_outputis accumulated and sent as thetool-call-endresult.text(isError+[exit code N]suffix when non-zero, last 64 KiB kept). The permission request'sargumentsgainacpTitle/acpKind/messagefrom the gate's confirm payload so the ask card can show the rule id and reason instead of a bareother. All of these fields are optional; a Web build that does not know them ignores them.--model, -m <id>— model for the session, carried by the first message (so it needs--prompt/--prompt-file; for an idle spawn pass it on the firstsend --model).default= the machine's own default.--fork <sessionId>— the Web's "fork session" from the CLI: copies the source conversation (Claude JSONL copy / Codex app-serverthread/fork) and continues it in a new session withparentSessionIdlineage, in the source's directory and agent (--dir/--agentare then optional and must match). The source must be a session this machine's daemon spawned; its current metadata is read from the server (the spawn-time copy insessions.jsonlacks the provider id). The daemon must be B-492 or newer: it answersresumed: truefrom/spawn-session; an older daemon strips the resume fields and starts a fresh session, whichspawn --forkthen stops and reports as an error (the copied conversation file stays behind).--env KEY=VALUE— extra environment for the session process; repeatable. A${VAR}reference is expanded against the daemon's own environment, and an unresolved reference fails the spawn rather than starting a session with a literal${VAR}in its environment. Useful because a spawned session inherits the daemon's environment, not the dispatcher's.--json— machine-readable output:{"sessionId", "url", "permissionMode"}(the effective mode), plus"spawnedBy","agent","forkedFrom"and the"model"actually sent when they apply. Without it, a human-readable line with a clickable session URL is printed.
Requires the daemon to be running (same semantics as spawning from the web: an offline machine cannot spawn). It will not auto-start the daemon.
Exit codes:
| Code | Meaning |
|---|---|
0 |
success |
1 |
spawn failed — no session was created |
2 |
session created, but sending the first message failed (the session exists; the URL is still printed) |
very-happy sessions list [--all [--include-archived]] [--machine <id>] [--tag <name>] [--limit <n>] [--json]
very-happy sessions read <id> [--machine <id>] [--limit <n>] [--full] [--answer] [--wait [--timeout <s>]] [--json]
very-happy sessions stop <id> [--json]
very-happy sessions archive <id> [--json]
very-happy sessions approve <id> <requestId> [--for-session] [--json]
very-happy sessions deny <id> <requestId> [--reason <text>] [--json]
very-happy sessions peers [--scope repo|cwd|machine] [--cwd <dir>] [--machine <id>] [--json]
very-happy sessions message <id> <text> [--reply-to <msgId>] [--machine <id>] [--json]Across machines (B-506, CLI ≥ 0.2.157 on both sides;
spec). read, message,
list --all and peers --machine reach sessions another machine of the
account spawned: the CLI asks the machine that holds the session to run the
operation with its keys and returns the plaintext result. The calling
machine never receives the account content key or another machine's session
key — that is the deliberate alternative to B-337. Mechanics: one short-lived
user-scoped socket, an rpc-call to <machineId>:sessions.list|read|send|peers|message
(the same channel the web uses for spawn / resume), answered by the owning
daemon as a plaintext JSON method (RpcHandlerManager.registerPlainHandler).
Same-account isolation is the server's room routing (rpc:<userId>:<method>),
so another account's daemon is unreachable by construction. The target daemon
whitelists exactly those five methods, checks the request shape, caps text at
64 KB, transcripts at 200 KB and turn.answer at 64 KB, allows 60 read
(list / read / peers) and 30 write (send / message) calls per minute in total,
abandons any call at 25 s with a timeout code (a send refuses to POST after
18 s, so an ambiguous failure never turns into a late duplicate; the CLI
retries once with the same localId, which the server deduplicates),
writes one audit line per call to its daemon log
([REMOTE SESSION OPS] <method> from machine=… host=… cli=… → ok|<code>), and
refuses everything with remoteSessionOps: "off" in its ~/.happy/settings.json.
Without --machine the online machines are asked newest-active first which
one holds the session (sessions.list { ids }); offline machines are skipped
(no daemon = no plaintext source; their sessions are unreachable until they
come back), and machines whose daemon reports a CLI older than 0.2.157 are
skipped or refused up front (the server exposes lastHappyClient on
GET /v1/machines). A daemon that does not answer surfaces as
"did not answer: its daemon is offline, restarting, or runs a CLI older than
0.2.157" rather than a silent 30 s hang. approve / deny / stop /
archive stay local-only.
Ask and collect (B-492). read reports where the latest turn stands:
turn in --json = { userSeq, ended, status, error, answer, lastSeq }, where
answer is the agent's reply to the latest prompt — the text it wrote after its
last tool call, untruncated. --wait polls (every 3 s, window of the newest 500
messages) until a turn-end follows the latest prompt, then reads; it exits 2
when --timeout (default 600 s) runs out, still printing the partial state.
--answer prints only that reply; --full lifts the 500-char per-line cap of
the transcript. A dispatcher's whole loop is therefore:
id=$(very-happy spawn --dir "$WORKDIR" --prompt-file task.md --spawned-by bot --json | jq -r .sessionId)
very-happy sessions read "$id" --wait --timeout 1800 --answer > reply.md
very-happy send --session "$id" --prompt "follow-up" # then read --wait againA prompt sent while a turn is running only counts as answered after its own
turn-start … turn-end, so the previous turn ending does not end the wait.
Background tasks (B-507). A turn ending is not the session going quiet:
an async-launched sub-agent, a run_in_background command or a Monitor keeps
running and wakes the session with a notification later. Every list row and
the summary of read carry backgroundTasks = { count, tasks[], reportedAt?, stale? }, each task { id, type, description, startedAt } (type is the SDK
task type: local_bash, local_agent, monitor, …). The wrapper reports the
set on every change and renews it every 60 s; the local list reads it from
the daemon (lease 150 s, cleared when the wrapper exits), --all from the
session's agentState (same lease, plus the server's active flag; stale: true means a set was reported but its lease ran out — count is 0). The field
is absent on a daemon or CLI too old to report it: absent means unknown,
not zero. Rules for a supervisor or an auto-archiver: count > 0 → the
session is busy even though turn.ended is true; do not archive it, do not
call the work done. read --wait still returns on turn-end — check
summary.backgroundTasks.count afterwards if the task may fan out. Only the
Claude runner has this notion; Codex and pi rows never carry the field.
Automations runs (very-happy auto) apply the same rule: a spawn/send run
whose turn ended with background tasks still running stays running until
they finish and the wake-up turn ends (a never-ending Monitor therefore holds
the run until the server's maxRuntimeMs).
spawn and send start work; these let an external agent layer see it and
intervene — the same four operations the built-in assistant has over MCP, now
reachable without being the assistant.
list— running sessions first (in daemon order), then the most recently seen.--limitcaps only the not-running tail; running sessions are never cut.--tag <name>keeps the sessions born with that origin tag, which is how an adapter finds its own work among everything else on the machine. Terminal-mirror shadow sessions are never listed: they mirror what the user is already doing in a terminal and are not dispatchable work.read— the tail of a session as a role-tagged transcript (--limitmessages, default 20, max 100).stop— SIGTERM the session's process via the local daemon.archive— mark the session inactive server-side; it stays resumable.
Scope: list (without flags) / stop / archive ask the local daemon;
read needs the session key, which is in ~/.happy/sessions.json for
sessions this machine's daemon spawned (pruned after 14 days) — any other
session of the account is read through its own machine as described above.
list --all widens the listing to the account over REST (newest 150) and, for
rows this machine cannot decrypt, asks the online machines to fill them in:
every row carries decryptable (this machine's own key), readable
(decrypted here or by its machine), and for proxied rows via (the answering
machine id) and machine: { id, host }; the text form shows via=<host> and
[running on <host>]. --json adds machines: { asked, skipped } saying
which machines were consulted and which were skipped (offline / too old).
Rows no online machine holds keep only the server's plaintext columns.
Exit codes: 0 success, 1 anything else. Note that stop on a session the
daemon is not running exits 1 — a caller asking to stop something must be
able to distinguish "stopped it" from "there was nothing to stop".
list --all asks the server (GET /v1/sessions, newest 150) instead of the
local daemon, so it sees sessions on every machine of the account — but it
can only read the ones this machine holds a key for. The server stores each
session's metadata and agentState encrypted with that session's key, and
a CLI holds only the keys it persisted itself in ~/.happy/sessions.json.
Every row therefore carries decryptable:
decryptable: true— spawned by this machine's daemon (≤14 days ago). The row hastitle,cwd,machineId,flavor,tags, andpending: the permission / question requests currently waiting on a human, oldest first, each withid,tool,createdAtandwaitingMs.attentionistruewhenpendingis non-empty — this is the "needs me" signal a supervisor polls for.decryptable: false— belongs to another machine. Only the server's plaintext columns are present:active(a wrapper is attached),archived,activeAt,updatedAt,url.title/cwd/pendingare unreadable, not empty; do not infer "no pending requests" from their absence.
Ordering: attention rows first (longest-waiting request first), then sessions
running under this daemon (live), then the rest newest-first. --limit
caps only that idle tail. --tag can only match decryptable rows. Archived
rows are hidden unless --include-archived. --json adds fields to the
local list shape and never renames one.
Making the foreign rows full-fidelity (and making read / approve work on
them) is a credentials change — the CLI would need to hold the account
content key the way the web does — not a flag on this command. Until then,
run the poller on each machine that dispatches work.
A default-mode session stops at the first tool that is not already allowed
and waits for a human. approve/deny send the wrapper exactly what the web
permission card sends — a session RPC permission with
{ id, approved, decision } (approved, approved_for_session with
--for-session, or denied plus an optional reason) — over a short-lived
user-scoped socket authenticated with the CLI's account token. A plain tool
approval carries no mode and no allowTools, on purpose.
Two facts the caller should know:
- The RPC payload is encrypted with the session key, so like
readthis works only for sessions in this machine's~/.happy/sessions.json. Same scope limit, same fix (above). - The wrapper ignores an unknown request id silently (it logs "already
resolved" and returns success). The CLI therefore reads the session's
agentStatefirst and refuses to send unless<requestId>is actually pending (exit 1 listing the ids that are), then re-reads for up to 5s after the ack and reportssettled: true|falsein--json—falsemeans the wrapper acknowledged but had not yet written the request out of the pending set when we stopped waiting, not that it refused.
Exit 1 with a precise reason when: no local key; the session is not on
this account (404); the request is not pending; no wrapper is online for the
session (RPC method not available, or it disconnected mid-call); the RPC
timed out (30s); the wrapper's handler returned an {error} envelope. With
--json, every one of these still prints a record on stdout
({sessionId, requestId, error} for the pre-RPC refusals, the full result
with outcome.status for the RPC-level ones), so a poller never has to parse
an empty string. Find <requestId> in sessions list --all (pending[].id)
or in the permission webhook.
B-497. Several sessions (Claude, Codex, pi, and a claude typed into a Very
Happy web terminal) often work in the same repository at once. peers shows
who else is live here and what each touched lately; message puts a note into
one of them. The same two operations are the session_peers /
session_message MCP tools every managed session gets, so two agents can
coordinate directly — no lock, no permission change.
peers— live sessions on this machine, excluding the caller. Scoperepo(default): same git repository, including its other worktrees (sameWorktreesays whether a row shares your checkout);cwd: the same directory;machine: everything. Each row carriesedits: the real paths the session called an edit tool on in the last 30 minutes (Claude Edit/Write/MultiEdit/NotebookEdit, Codex patches, pi write/edit — and, for a terminalclaudewith the mirror hooks installed, its transcript). Terminal mirrors are listed withkind: "mirror".message <id> <text>— the text lands in that session's chat as a user message headed[Very Happy session message <msgId> from "<title>" <sessionId>; agent …; cwd …]and ending with how to reply. The sender is the session named byVH_PEER_SESSION_IDwhen the command runs from a managed session's shell (every managed runner — Claude, Codex, pi — sets it for its child; it is a dedicated variable sovery-happy teams …in that shell keeps behaving as before), otherwisecli <user>@<host>(then the footer says there is no session to reply to).--reply-toquotes the peer's message id. Refused, exit 1, with the reason on stderr (and{delivered:false,error}on stdout under--json) when the target is not running, or is a terminal mirror (nothing reads a mirror's queue; the person at that terminal does). A target another machine spawned is delivered through that machine's daemon (B-506): the header gains; machine <host>naming the sender's host, the footer says the message came from a session on machine and thatsession_messageback to it is routed automatically;--jsonaddsmachine: { id, host }.--machine <id>skips the lookup.peers --machine <id>lists that machine's live sessions instead (scopemachineunless--cwdnames a directory there; repo identity is computed on the target).deliveredfollowsvery-happy send(B-501): the message goes throughdeliverToSession, sotruemeans a wrapper was attached before and after the POST; otherwise--jsoncarriesdelivered:false,stored(it is on the server, unread) andstatus.- Reply-loop guard. Inside a session,
session_messagerefuses the 9th message to the same target within 10 minutes with an error that tells the agent to stop replying (the footer already says not to reply just to acknowledge). The CLI form has no budget: a person is typing.
Edit conflicts. The daemon keeps a 30-minute table of real path → sessions
that edited it. When a second live session edits a path another one touched in
the window, both get a notice headed
[Very Happy edit conflict <id>; file <path>; peer "<title>" <sessionId>; …; peer edited <n>s ago]
that names the other session and suggests session_message — once per pair
of sessions per file per window, delivered as a steer into a running turn
(queued otherwise). Conflicts on several files between the same two sessions
within 10 s are coalesced into ONE notice listing every file (; more <n> in
the header, Files: in the body), and a pair gets at most 5 notices per
window. Each edit report carries the time the runner saw the call; the daemon
ignores anything older than the window, so replayed transcript history (a
terminal mirror's backfill, a re-read after the file was replaced, a daemon
restart re-adopting a terminal) never produces a notice. Nothing is blocked:
it is a heads-up, and the Web shows it as a card linking to the other session.
very-happy send --session <id> (--prompt <text> | --prompt-file <file>) [--model <id>] [--resume] [--machine <id>] [--json]--model switches the session's model with this message (default = machine
default); without it the session keeps its current model.
Pushes one user message into a session that is already running. A session
spawned by this machine's daemon (key in ~/.happy/sessions.json) is sent
to directly; any other session of the account is sent through the daemon of
the machine that spawned it (B-506, see sessions above — that machine must
be online and on CLI ≥ 0.2.157; --machine <id> names it, --resume then
resumes on that machine; --json adds machine: { id, host }).
The POST itself rides the server REST outbox, but the server stores a
message for any session — archived or dead included — so a 2xx never
meant a wrapper would read it (B-501). send therefore classifies the
session first, from the local daemon's /list and GET /v1/sessions/:id:
status |
meaning | send does |
|---|---|---|
live |
a wrapper is attached: tracked by this daemon, or server active with activeAt within 15 min (another machine) |
send, then re-check |
archived |
archivedAt set (Ctrl-C, sessions archive, web archive) |
refuse (exit 3) unless --resume |
offline |
not archived, no live wrapper (exited, or presence timed out after 10 min) | refuse (exit 3) unless --resume |
not_found |
server 404 — not on this account | refuse (exit 3); cannot be resumed |
--resume brings an archived / offline session back on this machine the
same way the web's Restore does: unarchive (archived only) → daemon
/resume-session (the local twin of the resume-happy-session RPC; resume-precheck:*
reasons are passed through) → wait up to 30 s until it is live → send. If the
daemon refuses, an unarchived session is re-archived again; a merely offline
one is left alone. A daemon older than this route answers "daemon too old to
resume sessions" — upgrade very-happy-cli and very-happy daemon start.
After a successful POST the state is re-checked; a wrapper that vanished in
between is reported as not delivered with stored: true (the message sits on
the server, unread). The same logic backs the assistant's session_send
(resume: true there).
--json on stdout:
{"sessionId":"…","url":"…","delivered":true,"status":"live","resumed":false}
{"sessionId":"…","url":"…","delivered":false,"status":"archived","resumed":false,"stored":false,"error":"Session … is archived — …"}
{"sessionId":"…","url":"…","delivered":false,"status":"archived","resumed":false,"stored":false,"error":"…","resume":{"ok":false,"error":"resume-precheck:cwd-missing: …"}}
{"sessionId":"…","url":"…","delivered":false,"status":"offline","resumed":false,"stored":true,"error":"Session … went offline while sending: …"}Exit codes: 0 delivered to a live wrapper, 1 bad args / unknown session
(no machine of the account holds it, or its machine is offline / too old) /
transport failure, 3 session not live (archived, offline, not found) or
resume failed — nothing was delivered.
The Web Assistant is the built-in path for asking one Claude coordinator to inspect and dispatch other Very Happy sessions. It is separate from an external IM adapter and currently spawns Claude sessions on one selected machine.
- Connect a machine whose daemon can start Claude structured sessions.
- Open Settings → Voice & Assistant and select the Assistant machine.
- Review Skip permission approvals. It is convenient for dispatch, but it grants the coordinator a high-privilege machine-control surface.
- Open Assistant (or
/assistant) and ask with an absolute workspace path, for example:在 /srv/project 派一个会话修复登录测试,然后汇报结果。
The assistant uses session_spawn to return a new session immediately; it does
not wait for that worker to finish. Use sessions_list, session_read, and
session_send to follow up (session_send refuses an archived / offline
session unless resume: true, same rules as very-happy send above). ~ may be expanded, but explicit absolute paths
are the least ambiguous. Automatic cross-machine or cross-provider routing is
not shipped today.
Remote SDK sessions get the happy MCP server injected automatically, but a
plain claude loads only the OS user's normal MCP config. Register once for
that OS user:
claude mcp add --scope user very-happy-clipboard -- very-happy mcpBecause this uses --scope user, it gives every Claude session for that OS user
a copy_to_clipboard tool; it is not bound to a Very Happy terminal. Text is
forwarded to the local daemon over its 127.0.0.1 control server
(POST /clipboard), relayed
over the authenticated machine socket, and fanned out to the clipboard of
every web client the user has open. Payloads over 256KB are truncated.
The new-session dialog no longer offers Meta agent. Ordinary managed
Claude, Codex, and pi sessions use official team_* tools for team work;
see Agent Teams. The /assistant voice interface and
existing variant: assistant sessions remain compatible. The variant is not
a team identity and does not import historical work into Teams.
Legacy HAPPY_SESSION_VARIANT=assistant subprocesses may still expose the
older sessions_* tools. Those tools operate on local sessions and lack the
Teams task/attempt/acceptance model. Keep them for existing voice/history
compatibility, not as the recommended way to start a new coordinator.
Managed pi receives the official bridge and permission gate from the CLI
through HAPPY_MCP_URL; a private supervisor wrapper or .mcp.json is not
required for Teams. Use very-happy pi for this path. Installing the shared
skill does not attach a bare pi process or provide its background inbox.
A plain Web-terminal MCP registration remains a separate clipboard/title/preview
handoff. VH_TERMINAL_ID identifies that terminal; when both it and
HAPPY_MCP_URL exist, the managed session endpoint takes precedence to avoid
duplicate title tools. very-happy mcp discovers the daemon using
HAPPY_HOME_DIR; use the correct isolated home when testing a dev daemon.
In Todos → My todos, choose Let AI use my todos to copy the complete
official skill
and the current server address. Paste it into your coding agent. Clipboard failure
shows selectable instructions. The copied content contains no login credentials.
The public document is served at /skills/very-happy-todos/SKILL.md.
Use a CLI with very-happy todo --help support. very-happy todo skill prints the
same instructions without requiring authentication. The agent verifies the existing
CLI login and expected server, then reads todo list; setup never writes test tasks.
Existing external providers remain separate.
Commands: todo list, todo get ID, todo add --id UUID --title TEXT [--note TEXT],
todo edit ID --version N [--title TEXT] [--note TEXT], and
todo complete|reopen|delete ID --version N. Use --title=VALUE / --note=VALUE
for text beginning with --. Successful data commands output {serverUrl, result};
list results include records, truncated, and invalidCount. Mutations require
the version returned by a read. Retry uncertain creation with the same UUID and
content; reconcile other uncertain writes by reading, never by blind overwrite.
Deleted IDs cannot be reused. CLI and Web share the same account KV schema and CAS.
Managed Claude, new Codex threads, and pi/ACP sessions receive a short discovery
hint. Resumed existing Codex threads do not receive a new first-turn hint; run
very-happy todo skill explicitly there. Host skill directories are not modified.
Scoped Teams sessions cannot use account-wide Todo commands. Task text is data,
not authorization to execute its contents or complete tasks automatically.
For maintainers, edit packages/happy-wire/src/builtinTodoSkill.ts as the text
source; Web clipboard and CLI output import it. Update the public Markdown copy
together and run pnpm -C packages/happy-wire exec vitest run src/builtinTodoSkill.test.ts
to verify exact equality. Compare installed todo skill output while allowing
only its extra printing newline, not arbitrary whitespace normalization.
Verify three separate paths: the deployed Markdown body equals the source (an HTTP 200 alone can be an SPA fallback); the page copies the complete instructions and exposes manual copy on failure; the installed CLI reads the intended account on the expected server. Do not infer that an installer makes a skill discoverable in every runner: verify prompt injection at each runner's actual first/resume boundary. Creation retry tests must cross client/process boundaries, since an in-memory retry map alone cannot protect separate CLI invocations.
No setup is required for Todos → My todos. The built-in list belongs to your account and syncs without an online machine. It supports editing, completion, reopening, deletion and manual order. The following optional integration appears under Todos → External source; it does not replace or import the built-in list.
For AI-assisted setup, give your agent the provider skill
(published at /skills/very-happy-todo-provider/SKILL.md). Official OAuth connectors
are not included yet. External create/complete actions write to the selected
provider, and task execution by an agent does not automatically complete a Todo.
Happy can show an external todo system in its web Todo panel (/todos) and
let you tick items off and add new ones, without Happy knowing anything about
that system. You supply a command; Happy runs it on the machine its daemon
lives on and speaks a small text contract to it.
Nothing is stored on the Happy server: the panel reads through the machine at view time and writes straight back out. There is no sync, no cache, and no second copy of your tasks.
There is no bundled account connector for Dida365/TickTick, Todoist or Linear, and installing the CLI does not connect a task account. The repository includes a local JSON-file reference provider; it is a demo task source, not an integration with those services.
-
On the machine selected in the Todo panel, save
todo-provider-jsonfile.mjsto a permanent location (for example/Users/you/tools/todo-provider-jsonfile.mjs). Install Node.js if it is not already available. Runcommand -v nodeto get its absolute path; use that path so the daemon does not depend on your shell's PATH. -
Run the example with a dedicated file. Replace the paths below with your own absolute paths. This creates only a demo task:
/absolute/path/to/node /absolute/path/to/todo-provider-jsonfile.mjs --file /absolute/path/to/demo-todos.json create "Try Todo" /absolute/path/to/node /absolute/path/to/todo-provider-jsonfile.mjs --file /absolute/path/to/demo-todos.json list -
Merge this property into the existing settings file; do not replace its other fields. Use
~/.happy/settings.jsonfor the default daemon, or$HAPPY_HOME_DIR/settings.jsonfor a daemon started with an isolated home.{ "todoProvider": { "command": "/absolute/path/to/node", "args": ["/absolute/path/to/todo-provider-jsonfile.mjs", "--file", "/absolute/path/to/demo-todos.json"] } } -
Return to Todo → External source, select the same machine, and choose Retry or Refresh. Settings are read for each request, so no daemon restart is needed. Confirm the demo task appears, create another task in the panel, and complete it. The next
listcommand should reflect both changes.
To connect a real task system, supply an adapter implementing the contract below and complete that system's authentication on the daemon machine. Test all three operations with a disposable task before changing the provider configuration. Keep credentials in the provider's local credential store or environment, never in command arguments or provider stdout.
If setup fails, check that the selected machine is online, the absolute paths
exist for the daemon user, and list writes only the expected JSON to stdout
(send logs to stderr). An empty list means the provider returned no tasks;
“not configured” means this daemon's settings have no provider.
Add a todoProvider block to that machine's local ~/.happy/settings.json:
Why this is machine-local and cannot be set from the web UI: the command runs as arbitrary code on that machine. The daemon already exposes
bash, so this is not new capability — but who gets to choose the command would be a new attack surface. Keeping it in the local settings file means a hijacked web session cannot turn it into remote code execution.
With no todoProvider configured the panel simply reports that the machine has
no provider; nothing is spawned.
Happy invokes your command three ways. Arguments are passed as a real argv list — no shell is involved, so quotes and semicolons in a task title are just characters, never syntax.
<command> [args...] list # → JSON on stdout
<command> [args...] complete <id> # → exit code is the result
<command> [args...] create <title> # → exit code is the result
Exit 0 means success. On failure, exit non-zero and write something useful
to stderr: Happy shows that text to the user verbatim, so permission denied for project X is far more helpful than a silent failure.
list must print JSON shaped like this:
{ "items": [
{ "id": "abc", // REQUIRED — what `complete` will be called with
"title": "Write the weekly report", // REQUIRED
"status": "open", // optional: "open" | "done" (default "open")
"due": "2026-08-20", // optional, shown as-is
"priority": "high", // optional: "none" | "low" | "medium" | "high"
"group": "Work", // optional, used to group rows
"note": "…" } // optional
] }Only id and title are required. Unknown fields are ignored, so you can
return whatever else your system produces and add fields later without breaking
older Happy clients. Items missing id or title are dropped (the panel tells
the user how many); at most 500 items are shown.
The output of complete and create is not parsed — only the exit code is.
Different backends return wildly different bodies, and parsing them would weld
one backend's shape into Happy. After either call the panel re-runs list, so
what you see is always the external system's real state rather than an
optimistic guess.
packages/happy-cli/examples/todo-provider-jsonfile.mjs implements the whole
contract against a plain JSON file. It has no dependencies and talks to no
service, so you can point todoProvider at it to see the panel work end to end,
then copy its shape for your own system:
happy_dir=~/.happy
"$PWD/packages/happy-cli/examples/todo-provider-jsonfile.mjs" \
--file "$happy_dir/todos.example.json" create "Try the todo panel"
"$PWD/packages/happy-cli/examples/todo-provider-jsonfile.mjs" \
--file "$happy_dir/todos.example.json" listA real provider is usually a thin shim over an existing CLI or HTTP API — the author's own is ~40 lines wrapping two personal task tools.
Pseudocode for a quote-reply IM adapter — a pattern that implements:
# One-time setup:
# POST /v1/webhook {url: "<gateway ingest URL that forwards to the group>",
# events: ["completed", "permission"]}
# Server-side: set HAPPY_WEB_URL for clickable links (optional).
on im_message(msg):
# Fail closed before treating chat text as remote-machine input.
if not allowed_sender(msg.sender) or not allowed_chat(msg.chat):
audit("rejected", msg)
return
if duplicate(msg.id) or rate_limited(msg.sender):
return
# 1) New task from chat: "[happy] fix the flaky test"
if msg.text.startswith("[happy] "):
prompt = msg.text.removeprefix("[happy] ")
workdir = allowed_workdir(msg.chat) # fixed map; never take a path from msg
out = run(["very-happy", "spawn",
"--dir", workdir,
"--prompt", prompt,
"--json"])
if out.exit_code in (0, 2): # 2 = session exists, msg failed
reply(msg, f"session started: {json.loads(out.stdout)['url']}")
else:
reply(msg, "spawn failed")
return
# 2) Reply routed back into a session: quote-reply to a notification
if msg.quoted_message is not None:
m = last_match(r"^session: (\S+)$", msg.quoted_message.text)
if m:
out = run(["very-happy", "send",
"--session", m.group(1),
"--prompt", msg.text])
react(msg, "✅" if out.exit_code == 0 else "❌")
Design notes:
- Authenticate and authorize the sender and chat before parsing commands.
The
session:trailer is a routing key, not authentication. Use allowlists, deduplication, rate limits, and an audit log; reject by default when identity cannot be verified. - Map chats to fixed, allowlisted workspace roots. Never accept an arbitrary working directory or command from the message. Keep normal agent permission prompts unless your threat model explicitly allows otherwise.
- Parse the
session:trailer from the quoted text, not from stored state — it makes the adapter stateless and restart-safe. - Use
--prompt-filefor long or multi-line replies to avoid shell-quoting issues. - The adapter must run on the same machine as the daemon that spawned the sessions (that is where the session keys live).
- Run the adapter with the least-privileged OS user that can reach those workspaces. Never expose the daemon's loopback control server to a network.
- Webhook delivery is best-effort; treat notifications as hints, not a queue.
User guide (web, CLI, MCP, triggers, the board's decision band): automations.md.
Account-level automations (B-496, spec): a
trigger — cron with time zone, fixed interval, one time, or manual only — and an
action on one machine: spawn a session (optionally sticky: repeated events with
the same rendered key continue one conversation), send into a fixed session, or
run a script (argv, no shell). Every execution is an AutomationRun with a
status, session link, summary and attention flag. The server must enable
VH_AUTOMATIONS_ENABLED=true (server-wide; no per-account allowlist since
B-502). Each account holds at most MAX_AUTOMATIONS_PER_ACCOUNT automations
(default 100, active + paused): a create beyond that answers 429
{ error: 'automation_count_quota_exceeded', limit, count }, which the CLI and
Web explain in one line. A daemon talking to an old or gated server logs one
debug line per ten minutes and does nothing else, and the CLI says so in one line.
very-happy auto create --name daily-tanka --cron '0 9 * * 1-5' --tz Asia/Singapore \
--spawn-dir ~/work/ops --prompt-file prompts/tanka.md # spawn claude, web-launcher defaults
very-happy auto create --name on-mention --manual \
--spawn-dir ~/work/ops --prompt 'Reply to: {{payload.text}}' --agent codex \
--sticky-key '{{payload.conversationId}}' # same conversation → same session
very-happy auto create --name backup --every 6h --script -- /usr/bin/env bash -lc 'restic backup ~/notes'
very-happy auto fire on-mention --payload-json '{"conversationId":"c9","text":"hi"}' --dedupe-key msg-123 --wait
very-happy auto runs --attention; very-happy auto ack <runId>
very-happy auto report --status done --summary 'synced 12 items' # inside a run's session: --run defaults to $VH_AUTOMATION_RUN_IDFire is the only event entry point (no inbound webhook): an IM bot, watcher or
systemd unit shells out to very-happy auto fire <name> with a payload; the same
--dedupe-key within 24h returns the original run. Prompts, argv and sticky keys
take {{payload}}, {{payload.a.b}} (JSON payload), {{run.id}},
{{automation.name}} and {{now}}. Spawned sessions carry the #automation
tag and VH_AUTOMATION_RUN_ID / VH_AUTOMATION_NAME in their environment;
scripts also get VH_AUTOMATION_PAYLOAD. A run finishes when the agent calls
automation_report (exact; in a continued sticky session pass the runId from
the latest prompt header — VH_AUTOMATION_RUN_ID is the run that started the
session), or — without one — when the wrapper's turn ends (B-466 heartbeat) and
the daemon confirms it on the session log anchored on that run's own prompt,
taking the last assistant text (4KB) as the summary. Scripts finish on exit (tail 4KB of output;
timeout SIGTERM → SIGKILL). The daemon keeps ~/.happy/automation-receipts.json
so a run id is never spawned twice across restarts; an unknown launch outcome is
reported failed with attention instead of retried. Exit codes of fire --wait:
0 done, 2 wait timed out, 3 failed / expired / cancelled / skipped.
very-happy auto skill prints the official skill; very-happy teams install --host … --apply materializes it next to the Teams skill at
~/.local/share/very-happy/skills/very-happy-automations/SKILL.md (very-happy auto install does only this one), with the same ownership checks.
Teams use the same team_* tools in managed Claude HTTP MCP, Codex stdio MCP,
and the official pi runtime bridge. No assistant session variant is required.
The server must enable VH_AGENT_TEAMS_ENABLED=true (server-wide; no
per-account allowlist since B-502). A new daemon advertises teamsVersion:1; the Web
Teams page disables dispatch when that capability or a live machine is absent.
very-happy teams install --host claude|codex|pi previews the official skill at
~/.local/share/very-happy/skills/very-happy-teams/SKILL.md; add --apply to
materialize it there. All hosts use this same Very Happy-owned copy. The installer
never writes host discovery directories (.claude/skills, .agents/skills, or
.pi/agent/skills), which may point to shared repositories. It rejects symlinks
in the destination ancestry. Ask the managed agent to read the returned absolute
path; installation does not imply host auto-discovery. uninstall uses the same ownership
check and preserves user edits. A standalone terminal still needs a managed
Very Happy session identity; skill installation is not a transport or auth grant.
The server owns tasks, attempts, credentials and persistent operations. The daemon starts isolated worktrees and delivers messages through existing session queues. Acceptance is separate from process activity and resource cleanup. Unknown launch outcomes require verification, not blind retry. See the Teams contract for scope, limits, rollback and legacy ledger migration preview. This feature has not been deployed by the implementation task.
Install the native Pi extension once, then launch Pi directly in a Very Happy tmux terminal:
very-happy install-pi-tools
pi
# Existing Pi in that terminal can use /reload; normal Pi arguments still work:
pi --continue
# Remove only the installed Very Happy extension:
very-happy install-pi-tools --removeInstallation adds ~/.pi/agent/extensions/very-happy-terminal-tools.js (or under
$PI_CODING_AGENT_DIR/extensions); it preserves Pi settings and other extensions,
and refuses to overwrite an edited loader. The loader points to the installed
CLI, so rerun installation if you move the CLI installation. For explicit loading,
use pi -e ~/.pi/agent/extensions/very-happy-terminal-tools.js after installation.
The extension also names the terminal tab (B-500). Claude Code writes its task
summary to the terminal title itself; pi only writes π - <dir> until the pi
session has a name, and never names one. So on the first prompt of an unnamed
pi session the extension asks its bridge (very-happy mcp --terminal-tools,
a custom JSON-RPC method, not a tool the model sees) for a title — the same
claude -p --model haiku one-shot that titles managed sessions — and sets it
as the pi session name (/name). Pi rewrites its title as π - <name> - <dir>
and the daemon follows the name into the tab title. A tab renamed in the
sidebar, a session named with /name, or a title set through change_title
is never overwritten. The one-shot runs with HAPPY_MANAGED=1, so the
terminal-mirror hook ignores it. very-happy pi --terminal behaves the same.
For temporary loading without installation, very-happy pi --terminal [pi args]
is also available.
The extension provides change_title, copy_to_clipboard, and open_preview
only when the process has a valid VH_TERMINAL_ID. New Very Happy tmux terminals
provide this context; an existing external tmux pane may need a new Very Happy
terminal. It starts its CLI bridge on Pi session start and closes it on shutdown
or /reload, without adding a native permission gate. change_title changes the
Very Happy terminal name; open_preview accepts absolute, ~/…, or cwd-relative
paths and uses the existing file viewer and path checks. The machine must run the
matching CLI/daemon. very-happy pi still starts a managed ACP conversation;
managed HAPPY_MCP_URL and the temporary launcher's HAPPY_TERMINAL_MCP_URL take
priority, so the auto-discovered extension does not register duplicate tools.
Copy and preview history appears inside conversations and terminal sessions. Records are isolated by session or machine plus terminal ID and saved in account KV, so a browser need not be open when a call arrives. Viewing history does not automatically copy or open anything. Click Copy again or Open preview. Each scope retains up to 50 recent calls within a 240 KiB storage budget; repeated calls remain separate. Clipboard history retains up to 32 KiB per text (less for JSON-escaped content), explicitly marked when truncated. Live clipboard delivery still accepts up to 256 KiB. Saving history is best effort under server storage failures or overload, and a record confirms only receipt of a valid push, not that a device copied it or a person viewed it. Preview reopening requires the source machine and file to remain available. Older chat transcripts can supply previous clipboard calls and preview paths; old terminal calls lacking a terminal ID cannot be reconstructed. The separate device-local clipboard panel is unchanged.
{ "todoProvider": { "command": "/absolute/path/to/your-provider", // required "args": ["--source", "work"], // optional, fixed prefix args "cwd": "/optional/working/dir", // optional "timeoutMs": 20000 // optional, default 20s } }