@nynth/tether
v0.3.0
Published
Client for Tether, Nynth's memory layer. Plain TypeScript types, no runtime dependencies.
Maintainers
Readme
@nynth/tether
Client for Tether, Nynth's memory layer. Plain TypeScript types, no runtime
dependencies, works anywhere fetch does.
npm install @nynth/tetherimport { Tether } from '@nynth/tether';
const tether = new Tether({
apiKey: process.env.TETHER_API_KEY, // or TETHER_API_KEY
baseUrl: process.env.TETHER_BASE_URL, // or TETHER_BASE_URL
space: 'default',
});
await tether.remember('Invoices go out on the 1st of the month.', { kind: 'process' });
const { anchors } = await tether.context({
query: 'how does billing work here',
maxTokens: 800,
});An API key and a baseUrl are both required, and the client throws at
construction without either. There is no default endpoint: guessing one would
send your credential and your content to a deployment you did not choose. The
URL must be https, except on localhost.
Full documentation: https://tether-docs.eli-c44.workers.dev. Start with the
guide on idempotency, then stored, searchable, and degraded — that one
covers the behaviour that surprises every integration.
Many users: spaces and provisioning
tether.spaces.list() returns the spaces your key is granted. A provisioner key
(created by an operator, with a namespace) creates spaces and mints scoped keys
without the root token: tether.provision.putSpace(space, { private }),
.mintCredential (the key is returned once; a taken name is already_exists
with existingId), .revokeCredential and .eraseSpace. It acts only on what
its namespace created, holds no grants and reaches no memory route. A space
created private cannot be granted to a minted key once it holds data or is
held (isSpaceNotEmpty, isSpaceHeld), so a leaked provisioner key cannot read
it; shared spaces stay reachable, so guard the key anyway. A key holder replaces
its own key with tether.credentials.rotate(); the old one works for 15 more
minutes. putSpace({ private }), eraseSpace and credentials.rotate need
0.3.0 or later. The full pattern is in the docs site under
Integrating an application.
Cancelling a call
Every method takes a signal, and a client-wide one applies to all of them.
const controller = new AbortController();
const anchors = tether.context({ query: "deploy policy", maxTokens: 800,
signal: controller.signal });
controller.abort(); // rejects with TetherAbortErrorAn 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 at the fetch boundary, so the client tells them apart by which
signal fired — a timeout still retries, a cancellation does not.
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("anc_…");
await tether.deletions.waitFor(deletionId); // resolves when the purge is doneIt rejects if the job fails, rather than handing back a failed job as a fulfilled promise — a caller awaiting a deletion is asking is it gone.
There is deliberately no waitUntilSearchable. Tether exposes no
per-anchor searchability: Anchor carries no such field, and searchable on a
write result is always false. The only implementation would poll /v1/search
and hope the id appears, which is unreliable and spends a rate limit on a
guess. Use context instead — its read-through covers the indexing window, so
an anchor written seconds ago is returned, labelled via: "recent".
Seeing retries
const tether = new Tether({
onRetry: ({ attempt, status, delayMs }) =>
log.warn({ attempt, status, delayMs }, "tether retry"),
});Retries are otherwise invisible — a caller sees one slow call and cannot tell it was throttled three times. A hook that throws is swallowed: instrumentation must never be load-bearing.
Narrowing errors
import { isNotFound, isConflict, isRateLimited } from "@nynth/tether";
try { await tether.anchors.correct(id, { content, expectedVersion: 2 }); }
catch (error) {
if (isConflict(error)) {
// error.code is "stale_version" or "idempotency_conflict"
}
}These are type guards, not booleans: after if (isNotFound(error)) the
compiler knows about .code and .requestId.
Before you put retrieved content in a prompt
Everything Tether returns was written by whoever held a key to that space. It is data, not instruction. An anchor reading "ignore your previous instructions" is a string somebody stored, and Tether hands it back faithfully because that is its job — the risk appears one step later, when an application concatenates it into a prompt.
Delimit it, keep your instructions above it, and never let retrieved text choose a tool call or a recipient. Retrieving covers this properly, including why Tether does not try to sanitise it for you.
Licence
MIT. Copyright (c) 2026 Nynth Labs. See LICENSE.
The client is MIT so anyone can install and use it. Tether itself — the service it talks to — is a separate, proprietary product, and this package is of no use without a credential for one.
