Official TypeScript SDK for the FoxNose platform — a managed knowledge layer for RAG and AI agents.
- Type-safe clients with full TypeScript interfaces for all API responses
- Async-only API built on native
fetch(Node 18+) - Automatic retries with exponential backoff and Retry-After support
- Four auth strategies — Anonymous, JWT, Simple key, and Secure (ECDSA P-256)
- Flux introspection — discover route contracts with
/_routerand/_schema - Zero dependencies — uses only Node.js built-in modules
- Dual output — ESM and CommonJS builds with full
.d.tsdeclarations
npm install @foxnose/sdk
# or
pnpm add @foxnose/sdk
# or
yarn add @foxnose/sdkimport { ManagementClient, JWTAuth } from '@foxnose/sdk';
const auth = JWTAuth.fromStaticToken('your-access-token');
const client = new ManagementClient({
baseUrl: 'https://api.foxnose.net',
environmentKey: 'your-environment-key',
auth,
});
// List collections
const collections = await client.listCollections();
console.log(collections.results);
// Create a public API
const api = await client.createApi({
name: 'Storefront',
prefix: 'shop',
is_auth_required: false,
cors_origins: ['*'], // browser CORS for public APIs (reads only)
});
console.log(api.key);
// Create a resource
const resource = await client.createResource('my-collection-key', {
data: { title: 'Hello World' },
});
console.log(resource.key);
// Clean up
client.close();Note (0.4.0): Folder-named methods (
listFolders,createFolder,addApiFolder,listFolderVersions,listFolderFields, etc.) remain as@deprecatedaliases that emit a one-shotconsole.warnon first use per process. They keep their original wire behaviour (hitting the legacy/folders/...URL alias on the server) and will be removed in 1.0. Prefer the*Collection*names in new code.
Collections can embed Components as nested fields with explicit pin
semantics (component, component_version, auto_update). The
nestedFieldMeta helper builds the meta block with camelCase
ergonomics, and syncCollectionComponent advances pinned fields to a
target Component version on demand.
import { ManagementClient, JWTAuth, nestedFieldMeta } from '@foxnose/sdk';
const client = new ManagementClient({
config: { baseUrl: 'https://api.foxnose.com' },
environmentKey: 'prod',
auth: new JWTAuth('ACCESS_TOKEN'),
});
// Embed a Component as a pinned nested field on a Collection draft.
await client.createCollectionField('articles', 'v2-draft', {
key: 'seo',
name: 'SEO',
type: 'nested',
required: true,
meta: nestedFieldMeta({
component: 'cmp-seo-metadata',
componentVersion: 'ver-abc12345',
autoUpdate: false, // default — pin until explicit sync
}),
});
// Later, advance every pinned nested field to its Component's
// current version (empty body = sync all pinned).
const result = await client.syncCollectionComponent('articles');
console.log(result.synced_paths, result.schema_version);
// Advance specific paths to a chosen Component version.
await client.syncCollectionComponent('articles', {
fieldPaths: ['seo'],
toVersions: { seo: 'ver-def67890' },
});syncCollectionComponent returns a SyncComponentResponse with
synced_paths, skipped (per-path reasons), and schema_version
(UID of the newly published Collection schema version, or null if no
field needed advancing). On compatibility conflict the server returns
409 component_sync_conflict; quota exhaustion returns 422
too_many_versions. Both surface as FoxnoseAPIError.
import { FluxClient, SimpleKeyAuth } from '@foxnose/sdk';
const auth = new SimpleKeyAuth('your-public-key', 'your-secret-key');
const client = new FluxClient({
baseUrl: 'https://your-env.fxns.io',
apiPrefix: 'v1',
auth,
});
// List resources from a folder
const resources = await client.listResources('articles');
console.log(resources.results);
// Get a single resource
const article = await client.getResource('articles', 'resource-key');
console.log(article);
// Search
const results = await client.search('articles', {
query: { match_all: {} },
size: 10,
});
// Discover available routes for this API prefix
const router = await client.getRouter();
console.log(router.routes.length);
// Get live schema metadata for a folder route
const schema = await client.getSchema('articles');
console.log(schema.searchable_fields);
// Writes require a write-capable key. Create publishes immediately; `key` is an
// optional external id used to deduplicate. updateResource is a full replace.
const created = await client.createResource('articles', { title: 'Hello' }, { key: 'my-id' });
await client.updateResource('articles', created.resource_key, { title: 'Hello (edited)' });
client.close();truncate_text is a query parameter on List Resources and Search that caps
every text-typed field to a maximum length. It is off by default, ignored
when raw=true, and the SDK does not validate it client-side — a value below
1 or a non-integer surfaces as a server 422 validation_error.
// List Resources: truncate_text is a plain query param
const page = await client.listResources('articles', { truncate_text: 200 });
// Search: pass it via the third `options.params` argument, not the body
const results = await client.search(
'articles',
{ find_text: { query: 'machine learning' } },
{ params: { truncate_text: 200 } },
);
// Truncated fields are reported per-resource under `_sys.truncated`; fields
// within the limit get no entry. `locale` is null for non-localized fields.
console.log(page.results[0]._sys.truncated);
// [{ field: 'body', locale: null, original_length: 850 }]next is a full absolute URL and preserves truncate_text across pages.
The SDK does not follow next automatically — buildUrl always resolves
paths against the configured baseUrl, so a server-returned absolute URL
cannot be fed back in directly. Extract the cursor yourself:
const cursor = page.next ? new URL(page.next).searchParams.get('next') : null;
if (cursor) {
const nextPage = await client.listResources('articles', {
truncate_text: 200,
next: cursor,
});
}A strict-reference collection can be reached at additional, read-only
addresses that omit some number of ancestor keys from the path — fully-flat
(drops every ancestor key) or partially-flat (keeps the root-most ancestor
keys). folderPath is always an opaque string that the SDK slash-trims and
interpolates without parsing, so any address the API exposes works unchanged
with listResources, getResource, search, and getSchema:
// Standard nested path
await client.listResources('realty/accounts/acc_1/listings/lst_1/photos');
// Partially-flat: keeps the root-most ancestor key
await client.listResources('realty/accounts/acc_1/listings/photos');
// Fully-flat: drops every ancestor key
await client.listResources('realty/accounts/listings/photos');These addresses are read-only; the server rejects writes on a flat path.
Before relying on a given address, check enabled, available, and
read_methods on the connection's flat_routes (see
ManagementClient.getApiCollection) rather than assuming every read method
is available at every level — the API has known discrepancies here (Get
Resource reported at level 0; Search absent from read_methods at every
level) that the SDK types as sent, without correcting.
The Flux client provides typed convenience methods for all vector search modes:
import { FluxClient, SearchMode, buildSearchBody } from '@foxnose/sdk';
// Semantic search (auto-generated embeddings)
const results = await client.vectorSearch('articles', {
query: 'machine learning in healthcare',
top_k: 10,
similarity_threshold: 0.7,
});
// Custom embedding search
const results = await client.vectorFieldSearch('articles', {
field: 'content_embedding',
query_vector: [0.012, -0.034, 0.056 /* ... */],
top_k: 20,
});
// Hybrid text + vector search
const results = await client.hybridSearch('articles', {
query: 'ML applications',
find_text: { query: 'machine learning' },
vector_weight: 0.7,
text_weight: 0.3,
});
// Boosted search (keywords boosted by vector similarity)
const results = await client.boostedSearch('articles', {
find_text: { query: 'python tutorial' },
query: 'beginner programming guide',
boost_factor: 1.5,
});
// Extra parameters (where, sort) are forwarded to the API
const results = await client.vectorSearch('articles', {
query: 'climate change',
limit: 5,
sort: '-published_at',
where: { category: 'science' },
});
// queryParams forwards to the query string of the underlying search() call —
// it is named queryParams, not params, because unknown keys on this options
// object are otherwise merged into the JSON body.
const truncated = await client.vectorSearch('articles', {
query: 'climate change',
queryParams: { truncate_text: 200 },
});You can also use buildSearchBody() for full control with the raw search() method:
const body = buildSearchBody({
search_mode: SearchMode.HYBRID,
find_text: { query: 'python' },
vector_search: { query: 'programming tutorials', top_k: 10 },
hybrid_config: { vector_weight: 0.6, text_weight: 0.4 },
limit: 20,
});
const results = await client.search('articles', body);You can configure per-route descriptions when connecting a folder to an API.
These descriptions are returned by Flux /_router introspection.
await managementClient.addApiFolder(api.key, folder.key, {
allowedMethods: ['get_many', 'get_one'],
descriptionGetOne: 'Get one article by key',
descriptionGetMany: 'List published articles',
descriptionSearch: 'Search published articles',
descriptionSchema: 'Read article schema',
});The SDK supports four authentication strategies:
Best for server-side applications with user tokens.
import { JWTAuth } from '@foxnose/sdk';
// From a static token
const auth = JWTAuth.fromStaticToken('your-access-token');
// With a custom token provider
const auth = new JWTAuth({
getToken() {
return fetchTokenFromSomewhere();
},
});For development and Flux API access.
import { SimpleKeyAuth } from '@foxnose/sdk';
const auth = new SimpleKeyAuth('public-key', 'secret-key');ECDSA P-256 signature-based authentication (Node.js only).
import { SecureKeyAuth } from '@foxnose/sdk';
const auth = new SecureKeyAuth('public-key', 'base64-der-private-key');For unauthenticated endpoints.
import { AnonymousAuth } from '@foxnose/sdk';
const auth = new AnonymousAuth();All API errors are thrown as typed exceptions:
import { FoxnoseAPIError, FoxnoseTransportError } from '@foxnose/sdk';
try {
await client.getResource('collection', 'nonexistent-key');
} catch (err) {
if (err instanceof FoxnoseAPIError) {
console.error(err.statusCode); // 404
console.error(err.errorCode); // "not_found"
console.error(err.detail); // Additional error details
} else if (err instanceof FoxnoseTransportError) {
console.error('Network error:', err.message);
}
}Billing-related responses are thrown as typed subclasses of FoxnoseAPIError,
so an existing catch (err) { if (err instanceof FoxnoseAPIError) ... } keeps
working. Narrow to a subclass to read its typed fields:
import {
FoxnoseAPIError,
SpendCapExceededError,
PlanExhaustedError,
PlanLimitExceededError,
RateLimitExceededError,
} from '@foxnose/sdk';
try {
await client.createResource('collection', payload);
} catch (err) {
if (err instanceof SpendCapExceededError) {
// HTTP 402
console.error(err.capUsd); // Spend cap in USD (or null)
console.error(err.cycleResetsAt); // ISO timestamp
console.error(err.raiseCapUrl); // Where to raise the cap
} else if (err instanceof PlanExhaustedError) {
// HTTP 402
console.error(err.axis); // e.g. "retrievals", "writes"
console.error(err.windowResetsAt); // ISO timestamp
console.error(err.upgradeUrl);
} else if (err instanceof PlanLimitExceededError) {
// HTTP 403
console.error(err.entity); // e.g. "collections"
console.error(err.current, err.limit);
console.error(err.upgradeUrl); // May be undefined
} else if (err instanceof RateLimitExceededError) {
// HTTP 429
console.error(err.retryAfter); // Seconds to wait (from Retry-After)
} else if (err instanceof FoxnoseAPIError) {
console.error(err.statusCode, err.errorCode);
}
}Resource writes (createResource / updateResource) can throw these typed
subclasses of FoxnoseAPIError:
import {
CollectionNotWritableError,
ExternalIdConflictError,
ContentValidationFailedError,
UpstreamError,
} from '@foxnose/sdk';
try {
await client.createResource('articles', { title: 'Hi' }, { key: 'ext-1' });
} catch (err) {
if (err instanceof ExternalIdConflictError) {
// HTTP 409 — a resource with that key already exists
} else if (err instanceof ContentValidationFailedError) {
// HTTP 422 — err.errors[] each carry a json_path; err.errorsTruncated
console.error(err.errors, err.errorsTruncated);
} else if (err instanceof CollectionNotWritableError) {
// HTTP 403 — this key cannot write to that collection
} else if (err instanceof UpstreamError) {
// HTTP 502 — outcome unknown; re-read with a GET before retrying
}
}Writes are never retried automatically (they are not idempotent).
Efficiently upsert multiple resources with concurrency control:
const items = [
{ external_id: 'article-1', payload: { data: { title: 'First' } } },
{ external_id: 'article-2', payload: { data: { title: 'Second' } } },
];
const result = await client.batchUpsertResources('folder-key', items, {
maxConcurrency: 5,
onProgress: (completed, total) => {
console.log(`${completed}/${total}`);
},
});
console.log(result.succeeded.length); // Successfully upserted
console.log(result.failed.length); // Failed items with errors# Install dependencies
pnpm install
# Build
pnpm build
# Run tests
pnpm test
# Run tests with coverage
pnpm test:coverage
# Lint
pnpm lint
# Type check
pnpm typecheck
# Format
pnpm format
# Check formatting without rewriting (this is what CI runs)
pnpm format:checkCI gates on pnpm lint, pnpm format:check, pnpm typecheck, pnpm test:coverage and pnpm build. To run the same checks locally on every git push, enable the repo's hook directory once per clone:
git config core.hooksPath .githooks.githooks/pre-push then runs lint, format, typecheck and tests, and aborts the push if any fail. It is opt-in per clone and can be skipped with git push --no-verify, so it is a fast feedback loop rather than a guarantee — CI remains the real gate.