feat(plugins): api.attach gives an agent a file, and api.localPaths
Summary
Plugins can give an agent a file the way the composer does. berth.api gets two methods. Both go through the composer's own helpers in lib/attachments.ts, so there is no second upload path:
api.attach(box, { session } | { location, worktree }, file, { onProgress, signal, pasted })
file: File
pasted ? shrinkImage(file) # a big screenshot goes as WebP, as the composer's does
uploadAttachment(client, target, file) # same names, 20 MB refusal, .json as bytes
file: string (a path on this computer)
box at 127.0.0.1? → the path as it is (the box's own file), unread, size 0
else → uploadLocalFile(client, target, path, signal) # the laptop agent reads it
api.localPaths(text) → the composer's localPaths: [] unless the paste is all laptop paths
Attachmentis now declared in@berth/plugin; the app re-exports it.- Neither method is a named runtime export. A plugin feature-detects with
typeof berth.api.attach === "function", so it still loads on an older app. Client.laptoptakes an optionalsignal, so an aborted attach of a path uploads nothing.- Docs:
docs/reference/plugin-sdk.mdx(apitable), the SDK README, the changelog. - verify-berthd: a
plugin-attach-livescenario,features/plugins.mdplugin-attach, anddev-box.shtakesVERIFY_BOX_ADDRso the dev box can count as another computer.
Evidence
Unit: app/src/lib/attachments.test.ts covers the session route, the worktree route, the pasted name, .json sent as octet-stream, the 20 MB refusal with no upload, a path going through attach-local for both targets, a path on a local box resolving unread, the signal reaching the laptop call, pasted on a text file, and localPaths. pnpm test: 586 pass, 0 fail. typecheck:plugins, every check:*, build, tsc -p e2e and the docs-site build all pass. No Go changed.
End to end: verify-berthd plugin-attach-live. A test plugin is loaded as a built-in, on a dev box and agent of this checkout, through the real app build. The console was empty in both runs.
| Box paired at 127.0.0.1 | Box paired at the tailnet IP (another computer) |
|---|---|
![]() | ![]() |
![]() | ![]() |
Steps from result.json (remote run):
- A picked
notes.mdattached to sessionasklands atacme-shop/.berth/attachments/20261022-…-notes.md, with progress 50/50. - A picked
image.pngattached to a new worktree lands as…-pasted-HHMMSS.png. - A 21 MB log is refused with "huge.log is 21 MB; attachments can be up to 20 MB.", and no file arrives.
localPaths("see /tmp/shot.png")gives[].localPaths("file:///tmp/verify-attach-…/shot.png")finds the path, and attaching it uploads through the laptop agent, byte for byte. On the loopback box the same call resolves to the path itself, and nothing arrives.- The same path with an already-aborted signal is refused with
AbortError, and nothing arrives. - A 7.4 MB 2400×1600 PNG sent with
pasted: truegoes up as a 1.9 MB WebP. - Second view, the box's disk: every file matches what was picked byte for byte, and
git check-ignoreignores each one.
Merge Danger
Door: two-way
Additive SDK surface. The only change to an existing signature is an optional trailing signal on Client.laptop.
Blast Radius: plugins
A plugin that calls attach on an older app finds no function, which is the documented feature check. The composer's own attach paths are untouched apart from uploadLocalFile taking an optional signal they don't pass.
🤖 Generated with Claude Code



