Skip to content

feat(box): let the phone app read conversations, follow events and get notified - #207

Merged
sean-brydon merged 2 commits into
mainfrom
feat/phone-api-v2
Nov 2, 2026
Merged

sean-brydon merged 2 commits into
mainfrom
feat/phone-api-v2

Conversation

@pumfleet

@pumfleet pumfleet commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Second of the phone app's PRs (after #206; independent of it). The box side of the iPhone app: version 2 of the phone API on the tailnet listener (:1379), behind the existing token, Host check and lockout.

New phone routes (internal/box/phonev2.go, documented in docs/reference/phone-api.mdx)

  • GET /phone/v1/info now says version: 2 and capabilities.
  • GET …/sessions/{name}/transcript?since=&gen=: the recent conversation, copied field by field into the phone's own structs, so a field added to transcript.Item never reaches a phone by accident. No tool ids, uuids, signals, artifacts, crew details or record path; ! output cut to 2 KB; no ?before= or tool details.
  • GET /phone/v1/events?since=: NDJSON like /v1/events, but an allowlist that rebuilds each event: agent/session/run state and transcript.changed by name, never paths, logins, secrets or other events. Replays at most 500; at most 16 streams per box; a new code or access turned off ends them (the listener closes).
  • POST /phone/v1/push, DELETE /phone/v1/push/{id}: a phone app registers the key the push relay gave it. A phone can never set the relay (a laptop can, PUT /v1/phone {relay}, https only). New gate before:phone.push, events phone.push.added/removed. At most 10 phones.
  • POST …/sessions/{name}/interrupt: Stop through the driver, gated as session.send with from: "phone" (refactored out of interruptSession).

Notifications: notify() is split into a notice plus ntfy and relay deliveries, run side by side. The relay gets the same fields ntfy does (title, "loc/wt on box", session or run), never agent text. 410 forgets the phone, 5xx retries once, the result is noted per phone. Outbound goes through outboundPolicy() (public addresses only). GET /v1/phone lists phones without their keys; rotating the code or turning access off forgets them all. Capability phone.push for laptops.

Tests (phonev2_test.go): the projection leaks nothing; the stream carries only allowed types and fields; replay cap; stream cap; rotate ends streams; register, forget and rotate-clears; a phone can't pick the relay; registration gate; relay and ntfy both notified, no content; 410 forgets; 5xx retried and noted; loopback refused; interrupt gated. While writing these, -race caught a cursor closed while its reader was in Next; the phone stream closes it in the reader goroutine. streamEvents in api.go has the same pattern and is untouched here.

