Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,58 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased] — 0.5.0

Channels. 0.4.0 made an approval small enough to fit in a chat message — one
screenshot, one sentence, two answers — and then left it in a browser tab.
A channel is where that message goes, and in approval mode the answer can come
back from there instead of from the phone.

Everything here is additive. No existing call, type or outcome changes.

### Added

- **`channels?: HandoffChannel[]`** on `raiseHand`. Each channel's `notify` is
called once, as soon as there is something to send: the link in takeover
mode, the link and the screenshot in approval mode. It is never awaited, and
a throw or a rejection is one `channel_failed` warning — a chat API that is
down costs you a notification, not a browser session.
- **`ChannelHandoff`, the view an adapter gets.** A discriminated union on
`mode`, like `RaiseHandOptions`: a takeover carries `handoffId`, `url`,
`reason` and `mode`; an approval adds the `action`, the `screenshot` as the
decoded JPEG the phone is looking at (the same bytes, not a second shot of a
page that has moved on), and `answer()`.
- **`answer("approve" | "deny")` settles the handoff in-process**, through the
same path a relay `approve` takes. The first answer wins whoever gives it —
phone or channel — and the loser is told: `answer()` returns `false` when the
handoff was already settled, because an approval sent to two places at once
is *meant* to be answerable twice and losing that race is ordinary, not an
error. The relay still gets its `ended` message, so a phone that is open
shows the ending; nothing else about a settled handoff changes.
- **`HandoffEvent.answeredVia`**, `"relay"` or `"channel"`, present on the
`approved` and `denied` outcomes only. Optional and additive.
- **`docs/adr/0007`** on why a channel is an in-process hook rather than a
second human WebSocket client — the relay accepts one human peer and replaces
it, so an adapter that connected would throw the phone off the handoff.
- **`handraise-telegram`**, the first adapter, written against this release in
its own package: the screenshot with Approve/Deny buttons in a Telegram chat,
answered by long polling, no public callback endpoint to host. It is not on
npm at the time of writing; this release is what it needs in order to be.

- **`ChannelHandoff.settled`**, a promise that resolves with the outcome the
moment the handoff ends — an answer from the phone, an answer from a channel,
the timeout, a dead session, a handback. It never rejects, it stays resolved,
and it is the same promise for every channel of one handoff. It carries the
outcome the *caller* gets: a handback that turns into `disconnected` because
the session died during the cookie capture reaches channels as
`disconnected`, not `resolved`.

This is the signal that lets an adapter stop. Without it one that waits for a
reply can only stop on its own clock: measured on `handraise-telegram`, a
handoff answered on the phone 500 ms in left the adapter polling and the Node
process alive for another 20 s with a 20 s budget — five minutes fifty at its
default, holding the bot's single update slot the whole time.

## [0.4.0] - 2026-09-02

Approval mode. A capability gap ("I can't do this": 2FA, a captcha) and an
Expand Down
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,55 @@ and `RaiseHandOptions` is now a union (extend `HandoffOptions` or
`TakeoverOptions` instead of it). The
[CHANGELOG](CHANGELOG.md) has the detail.

## Channels

An approval is a screenshot, a sentence and two answers — which is a chat
message. A **channel** is an object handraise notifies when the handoff starts;
in approval mode it also gets the JPEG and can answer in-process, so nobody has
to open the link at all.

```ts
import { raiseHand } from "handraise"
import { telegram } from "handraise-telegram"

const { TELEGRAM_BOT_TOKEN = "", TELEGRAM_CHAT_ID = "" } = process.env

await raiseHand(page, {
mode: "approval",
reason: "The agent may not move money without a human",
action: "Submit $12,430 vendor payment to Acme GmbH",
channels: [telegram({ botToken: TELEGRAM_BOT_TOKEN, chatId: TELEGRAM_CHAT_ID })],
})
// The screenshot and two buttons arrive in the chat; the first answer wins,
// whether it comes from there or from the phone.
```

Write your own in about ten lines: `notify(handoff)` gets `handoffId`, `url`,
`reason`, `mode`, `settled` and — in approval mode — `action`, `screenshot` (the
same bytes the phone shows) and `answer("approve" | "deny")`, which returns
`false` if somebody was faster. `notify` is never awaited and whatever it throws
is one `channel_failed` warning: a chat API that is down costs you a
notification, not a browser session. Anyone who can see the channel can answer
it ([`docs/adr/0007`](docs/adr/0007-channels.md)).

