@nynth/tether
The official client. Plain TypeScript types, no runtime dependencies, works
anywhere fetch does — Node 20+, Cloudflare Workers, Bun, Deno, the browser.
Installation
npm install @nynth/tether
Public on npm and MIT licensed, so it installs like any other package. Tether itself — the service it talks to — is a separate, proprietary product, and the client is of no use without a credential for one.
An API key is required
Every Tether endpoint is authenticated, so the client refuses to exist without a credential. It throws at construction rather than returning a 401 from whichever call happens to run first:
new Tether({}) // TetherConfigError: An API key is required.
Keys look like tth_dev_… or tth_prod_…. Anything else is rejected with a
specific message, because the likeliest mistake is passing the root
ADMIN_TOKEN — which authenticates the admin endpoints and no tenant endpoint
at all.
So is a baseUrl
There is no default, deliberately. A package cannot responsibly guess where your Tether is: a wrong guess sends your credential and the full text of everything you store to a deployment you never named, and it does so silently, because a valid key authenticates against any deployment holding its hash.
new Tether({ apiKey }) // TetherConfigError: A baseUrl is required.
It must be https. Every request carries the key in an Authorization
header, so a plaintext origin would put it on the wire in the clear on every
call. http://localhost and http://127.0.0.1 are allowed, for local
development against wrangler dev.
Mint one against your Tether deployment:
curl -X POST "$TETHER_URL/v1/admin/credentials" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"tenantId":"ten_…","name":"maiden","app":"maiden","environment":"dev"}'
Then grant it a scope — POST /v1/admin/grants — or every call returns
not_found for a space that looks correct. whoami() tells you which scope a
key in hand actually reaches.
Quick start
import { Tether } from '@nynth/tether';
const tether = new Tether({
apiKey: process.env.TETHER_API_KEY, // or TETHER_API_KEY in the environment
baseUrl: process.env.TETHER_BASE_URL, // or TETHER_BASE_URL — no default
space: 'default', // used by every call unless overridden
});
// Store a fact
await tether.remember('Invoices go out on the 1st of the month.', {
kind: 'process',
subjectId: 'billing',
});
// Assemble context for a prompt, inside a token budget
const context = await tether.context({
query: 'how does billing work here',
maxTokens: 800,
});
for (const anchor of context.anchors) {
console.log(anchor.via, anchor.score, anchor.content);
}
Common operations
// Full control over a write
const written = await tether.anchors.create({
kind: 'decision',
content: 'We ship on Thursdays.',
subjectId: 'cadence',
isInference: false, // a person asserted this, not the system
idempotencyKey: row.id, // see below — this is the important one
});
// Read one back. Immediately consistent, unlike search.
const anchor = await tether.anchors.get(written.id);
// Correct it. The old version is superseded, not overwritten.
await tether.anchors.correct(written.id, {
content: 'We ship on Wednesdays.',
expectedVersion: anchor.version,
});
// Exclude from recall, keeping the row
await tether.anchors.forget(written.id);
// Request physical removal of the whole chain
const { deletionId } = await tether.anchors.delete(written.id);
const progress = await tether.deletions.get(deletionId);
// Ranked matches. Returns nothing for a write that is not yet indexed.
const { results, degraded } = await tether.search({ query: 'shipping', topK: 5 });
// Which tenant, app and environment this key reaches
await tether.whoami();
// { credentialId: 'cred_…', tenantId: 'ten_…', app: 'maiden', environment: 'dev' }
Idempotency is not optional
Every write carries an Idempotency-Key. The client generates one per call if
you do not supply it, which covers its own retries — but not your process
restarting and calling again, which would write twice.
Derive the key from the thing you are storing, not from a clock. A database row id is ideal: the same row always replays, and a different row never collides.
await tether.anchors.create({ ...fields, idempotencyKey: `memory-${row.id}` });
Replaying a key with the same body returns the original result. Reusing it with a different body returns 409 — the key is a claim about which write this is, not a lock.
const a = await tether.remember('x', { idempotencyKey: 'k' });
const b = await tether.remember('x', { idempotencyKey: 'k' });
a.id === b.id; // true — replayed, not written twice
stored is not searchable
A write returns stored: true, searchable: false. Indexing is asynchronous and
takes up to about 105 seconds.
const { stored, searchable } = await tether.remember('…');
// { stored: true, searchable: false }
Do not poll. Use context(), which reads not-yet-indexed anchors straight
out of storage and labels them via: "recent" with score: null, so something
written seconds ago still comes back. search() cannot do that.
degraded: true means the space has writes not yet handed to the index. Note
that degraded: false is not a completeness guarantee: vector upserts apply
asynchronously, and the flag has been measured clearing 21 seconds before a
write became findable. See 05-stored-searchable-degraded.
Errors and retries
| Status | Class | |
|---|---|---|
| 400 | TetherBadRequestError |
schema violation, or content carrying a secret |
| 401 | TetherAuthenticationError |
missing, malformed, unknown, or revoked key |
| 403 | TetherPermissionError |
the grant does not permit this operation |
| 404 | TetherNotFoundError |
unknown anchor — or a space this key cannot reach |
| 409 | TetherConflictError |
stale_version, or a key reused with a different body |
| 429 | TetherRateLimitError |
120 requests per 60 seconds, per credential |
| 503 | TetherUnavailableError |
embeddings, index or database unreachable |
| other 5xx | TetherServerError |
|
| — | TetherConnectionError |
never reached Tether: DNS, TCP, TLS, or timeout |
| — | TetherAbortError |
you cancelled it — see Cancelling a call |
| — | TetherConfigError |
the client was constructed wrong |
All extend TetherError and carry status, Tether's stable code, and the
requestId — which is what a support conversation is actually about.
import { TetherConflictError } from '@nynth/tether';
try {
await tether.anchors.correct(id, { content, expectedVersion: 1 });
} catch (err) {
if (err instanceof TetherConflictError && err.code === 'stale_version') {
// Someone corrected it first. Re-read and decide.
}
throw err;
}
Narrowing without an instanceof chain
import { isConflict, isNotFound, isRateLimited } from '@nynth/tether';
try {
await tether.anchors.correct(id, { content, expectedVersion: 1 });
} catch (err) {
if (isConflict(err)) {
// err.code is 'stale_version' or 'idempotency_conflict'
}
throw err;
}
isTetherError, isNotFound, isConflict, isRateLimited, isUnavailable
and isAborted. They are type guards rather than booleans, so after
if (isNotFound(err)) the compiler knows about .code and .requestId —
which it does not inside a plain catch, where err is unknown.
Seeing the retries
Retries are invisible by default: a caller sees one slow call and cannot tell it was throttled three times.
const tether = new Tether({
onRetry: ({ attempt, status, delayMs }) =>
log.warn({ attempt, status, delayMs }, 'tether retry'),
});
status is the HTTP status that caused it, or null for a connection
failure. A hook that throws is swallowed — instrumentation must never be
load-bearing.
408, 429 and 5xx are retried automatically, as are connection failures:
maxRetries, default 2, exponential backoff with jitter, honouring
Retry-After when the server sends one. A 4xx is never retried — it will fail
identically however many times it is sent.
Retrying a write is safe here for one specific reason: the idempotency key is chosen once per call and every attempt carries the same one. A retried timeout replays rather than writing twice.
Requests time out after 30 seconds (timeoutMs), per attempt — a retry gets a
full budget rather than the remainder of the first one's.
A Retry-After longer than 60 seconds is not waited out. The client throws
the error instead and lets you decide. Sixty seconds is the rate-limit window,
so a longer wait cannot be about this limiter; it is a proxy or a
misconfiguration, and a client that sleeps for a day on a header it does not
control is worse than one that fails.
Cancelling a call
Every method takes a signal, and a client-wide one applies to all of them.
const controller = new AbortController();
const work = tether.context({
query: 'deployment policy',
maxTokens: 800,
signal: controller.signal,
});
controller.abort(); // rejects with TetherAbortError
An abort is a decision, not a failure. It is never retried, and it rejects
with TetherAbortError rather than a connection error — the two are the same
AbortError underneath, and the client tells them apart by which signal fired.
A timeout still retries; a cancellation does not.
Passing an already-aborted signal makes no request at all.
Waiting for a deletion
delete answers 202. Rows and markers are written immediately; vectors are
purged by a background job.
const { deletionId } = await tether.anchors.delete(id);
await tether.deletions.waitFor(deletionId);
It polls until the job is done, and rejects if it ends failed rather
than handing back a failed job as a fulfilled promise — a caller awaiting a
deletion is asking is it gone, and the ordinary await should not read as
success when it is not.
intervalMs defaults to 2000 and timeoutMs to 120000. It takes a signal
too, and the wait between polls is interruptible: abort() returns
immediately rather than finishing the current interval.
There is no waitUntilSearchable
Deliberately. Tether exposes no per-anchor searchability — Anchor carries no
such field, and searchable on a write result is always false. The only
possible implementation would poll /v1/search and hope the id turns up, which
is unreliable and spends your rate limit on a guess.
Use context instead. Its read-through covers the indexing window, so an
anchor written seconds ago comes back labelled via: "recent". See
stored is not searchable above.
Options
| Default | ||
|---|---|---|
apiKey |
process.env.TETHER_API_KEY |
required; throws if absent |
baseUrl |
process.env.TETHER_BASE_URL |
required; must be https, except on localhost |
space |
none | applied to every call that does not pass its own |
timeoutMs |
30000 |
|
maxRetries |
2 |
0 disables retrying |
fetch |
globalThis.fetch |
inject for tests, or for a runtime that scopes it |
signal |
none | cancels every call this client makes |
onRetry |
none | called before each retry with attempt, status and delay |
Requires Node 20+, or any runtime with a global fetch.