Docs: phone guide (what's new, the relay, threat model), phone API reference, events, security, changelog.

Checks: go vet ./..., go test -race ./..., docs-site build.

🤖 Generated with Claude Code

View guided diff Turn on auto-fix

…t notified

A box with phone access now also serves the phone API's version 2 on
its tailnet listener: an agent's recent conversation, copied field by
field so nothing new reaches a phone by accident; the events a phone
may hear, rebuilt without paths, logins or secrets; stopping an agent
through the send gate; and registering for notifications through the
push relay, which a laptop chooses and a phone cannot. A new code or
access turned off forgets every phone and ends their streams.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
berth Ready Ready Preview Nov 2, 2026 9:18pm UTC
berth-docs Ready Ready Preview Nov 2, 2026 9:18pm UTC

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

11 issues found across 11 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="docs/reference/events.mdx">

<violation number="1" location="docs/reference/events.mdx:148">
P3: The `phone.push.removed` row omits one trigger: turning phone access off also forgets every phone and emits this event (`internal/box/phone.go`, the `!c.Enabled` branch). Add it to the list of triggers.</violation>
</file>

<file name="docs/concepts/security.mdx">

<violation number="1" location="docs/concepts/security.mdx:23">
P3: This row under-describes what the push relay receives. The notice also carries the project (location) name and the run name, as the phone guide states. Name them so the security table matches the payload.</violation>
</file>

<file name="docs/changelog.mdx">

<violation number="1" location="docs/changelog.mdx:17">
P2: This says push is used 'instead of ntfy', but the code sends both when each is configured, and the guide says push works without ntfy. Change the wording to 'alongside ntfy'.</violation>
</file>

<file name="internal/box/phonev2_test.go">

<violation number="1" location="internal/box/phonev2_test.go:467">
P3: Check the errors from `Locations.Add` and `CreateWorktree` with `t.Fatal`. Otherwise a setup failure shows up as "ntfy heard nothing" after 5 seconds.</violation>

<violation number="2" location="internal/box/phonev2_test.go:468">
P3: These tests publish after a fixed 100ms sleep, assuming `Run` has subscribed by then. Under `-race` on a loaded CI runner, the event can be published before the subscription, and the test then fails with a timeout. Wait on an observable readiness signal instead of sleeping.</violation>
</file>

<file name="docs/reference/phone-api.mdx">

<violation number="1" location="docs/reference/phone-api.mdx:17">
P3: The tenth bad token still returns 401; only subsequent requests get 429 while ten failures remain in the window. Say the lockout starts after ten failures.</violation>
</file>

<file name="docs/guides/phone.mdx">

<violation number="1" location="docs/guides/phone.mdx:55">
P2: This promises no file contents or diffs reach the phone, but `phoneTranscript` forwards `text` items verbatim. Clarify that separate tool/file records are excluded while agent-written text may still contain them.</violation>

<violation number="2" location="docs/guides/phone.mdx:141">
P2: `sendRelay` uses `outboundPolicy`, whose `network.json` allowlist can permit private destinations. Qualify this as public-by-default and mention the allowlist override.</violation>
</file>

<file name="internal/box/phonev2.go">

<violation number="1" location="internal/box/phonev2.go:83">
P3: A malformed or overflowing `since` silently becomes zero and reloads the initial transcript page. Check the parse error and return `400 Bad Request`, as the event endpoint does.</violation>

<violation number="2" location="internal/box/phonev2.go:495">
P2: A registration authorized just before code rotation can add its phone after rotation clears `Push`; this update checks only `Enabled`, not whether the request token is still current. Revalidate the bearer token inside the update before persisting the registration.</violation>

<violation number="3" location="internal/box/phonev2.go:513">
P3: Evicting the oldest phone silently removes its registration, so event subscribers never learn that it stopped receiving notifications. Capture the evicted device and publish `phone.push.removed` as the other removal paths do.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread docs/changelog.mdx
box now also lets a paired phone read an agent's recent conversation
(never tool output or files), follow which agents and runs change as it
happens, stop an agent, and ask to be notified through Shipyard's push
relay instead of ntfy. Notifications still never carry what an agent

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This says push is used 'instead of ntfy', but the code sends both when each is configured, and the guide says push works without ntfy. Change the wording to 'alongside ntfy'.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/changelog.mdx, line 17:

<comment>This says push is used 'instead of ntfy', but the code sends both when each is configured, and the guide says push works without ntfy. Change the wording to 'alongside ntfy'.</comment>

<file context>
@@ -10,6 +10,13 @@ upgrades with `berth upgrade BOX`, or by running the
+  box now also lets a paired phone read an agent's recent conversation
+  (never tool output or files), follow which agents and runs change as it
+  happens, stop an agent, and ask to be notified through Shipyard's push
+  relay instead of ntfy. Notifications still never carry what an agent
+  wrote, a new code forgets every phone, and `before:phone.push` hooks can
+  refuse a phone. See [the phone API](/reference/phone-api).
</file context>
Suggested change
relay instead of ntfy. Notifications still never carry what an agent
relay, alongside ntfy. Notifications still never carry what an agent

Comment thread docs/guides/phone.mdx
[outbound requests](/guides/automations#outbound-requests)); the check is
made on the address actually dialled, redirects included.
- **The push relay** is chosen by a paired laptop, never by a phone, and the
box reaches it on public addresses only. It gets the same notice ntfy

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: sendRelay uses outboundPolicy, whose network.json allowlist can permit private destinations. Qualify this as public-by-default and mention the allowlist override.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/guides/phone.mdx, line 141:

<comment>`sendRelay` uses `outboundPolicy`, whose `network.json` allowlist can permit private destinations. Qualify this as public-by-default and mention the allowlist override.</comment>

<file context>
@@ -90,6 +137,9 @@ does not serve them.
   [outbound requests](/guides/automations#outbound-requests)); the check is
   made on the address actually dialled, redirects included.
+- **The push relay** is chosen by a paired laptop, never by a phone, and the
+  box reaches it on public addresses only. It gets the same notice ntfy
+  does, plus the key that names the phone, and keeps neither.
 
</file context>
Suggested change
box reaches it on public addresses only. It gets the same notice ntfy
box reaches it on public addresses by default; an owner allowlist can permit private destinations. It gets the same notice ntfy

Comment thread docs/guides/phone.mdx
Comment on lines +55 to +56
questions and their answers. Never tool output, thinking, file contents,
diffs or older pages;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This promises no file contents or diffs reach the phone, but phoneTranscript forwards text items verbatim. Clarify that separate tool/file records are excluded while agent-written text may still contain them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/guides/phone.mdx, line 55:

<comment>This promises no file contents or diffs reach the phone, but `phoneTranscript` forwards `text` items verbatim. Clarify that separate tool/file records are excluded while agent-written text may still contain them.</comment>

<file context>
@@ -47,11 +47,45 @@ The phone can:
+
+- an agent's recent conversation, as its chat shows it: prompts, replies,
+  one-line tool calls, the files it edited with their line counts, its
+  questions and their answers. Never tool output, thinking, file contents,
+  diffs or older pages;
+- what happens as it happens: which session or run changed, by name, so the
</file context>
Suggested change
questions and their answers. Never tool output, thinking, file contents,
diffs or older pages;
questions and their answers; it exposes no separate tool-output or file records,
but agent-written text can still include them. Older pages are not available;

Comment thread internal/box/phonev2.go
return err
}
_, err := p.update(func(c *PhoneConfig) error {
if !c.Enabled {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A registration authorized just before code rotation can add its phone after rotation clears Push; this update checks only Enabled, not whether the request token is still current. Revalidate the bearer token inside the update before persisting the registration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/phonev2.go, line 495:

<comment>A registration authorized just before code rotation can add its phone after rotation clears `Push`; this update checks only `Enabled`, not whether the request token is still current. Revalidate the bearer token inside the update before persisting the registration.</comment>

<file context>
@@ -0,0 +1,639 @@
+		return err
+	}
+	_, err := p.update(func(c *PhoneConfig) error {
+		if !c.Enabled {
+			return errors.New("phone access is off")
+		}
</file context>

Comment thread docs/reference/events.mdx
| `helper.removed` | `plugin`, `purged`, `attached`: one was stopped and removed, with its data when `purged`; `attached` counts the running services that still point at it. Not announced when nothing of the helper was on the box |
| `phone.changed` | `enabled` |
| `phone.push.added` | `name`, `platform`: a phone app asked to be notified (`origin: "phone"`) |
| `phone.push.removed` | `name`, `platform`: a phone app is no longer notified: removed from the phone or a laptop, forgotten with a new code, or `reason: "gone"` when the push relay said it no longer takes notifications |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The phone.push.removed row omits one trigger: turning phone access off also forgets every phone and emits this event (internal/box/phone.go, the !c.Enabled branch). Add it to the list of triggers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/reference/events.mdx, line 148:

<comment>The `phone.push.removed` row omits one trigger: turning phone access off also forgets every phone and emits this event (`internal/box/phone.go`, the `!c.Enabled` branch). Add it to the list of triggers.</comment>

<file context>
@@ -144,6 +144,8 @@ events.
 | `helper.removed` | `plugin`, `purged`, `attached`: one was stopped and removed, with its data when `purged`; `attached` counts the running services that still point at it. Not announced when nothing of the helper was on the box |
 | `phone.changed` | `enabled` |
+| `phone.push.added` | `name`, `platform`: a phone app asked to be notified (`origin: "phone"`) |
+| `phone.push.removed` | `name`, `platform`: a phone app is no longer notified: removed from the phone or a laptop, forgotten with a new code, or `reason: "gone"` when the push relay said it no longer takes notifications |
 | `turncheck.changed` | `enabled`, `provider`, `url`, `model`, `key` (a reference, or empty), `key_pasted`, `key_removed`; never a key's value |
 | `box.upgraded` | `build` |
</file context>
Suggested change
| `phone.push.removed` | `name`, `platform`: a phone app is no longer notified: removed from the phone or a laptop, forgotten with a new code, or `reason: "gone"` when the push relay said it no longer takes notifications |
| `phone.push.removed` | `name`, `platform`: a phone app is no longer notified: removed from the phone or a laptop, forgotten with a new code or when phone access is turned off, or `reason: "gone"` when the push relay said it no longer takes notifications |

}})
repo := gitRepo(t)
b.Locations.Add(ctx, "acme", repo)
wt, _ := b.Locations.CreateWorktree(ctx, "acme", "checkout", "", "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Check the errors from Locations.Add and CreateWorktree with t.Fatal. Otherwise a setup failure shows up as "ntfy heard nothing" after 5 seconds.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/phonev2_test.go, line 467:

<comment>Check the errors from `Locations.Add` and `CreateWorktree` with `t.Fatal`. Otherwise a setup failure shows up as "ntfy heard nothing" after 5 seconds.</comment>

<file context>
@@ -0,0 +1,591 @@
+	}})
+	repo := gitRepo(t)
+	b.Locations.Add(ctx, "acme", repo)
+	wt, _ := b.Locations.CreateWorktree(ctx, "acme", "checkout", "", "")
+	go p.Run(ctx, b)
+	time.Sleep(100 * time.Millisecond)
</file context>
Suggested change
wt, _ := b.Locations.CreateWorktree(ctx, "acme", "checkout", "", "")
wt, err := b.Locations.CreateWorktree(ctx, "acme", "checkout", "", "")
if err != nil {
t.Fatal(err)
}

repo := gitRepo(t)
b.Locations.Add(ctx, "acme", repo)
wt, _ := b.Locations.CreateWorktree(ctx, "acme", "checkout", "", "")
go p.Run(ctx, b)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: These tests publish after a fixed 100ms sleep, assuming Run has subscribed by then. Under -race on a loaded CI runner, the event can be published before the subscription, and the test then fails with a timeout. Wait on an observable readiness signal instead of sleeping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/phonev2_test.go, line 468:

<comment>These tests publish after a fixed 100ms sleep, assuming `Run` has subscribed by then. Under `-race` on a loaded CI runner, the event can be published before the subscription, and the test then fails with a timeout. Wait on an observable readiness signal instead of sleeping.</comment>

<file context>
@@ -0,0 +1,591 @@
+	repo := gitRepo(t)
+	b.Locations.Add(ctx, "acme", repo)
+	wt, _ := b.Locations.CreateWorktree(ctx, "acme", "checkout", "", "")
+	go p.Run(ctx, b)
+	time.Sleep(100 * time.Millisecond)
+	b.Events.Publish(events.Event{Type: "agent.waiting", Data: map[string]any{"path": wt.Path, "agent": "claude", "text": "the agent's own words"}})
</file context>

name, or its own short name, with the port.
- **Token.** Every request carries `Authorization: Bearer <token>`, the box's
phone token from the pairing link. Ten wrong tokens in a minute from one
address answer 429 until the minute is up.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The tenth bad token still returns 401; only subsequent requests get 429 while ten failures remain in the window. Say the lockout starts after ten failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/reference/phone-api.mdx, line 17:

<comment>The tenth bad token still returns 401; only subsequent requests get 429 while ten failures remain in the window. Say the lockout starts after ten failures.</comment>

<file context>
@@ -0,0 +1,127 @@
+  name, or its own short name, with the port.
+- **Token.** Every request carries `Authorization: Bearer <token>`, the box's
+  phone token from the pairing link. Ten wrong tokens in a minute from one
+  address answer 429 until the minute is up.
+- **CORS.** Pages served from tailnet addresses and `*.ts.net` may read it
+  (another box's web phone app); the token is always a header, never a
</file context>

Comment thread internal/box/phonev2.go
writeError(w, statusFor(err), err.Error())
return
}
since, _ := strconv.Atoi(r.URL.Query().Get("since"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: A malformed or overflowing since silently becomes zero and reloads the initial transcript page. Check the parse error and return 400 Bad Request, as the event endpoint does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/phonev2.go, line 83:

<comment>A malformed or overflowing `since` silently becomes zero and reloads the initial transcript page. Check the parse error and return `400 Bad Request`, as the event endpoint does.</comment>

<file context>
@@ -0,0 +1,639 @@
+			writeError(w, statusFor(err), err.Error())
+			return
+		}
+		since, _ := strconv.Atoi(r.URL.Query().Get("since"))
+		res, err := b.driverFor(sess).Conversation(r.Context(), sess, all, since, r.URL.Query().Get("gen"))
+		if err != nil {
</file context>

Comment thread internal/box/phonev2.go
oldest = i
}
}
c.Push = slices.Delete(c.Push, oldest, oldest+1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Evicting the oldest phone silently removes its registration, so event subscribers never learn that it stopped receiving notifications. Capture the evicted device and publish phone.push.removed as the other removal paths do.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/phonev2.go, line 513:

<comment>Evicting the oldest phone silently removes its registration, so event subscribers never learn that it stopped receiving notifications. Capture the evicted device and publish `phone.push.removed` as the other removal paths do.</comment>

<file context>
@@ -0,0 +1,639 @@
+					oldest = i
+				}
+			}
+			c.Push = slices.Delete(c.Push, oldest, oldest+1)
+		}
+		c.Push = append(c.Push, d)
</file context>

@sean-brydon
sean-brydon merged commit a6ee436 into main Nov 2, 2026
4 of 6 checks passed
@linear-code

linear-code Bot commented Nov 2, 2026

Copy link
Copy Markdown

ENG-7690

This branch was successfully deployed

2 active deployments
Preview – berth-docs — 69d21249 Deployed Nov 2, 2026 by vercel[bot]
Preview – berth — 69d21249 Deployed Nov 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants