Spindle is a music distribution agentic OS. It maps everyone who can carry a record — creators, curators, stations — into one vector index, works out who should hear it, and writes to them individually. Not a blast. Radio is the 0 rows we could earn first, because its registers are public. The creators are the point.
One query over the whole index, on page load. Countries light in the order we first reached them; click one to search inside it. Radio is lit because its registers are public and it was the cheapest reach to earn. The creator platforms are listed dark because that is where this is going and we have not got there — a creator enters through a scout who pastes what they actually posted, never a scraper, and lands in the same index as every station on this map.
Throughput is the right unit for a trading bot racing other machines. Spindle's work is a correspondence with a person who opens their inbox on Tuesday, so its tick is a curator's reply, a programming meeting, a register that republishes weekly. The schedule below is not throttled — it is paced, and every interval in it is a constant you can read in the source.
The same eight bars, a bar each in turn. Only the rate differs — and one of the two stops being music.
awaiting_reply
rather than sent, because the next real event belongs to the
curator.
sender.py · BATCH = 5
next_action_at comes due. Five days is
not a retry and not a timeout. It is how long you leave a curator alone
before you follow up, and it is written onto the row at the instant of
sending.
sender.py:188 · INTERVAL '5 days'
Nine days on a broken axis — the first hour would otherwise be half a pixel wide. Every interval is a constant in this repository: five days is sender.py:188, fifteen minutes is _record_refused, the batch of five is BATCH. The two ticks that set the pace are the orange ones, and we own neither. Throughput is not the constraint here; somebody's attention is.
Four predicates — tenant, embedding model, party class, contact state — sit in the index prefix, so the search happens inside the filtered subspace instead of over everything followed by a discard. A test asserts this plan on every run: a query that degrades to a full scan still returns rows that look correct.
Sending is the one act this system cannot undo, so the guarantees are constraints rather than conventions — things the database will not let us get wrong, even when we are in a hurry.
FOR UPDATE SKIP LOCKED, and a fence that
fails closed.
A partial unique index on (tenant_id, counterparty_id) where the
thread is not closed. Two campaigns cannot work the same person at once, and
the lock releases the moment the conversation ends.
UNIQUE (message_id). A double approval is a failed insert, not a
second copy in flight. The gate is safe to double-click.
Every agent action is fenced on a lease_token stamped at claim
time. Two workers with the same name, one lead: the name cannot tell them
apart, the token can. The stale claim is refused.
Ask us to stop and opted_out is a state no discovery stage can
overwrite. Guessed addresses are refused outright. Not spam by construction,
not by policy.
A vendor who claims every part of their stack is special is telling you about their marketing, not their engineering. So here is the audit we ran on ourselves, and the single row that survived it.
| What we use | Postgres equivalent | Verdict |
|---|---|---|
| Vector index, cosine, prefix-filtered | pgvector + composite indexes | replaceable |
| pgvector does this, and has for years. Our prefix trick is a composite index with the vector column last — an idea Postgres can express directly. If the shortlist were all this system did, there would be no argument here. | ||
| SERIALIZABLE by default | one SET statement | replaceable |
SET default_transaction_isolation = 'serializable'. The honest
difference is that we did not have to remember, and a default nobody has to
remember is worth something — but it is a default, not a capability.
| ||
| FOR UPDATE SKIP LOCKED | Postgres shipped it first | replaceable |
| Postgres 9.5, in 2016. The lease fence you just played with would work unchanged on it. We are not going to claim a 2016 feature as a 2026 reason. | ||
| Scale to zero | Neon, Aurora Serverless | replaceable |
| Several managed Postgres products scale to zero, some of them more aggressively than we do. This is a cost property of a deployment, not a property of a database, and it belongs in the concessions column. | ||
| AS OF SYSTEM TIME over a vector index | nothing | irreplaceable |
Our agents write to real people, so why did you contact me has to be
answerable — which means re-running the ranking against the index as it
stood at that second, not as it stands now. Four extra words of SQL, no
snapshot table, no audit copy of the embeddings.
Every decision carries the hybrid logical clock it
happened at — decision.at_hlc, written by
035_decision_ledger.sql — which is the coordinate that query is
issued against.
| ||
One row survived. It is the row that lets an autonomous system be held to account for what it did, and it is why this is built on CockroachDB.
Most pricing pages are a promise. This one is a predicate. Every plan
below is a row in budget, and the spend gate that reads it refuses the
work rather than invoicing for it afterwards. The unit is an open
conversation — the one thing the schema was already counting, not a meter
invented to bill you with.
Everything the console does, on one artist, with sending off. Enough to judge the product without a card.
An independent label running three acts. Fifty conversations a month is roughly one campaign per act per quarter, worked properly.
A full roster, no cap on how many acts. The meter is conversations, so an artist you are not pitching costs nothing.
Back catalogue, multiple labels, or a shape none of the above fits. Priced against the work; talk to us.