Attachments and spt fetch
Sending a file with a message does not push bytes to the receiver. The file is snapshot into this node’s own store, registered for serving, and the message carries its URL. The receiver decides whether to pull it, and when.
Attaching a file to a message
$ printf 'the report you asked for' | spt send doyle --attachment ./report.md
ATTACHED:report.md: http://localhost:5474/hfenduleam/f/report.md (2481 bytes, ttl 2592000000ms)
SENT:doyle
--attachment repeats; each file becomes its own registry entry with its own
URL. The message envelope grows an attachments attribute carrying, per file,
its served name, its URL and its size in bytes — so a receiving agent can decide
whether to fetch before it fetches. A core that has never heard of the
attribute delivers the message unchanged.
An attachment is a snapshot, and that is the one place the serving registry does not resolve at request time:
spt serve add | spt send --attachment | |
|---|---|---|
| What is served | the file at its path | a copy taken at send time |
| Editing the source | the reader sees the edit | the reader still sees what was sent |
| Deleting the source | the URL answers 404 | the URL keeps serving |
| Lifetime | until removed | a TTL, 30 days by default |
A message’s attachment is as immutable as the message: an edit after the send must not change what the receiver pulls, and deleting the original must not turn a delivered link into a dead one.
Two sends of the same filename get two stable, distinct URLs under the
registry’s ordinary disambiguation rule (report.md, then report~1.md), so no
link ever changes meaning.
A missing or unreadable path refuses the send by name — nothing is delivered and nothing is spooled — rather than delivering a message whose link is dead on arrival:
$ printf 'here' | spt send doyle --attachment ./nope.md
ATTACHMENT_REFUSED:./nope.md: The system cannot find the file specified. (nothing was sent)
Lifetime
--ttl sets this send’s lifetime and requires a unit — s, m, h or
d. A bare number is refused rather than guessed, because 30 meaning seconds
when the sender meant days is a deleted attachment.
$ printf 'valid for a day' | spt send doyle --attachment ./build.log --ttl 24h
The brain’s pulse reaps expired registry entries and logs how many it removed. For an attachment, it attempts to unlink the core-owned snapshot before saving the registry without that entry. These are not one atomic transaction: if saving fails after unlinking, the next pulse can finish removing the expired entry even though its snapshot is already gone. Saving first could instead leave untracked snapshot bytes that later pulses cannot find.
Only attachment snapshots under $SPT_HOME/serve/snapshots/ are candidates for
unlinking. Expiry removes a live file or directory reference from the registry
but never deletes the user’s source. The
writer-coordination contract keeps the
guard across the reaper’s fresh load, snapshot cleanup, and explicit save;
a pass with nothing expired does not save the registry.
An expired entry stops serving the moment it expires, whether or not the reaper has run yet: between expiry and the next tick the bytes may still be on disk, and serving them would make the TTL a suggestion.
Pulling one: spt fetch
$ spt fetch hfenduleam/f/report.md
report.md
$ spt fetch http://localhost:5474/hfenduleam/f/report.md ~/inbox/report.md
/home/reavus/inbox/report.md
It accepts a full node-prefixed URL or the bare <node>/f/<name> shorthand —
the shorthand is how a link is said between agents, and the host and port belong
to whichever node is doing the fetching. The written path is printed on stdout
so a caller can pipe it.
Without a destination the file lands in the current directory under its served
name. An existing destination is not overwritten without --force. The body
streams to a temporary file and is renamed into place, so an interrupted or
refused fetch never leaves a truncated file where a later reader would trust it.
The three outcomes are distinct, and the distinction is the point:
| Exit | Meaning |
|---|---|
0 | The file was written; its path is on stdout. |
3 | The owner refused it — an access decision. Retrying is wrong. |
1 | Anything else: unreachable owner, not found, a deadline, a local write error. |
Every fetch — local or remote — is served through the local daemon’s web surface, the same path a browser takes, so a fetch of your own node’s file exercises the code a remote fetch will use.
The FILE_ACCESS_HELPER signal
An agent does not have to notice an attachment. When a delivered message carries one, the now-signal hands the agent the exact command to run, taken verbatim from the envelope:
<FILE_ACCESS_HELPER>
spt fetch http://localhost:5474/kitsubito/f/report.md
</FILE_ACCESS_HELPER>
The same category answers the other way a file arrives: a user quoting a
filepath while controlling the receiving agent through remote spt rc.
Core binds the existing session-authenticated input report to the broker’s live
remote controller when the report arrives. A later controller change cannot
reattribute it. Registration happens on that controller’s machine, not by looking
for a similarly named file on the receiving machine.
The file is a live reference, not an attachment snapshot: edits are visible and deletion returns not found. The reference lasts up to 24 hours. If the user later hands the same live path to another endpoint, that endpoint joins the existing entry’s audience and receives the same URL. No duplicate entry is created and the original deadline does not move.
The fetch command normally arrives with the path-bearing prompt after a short bounded wait for registration. If the owner answers later, the command remains available on a subsequent signal poll. The wait does not cancel owner work.
- Absolute or
~-rooted only. The owner resolves~against its own home. A relative path has no anchor on another node. At most five paths per report; directories receive the same lifetime and audience. Paths containing whitespace must be enclosed in matching double quotes, single quotes, or backticks:"C:\My Pictures\a.png",'/home/x/My Pictures/a.png', or`~/a b/c.md`. Each quoted span is one path, not whitespace-separated fragments; punctuation outside its closing quote is not part of the path. Unquoted paths containing whitespace are outside this contract. An unmatched opening quote ends extraction; complete paths before it are retained. - No quoted paths means no broker IPC and no line. An absent hosted session, local or viewer-only seat, or no controller binds nothing silently. A missing file on the bound owner’s machine is also silent, with no fallback origin.
- CLI broker-connect and unanswered-receipt failures are silent unless
SPT_PUMP_TRACEenables diagnostics. Named declines apply only to failures with a remote controller, such as an invalid session, uncertain delivery-byte evidence, or owner refusal. The broker’s 10-second owner-reply timeout remains named. - Once per endpoint, session and filepath. Rephrasing a prompt does not repeat its helper. A new endpoint or session can receive the existing URL; neither can extend the live reference’s original deadline.
- Core excludes bytes it knows it physically wrote for peer delivery: either an exact match or a match after trimming ASCII whitespace from both byte sequences’ ends. It does not parse message shapes or normalize interior text. This is receipt-seat attribution, not proof of who typed every byte: external automation and native harness re-submission cannot be distinguished beyond the current controller seat. No adapter token or integration change is required.
An original prompt and its edge-ASCII-trimmed report share the first path receipt in the same endpoint session, including its controller custody and deadline. Surrounding prose is not a new file handoff.
Each poll emits an identical fetch command at most once, even if distinct attachment or helper records supplied it. All such records are marked seen, so suppressed duplicates do not appear on the following poll.
Every entry the helper registers is enumerable in spt serve list with its
origin and complete audience, so an automatic exposure is exactly as visible
as a deliberate one.
Two-node field acceptance was granted by Doyle on 2026-09-22 for the W2 candidate:
spaced-path fetch, one helper per physical prompt, delivery with the original
prompt, two same-entry/URL audience-admission cases preserving the original
deadline, and exact plus XML-reencoded peer bodies causing no serve while a
remote controller remained seated. The seven-cell observations and verified
isolated teardown are preserved in
.spt/preserved/318/hertz/w2-two-node/DELIVERY.json; resident daemon records and
firewall output remained unchanged on both nodes. This accepts the tested
dirty-source candidate, not a later commit SHA or the whole release, and does
not turn one node pair’s timing into a WAN or numeric hook-latency guarantee.
Reading a message back
Every message carries a short-ID, and an attachment link is part of what that id
renders — spt msg show <id> prints the message with its attachment URLs, and
/<node>/m/<id> is the same view in a browser. See
Messaging for the id itself.