Tether

@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.