**`settled` is how a channel knows it can stop.** It is a promise that resolves
with the outcome the moment the handoff ends — however it ended, including on
the phone or by timeout — and it never rejects:

```ts
const channel = {
notify: async (handoff) => {
const message = await post(handoff)
const outcome = await Promise.race([waitForReply(message), handoff.settled])
await close(message, outcome)
},
}
```

Without it an adapter that waits for a reply can only stop on its own clock,
which means holding a connection open — and the process alive — long after
`raiseHand` has returned.

Runnable without writing any code: [`demo/try.ts`](demo/try.ts) raises a hand
immediately so you can drive it; [`demo/approval.ts`](demo/approval.ts) asks
you to approve a payment; [`demo/github-2fa.ts`](demo/github-2fa.ts) does the
Expand Down Expand Up @@ -184,14 +233,16 @@ takes the 700ms; the ending says which one happened.

## Getting notified

Three ways, no vendor lock-in:
Four ways, no vendor lock-in:

- **QR code in the terminal** (default) — scan with the phone camera.
- **`onUrl` callback** — do whatever you want with the link.
- **`webhookUrl`** — handraise POSTs `{ url, reason, mode, action?, sessionId }`
as JSON (`action` only in approval mode).
Point it at Slack, Discord, ntfy, a Telegram bot — anything that accepts a
POST.
- **`channels`** — the only one that can carry the screenshot and bring an
answer back. See [Channels](#channels).

```ts
await raiseHand(page, {
Expand All @@ -213,6 +264,7 @@ await raiseHand(page, {
| `timeoutMs` | `number` | 5 minutes | How long to wait for the human. |
| `webhookUrl` | `string` | — | Generic JSON POST when the link is ready. |
| `onUrl` | `(url) => void` | — | Called with the handoff URL. |
| `channels` | `HandoffChannel[]` | — | Where else to announce it. In approval mode a channel also gets the screenshot and can answer. See [Channels](#channels). |
| `qr` | `boolean` | `true` | Print a QR code to the terminal. |
| `apiKey` | `string` | `$SOLARI_API_KEY` | Solari key used to create the relay sandbox. |
| `logger` | `Logger` | warn/error only | Structured logging sink. Pass `consoleLogger` for full JSON lines incl. the per-handoff wide event. |
Expand Down
157 changes: 157 additions & 0 deletions docs/adr/0007-channels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# 0007 — Channels: an in-process hook, not a second WebSocket client

- **Status:** accepted
- **Date:** 2026-09-02

## Context

Approval mode (ADR [0006](0006-approval-mode.md)) made the handoff small enough
to fit in a chat message: one screenshot, one sentence, two answers. That is
also where approvals actually happen — nobody watches a terminal for a QR code
at 23:00, and the last consequence of that ADR was left open on purpose:
"notifying a chat channel with the screenshot and two buttons … belongs to the
channel adapters, not to this library".

So handraise needs a seam that a `handraise-telegram` or `handraise-slack`
package can sit on. What already existed does not carry an approval:

- **`onUrl` and the QR code** deliver a link and nothing else.
- **`webhookUrl`** POSTs `{ url, reason, mode, action, sessionId }`. It is
one-way by construction, it has no picture, and getting an answer back would
mean the caller runs a public callback endpoint.

An adapter needs three things a link cannot give it: the screenshot bytes, the
handoff id, and a way to send the answer back into a `raiseHand` call that is
already awaiting.

## Decision

**One optional array of in-process objects**, notified once per handoff:

```ts
await raiseHand(page, { mode: "approval", reason, action, channels: [telegram({ … })] })

interface HandoffChannel { notify(handoff: ChannelHandoff): void | Promise<void> }
```

`ChannelHandoff` is a discriminated union on `mode`, exactly as
`RaiseHandOptions` is. A takeover carries `handoffId`, `url`, `reason` and
`mode` — there is nothing to decide and no still image worth sending, because
the human has to drive. An approval additionally carries the `action`, the
`screenshot` as the decoded JPEG the phone is looking at, and `answer()`.

Four properties, each of them load-bearing.

**`notify` is called when there is something to send, from inside the
handoff.** In takeover mode that is as soon as the relay is up, before the
screencast starts. In approval mode it is after `captureApprovalFrame`, because
a message with the link but without the picture is the webhook that already
exists. That is why the call sits in `runHandoff` and not next to `onUrl` in
`raiseHand`.

**A channel cannot break or delay a handoff.** `notify` is not awaited, and a
synchronous throw and a rejected promise are the same thing: one
`logger.warn("channel_failed", { error })`. A chat API that is down must cost
the caller a notification, never a browser session — the same rule `onUrl`,
`onEvent` and `webhookUrl` already follow.

**`answer()` settles through the same path a relay `approve` takes.** Both go
through one `answerHandoff(outcome, via)` guarded by the flag the first settle
sets, so the first answer wins whoever gives it, the relay still receives the
usual `ended` message (the phone shows its ending), and nothing about the
settled handoff changes: no `storageState` capture, no input, no second wide
event. The wide event grows one field, `answeredVia: "relay" | "channel"`, set
on `approved` and `denied` only.

**`answer()` returns a boolean, not void and not a throw.** An approval sent to
a phone and a chat channel at the same time is *meant* to be answerable twice;
losing that race is the ordinary case, not an error. The adapter has to render
it — Telegram edits its message to "already decided elsewhere" — and a `false`
is the smallest thing that says so. A throw would mean writing a `try` around
the happy path for a routine outcome; `void` would leave the adapter unable to
tell a decision from a no-op.

## Alternatives

- **The adapter as a second human WebSocket client.** Rejected, and this is the
decisive one: the relay accepts exactly one human peer and a new one replaces
the old, so an adapter that connected would throw the phone off the handoff.
It would also arrive with neither the screenshot bytes nor the `handoffId`
until the relay replayed them, i.e. a round trip through infrastructure that
is already holding the same data in-process, and it would need the bearer URL
handed to it anyway. The in-process hook is smaller and more honest: an
adapter is a listener, not a second human.
- **Extend `webhookUrl` with a callback URL for the answer.** Rejected: it
makes every adapter a public HTTP endpoint the caller has to host and secure,
which is precisely the thing "no server to host" says handraise does not ask
for. (Slack does need one for its interactivity endpoint — that is Slack's
constraint, and it is why Telegram, which long-polls, is the first adapter.)
- **Ship a Telegram client inside handraise.** Rejected: a bot token, a chat id
and a vendor's API shape in the core, for a feature most callers do not use.
The generic hook is ~90 lines; the vendor code lives in its own package with
its own release cycle, and a second one cannot break the first.
- **`answer()` returns a promise that resolves when the relay has acknowledged
the ending.** Rejected as premature: the adapter needs to know whether it won
the race, which is knowable synchronously, not whether the phone's socket got
the message, which it cannot act on either way.
- **A `channel` singular option.** Rejected for no reason beyond arithmetic: an
approval that goes to Telegram *and* pages an on-call is one array today
instead of a breaking change later.

## Consequences

- **`HandoffEvent` gains an optional `answeredVia`.** Additive; absent on every
outcome that is not an answer, so nothing that reads the event has to change.
- **The screenshot is decoded once per approval that has channels.** It is held
base64 for the wire; a channel gets `Buffer.from(data, "base64")` — the same
bytes, not a second screenshot of a page that may have moved on. Callers with
no channels pay nothing.
- **Whoever holds the channel holds the decision.** A chat channel has members,
and any of them can press Approve. That is the same trust boundary the bearer
URL always had, moved somewhere more comfortable — an adapter's README has to
say so, and `handraise-telegram`'s does.
- **A channel is told when the handoff ends, by `settled`.** See the amendment
below; the first adapter made the case for it before the second one existed.

## Amendment, 2026-09-02: `settled`

The consequence above said a `settled` promise was the clean fix and should
wait for a second adapter. The first one settled the question by itself.

`handraise-telegram` long-polls Telegram while an approval is open, and with no
signal that the handoff ended it could only stop on its own clock —
`maxWaitMs`, six minutes by default. Measured on that package: a handoff
answered on the phone 500 ms in left the adapter's timer and its in-flight poll
alive, and the Node process exited **20.5 s later** with `maxWaitMs: 20_000`.
At the default that is a script that prints its result and then sits there for
five minutes fifty, holding the bot's single `getUpdates` slot — so a second
run started inside that window is refused with a 409. Nothing about that is
specific to Telegram: any adapter that waits for a reply has the same shape.

So `ChannelHandoffBase` gains:

```ts
settled: Promise<HandoffOutcome>
```

Resolved once, on every path — an answer from the phone, an answer from a
channel, the timeout, a dead session, a handback or a give-up in takeover mode.
It never rejects, so an adapter can await it without a guard, and it stays
resolved, so awaiting it after the fact returns immediately.

Three details are decisions rather than mechanics:

- **It carries `finalOutcome`, not the outcome the human gave.** A handback
that wins the promise while the browser session is dying is reported to the
caller as `disconnected`, and a channel that had been told `resolved` would
post the wrong ending into a chat that outlives the process.
- **It resolves before teardown**, at the earliest point the outcome is final.
An adapter that stops there releases its connection while the relay sandbox
is still shutting down, rather than after.
- **It is the same promise for every channel of one handoff.** One handoff has
one ending; two adapters must not be able to see different ones.

Rejected: an `onSettled` callback (a second failure surface to catch, for
something that happens once), and resolving it with the whole `HandoffEvent`
(the event is the caller's, and a channel does not need frame counts to decide
whether to stop polling).
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pre-publish security review — they document the history, they do not invent it
| [0004](0004-separate-agent-secret-closed-message-set.md) | Agent role via a separate secret, and a closed human message set | accepted | Security review |
| [0005](0005-handoff-not-wall-detection.md) | handraise is the handoff mechanism, not the wall detection | accepted | Scope decision |
| [0006](0006-approval-mode.md) | Approval mode: one screenshot, a hold on yes | accepted | Scope decision |
| [0007](0007-channels.md) | Channels: an in-process hook, not a second WebSocket client | accepted | Scope decision |

## Format

Expand Down
81 changes: 81 additions & 0 deletions e2e/handoff.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,87 @@ try {
await askApproval("approve")
await askApproval("deny")

// --- An approval answered by a channel, not by the phone ---------------
//
// The path a Telegram or Slack adapter takes: handraise hands the channel
// the screenshot and an `answer()`, and nobody opens the link at all. The
// in-process channel here stands in for the adapter; what is under test is
// the core's side of it against the real relay.
const channelAt = Date.now()
let channelUrl = ""
let channelEvent: HandoffEvent | undefined
let channelShot = 0
const channelAnswered = raiseHand(page, {
mode: "approval",
reason: "The agent may not move money without a human",
action: APPROVAL_ACTION,
qr: false,
timeoutMs: 60_000,
onUrl: (url) => {
channelUrl = url
},
onEvent: (raised) => {
channelEvent = raised
},
channels: [
{
notify: (raised) => {
if (raised.mode !== "approval") return
channelShot = raised.screenshot.length
check(
raised.action === APPROVAL_ACTION,
"the channel is handed the action verbatim",
)
check(
raised.url === channelUrl && channelUrl !== "",
"the channel is handed the same link the phone would open",
)
check(
raised.answer("approve") === true,
"the channel's first answer settles the handoff",
)
check(
raised.answer("deny") === false,
"a second answer from the channel is refused",
)
},
},
],
})
pending = channelAnswered
const channelResult = await channelAnswered
pending = null
timings.channelApprovalMs = Date.now() - channelAt
log("channel_approval_done", {
outcome: channelResult.outcome,
answeredVia: channelEvent?.answeredVia,
screenshotBytes: channelShot,
ms: timings.channelApprovalMs,
})

check(
channelResult.outcome === "approved",
"an approval answered by a channel reports approved",
)
check(
channelEvent?.answeredVia === "channel",
`the wide event says who answered (${channelEvent?.answeredVia})`,
)
check(
channelShot > 1000,
`the channel got the real JPEG, not an empty buffer (${channelShot} bytes)`,
)
check(
channelResult.storageState === undefined,
"a channel-answered approval captures no cookies either",
)
const channelGone = await fetch(channelUrl, { cache: "no-store" })
await channelGone.text()
check(
channelGone.status !== 200,
`the channel-answered relay is gone (${channelGone.status})`,
)

// --- The cheap second case: nobody comes -------------------------------
const timeoutAt = Date.now()
let secondUrl = ""
Expand Down
Loading
Loading