npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@valtrix/sdk

v0.51.0

Published

Typed client and codegen CLI for the Valtrix API. Run valtrix generate to get a client typed against your organization's synced records.

Readme

@valtrix/sdk

Typed client and codegen CLI for the Valtrix platform API.

Valtrix syncs every system a business runs on, API or not, into one live, typed database that platforms, agents, and organizations query and write back. This package turns that database into a typed client for your application: canonical records for every entity type, the tables your data team publishes over them, and the management surface (organizations, connections, API keys) around them. The schema is the contract: entity, table, and column names, types, nullability, and descriptions flow straight into your editor.

Quickstart

export VALTRIX_API_KEY=vlt_...   # from your console, under Developers
npm install @valtrix/sdk
npx valtrix generate

valtrix generate reads your schema and writes valtrix/client.ts into your project. Commit it: it is ordinary TypeScript source, and regenerating after a schema change is a reviewable diff.

Key prefixes tell you the scope of a key: vlt_ is platform-wide, vlt_org_ is pinned to one organization, and vlt_read_ / vlt_org_read_ are their read-only variants. Organization-scoped keys never need an org option.

Reading records

Records are the canonical layer: every entity synced from an organization's systems, in one shape shared by every connector. They are always there, even before any table is published.

import { valtrix } from './valtrix/client'

const invoices = await valtrix.records.invoice.findMany({
    where: { status: 'open', total: { gte: 1_000 } },
    orderBy: { issue_date: 'desc' },
    limit: 50,
})

for await (const customer of valtrix.records.customer.iterate({ connector: 'procore' })) {
    process(customer)
}

const { records, nextCursor } = await valtrix.records.customer.page({ cursor })

Every record carries _record_id, _external_id, _org_id, _connector, _connection_id, _source_id, _last_write_intent, _created_by, _updated_by, _synced_at, _changed_at, and _frozen alongside its entity fields. _connection_id names the connection the record came from, _source_id is its id in the source system, and _last_write_intent is the { id, status } of the most recent write that was propagated back to the source, or null. _created_by and _updated_by are a RecordActor, { kind, id, name }, naming who created the record and who last changed its data: kind is connector for a sync (the connection id and connector slug), key for a write through this API (the API key), applet for one of the organization's applets, or user for an edit made in the console. A write intent carries the same value as actor. _synced_at is when the record was last re-read from its source and _changed_at when its data last actually changed; both are orderable and take range filters (where: { _changed_at: { gte: cutoff } }). _frozen is true on a record that aged out of the connector's sync window and is no longer refreshed, so its _synced_at stops where the last run that reached it left it; where: { _frozen: false } (a FrozenFilter, true, false, or { eq } / { neq }) keeps only the records the sync still maintains, on records and on table rows alike. _attachment_id is the id of the record's newest stored attachment (a synced document, a rendered PDF, a report screenshot), or null; resolve it with records.attachment, never through a URL you keep. A records query also takes syncedSince, a freshness window that keeps only the records written or re-read at or after a timestamp; deletions never appear in it, so a table's change feed is still how you stay in sync.

Point lookups work by external id within an entity type, or by _record_id across all of them. get by external id covers records written through this API; a synced record is found by the id its source system gave it, with _external_id in where (eq or in):

const { record, tables, attachments } = await valtrix.records.customer.get({ externalId: 'usr_8f2' })
const [cost] = await valtrix.records.cost.findMany({ where: { _external_id: '408B8849-ED90-4B1B-808F-FC1779CFA69A' } })
const found = await valtrix.records.get('rec_9f2k7d')

Reading tables

Tables are published, versioned views over records with their own column shapes and quality gates. When one covers what you need, prefer it.

const vips = await valtrix.customersClean.findMany({
    where: { lifetime_value: { gte: 10_000 }, email: { not: null } },
    orderBy: { lifetime_value: 'desc' },
    limit: 50,
})

for await (const row of valtrix.customersClean.iterate()) {
    process(row)
}

const [invoice] = await valtrix.invoicesClean.findMany({ where: { _record_id: 'rec_9f2k7d' } })

Rows come from every organization your key can see. Pass org to narrow to one organization. Filters (eq, neq, gt, gte, lt, lte, in, not: null), ordering, select, and pagination all run server side. Rows carry the same _record_id, _org_id, _connector, _connection_id, _source_id, _last_write_intent, _synced_at, _changed_at, _frozen, and _attachment_id meta fields; _synced_at and _changed_at take range filters (gt, gte, lt, lte) for a cutoff, while the change feed is the way to stay in sync because deleted rows never show up in a filtered read. _attachment_id is the newest stored attachment of the row's record and null on rows that aggregate many records; where: { _attachment_id: { not: null } } (an AttachmentFilter: an id, null, or { eq } / { neq } / { in } / { not: null }) keeps only the rows that have a file.

Writing records

Writes go through records, keyed by your externalId within the organization, so the same call creates or updates. They flow through any published transformation like any other source, so they arrive in your tables cleaned, deduped, and quality checked.

const result = await valtrix.records.customer.upsert({
    externalId: 'usr_8f2',
    data: { name: 'Jane Cooper', email: '[email protected]' },
})

await valtrix.records.customer.delete({ externalId: 'usr_8f2' })

The response includes the derived table rows (result.tables), so you read your write already transformed. A row that fails an error-severity quality rule is reported as quarantined instead of landing in the table.

Pass idempotencyKey on any write you might retry. It is sent as the Idempotency-Key header, and a replay returns the stored response instead of writing twice:

await valtrix.records.customer.upsert({
    externalId: 'usr_8f2',
    data: { name: 'Jane Cooper' },
    idempotencyKey: 'signup-8f2',
})

A record takes its lines in the same call. Pass lines on a contract, estimate, invoice, order, cost, or journal entry and each one is written as its own line_item record with document_entity, document_type, document_id, and position stamped from the parent record; result.lines returns them:

await valtrix.records.contract.upsert({
    externalId: 'po-1042',
    data: { kind: 'purchase_order', title: 'Framing materials', counterparty_id: vendor.id, project_id: job.id },
    lines: [
        { description: '2x6x16 Treated Bottom Plate', quantity: 8, uom: 'pc', unit_cost: 13.83, cost_code_id: costCode.id },
        { description: '6"x50\' Sill Seal', quantity: 2.4, uom: 'roll', unit_cost: 5.45, cost_code_id: costCode.id },
    ],
})

A record can carry files. Pass attachments with the file's name, media type, and content (base64 or bytes); each one is stored as a record attachment, the same object a synced document becomes, and comes back on result.attachments. An optional kind, a short lowercase token such as invoice or purchase_order, says what the file is, for a connector whose write puts different files through different steps; the connection's writeGuidance names the kinds its connector reads, and a file with no kind is the default one:

await valtrix.records.cost.upsert({
    externalId: '408B8849-ED90-4B1B-808F-FC1779CFA69A',
    data: { kind: 'bill', po_number: '240610' },
    attachments: [
        { filename: 'CADI-195884.xml', mediaType: 'text/xml', content: xmlBytes },
        { filename: 'CADI-195884.pdf', mediaType: 'application/pdf', content: pdfBytes, kind: 'invoice' },
        { filename: 'PO-240610.pdf', mediaType: 'application/pdf', content: poPdfBytes, kind: 'purchase_order' },
    ],
    propagate: true,
})

Writing through to the source system

By default a write stays in Valtrix. Pass propagate: true to also push it into the organization's connected system. The canonical write still lands immediately; the push to the source is tracked as a write intent, returned on the result:

const result = await valtrix.records.customer.upsert({
    externalId: 'usr_8f2',
    data: { name: 'Jane Cooper', email: '[email protected]' },
    propagate: true,
    idempotencyKey: 'signup-8f2',
})

if (result.writeIntent) {
    const writeIntent = await valtrix.writeIntents.get(result.writeIntent.id)
    if (writeIntent.status === 'failed' && writeIntent.error) {
        const { code, message, hint, retryable, sourceMessage, step, sourceChanged } = writeIntent.error
        if (retryable) await valtrix.writeIntents.retry(writeIntent.id)
        else escalate(`${code}: ${message}`, { hint, sourceMessage, step, sourceChanged })
    }
}

An applied write intent's verification says what the source showed afterwards: confirmed when the connector read the write back and every field it sent came back as sent, unconfirmed when the operation has no read-back (a form submission, a document upload, an API that does not echo the fields), and contradicted when the read-back showed a different value for at least one field. The status is applied in all three cases; contradicted means the source accepted the write and then showed something else, which is the source normalising the value or applying a rule of its own, so read the record back before writing again. It is null while the intent is pending or failed. Wire: verification on the write intent object.

A write intent's steps is the outcome of each stage of a multi-step write as the connector reports it, or null when the connector reports none: an array of WriteIntentStep, { key, label, status, detail }, in the connector's own order, where status is pending (the write is still running and the stage has not finished; detail is null), done, unchanged (the source already held what the stage would have written), skipped (nothing in the write asked for it, or the source offered no way to do it for that login, which detail says), or failed (the stage whose step failed, with the source's answer as detail). While the intent is pending the list is updated as each stage finishes, so valtrix.writeIntents.get(id) mid-run shows the stages behind the current one settled and the rest pending; once the intent is applied or failed every entry is settled, so a failed intent says which stages landed before the one that did not, and a connector that uploads a document, links it, validates the link, and attaches a second file in one write reports each of those as its own step. Wire: steps on the write intent object.

A failed write intent's error says why in plain words. code is one of refused (the source refused the write in its own words), invalid_input (the record lacks something the source needs, or carries nothing the operation sends), precondition_failed (a rule of the connector stopped the write before anything was sent), not_found, exists, source_error (the source failed internally), session_refused, rate_limited, unreachable, reference_pending (a referenced record has no source id yet), grant_missing (the login the connection signs in with lacks a permission the operation needs at the source; the message names the objects and, for a bridge connection, the grant script that adds them), connector_error (the connector could not express the write; Valtrix is notified) or write_failed; message says what went wrong and what to do, hint explains the code, retryable whether retrying the same write can change the outcome, sourceMessage is the source system's own text when message was translated from it, step the connector step that failed, and sourceChanged whether anything had been posted to the source before the failure. A failed write intent whose connector learned the source's id for the record before a later step failed carries it as sourceExternalId: the source holds the record, the record is bound to that row so the next sync updates it rather than creating a second one, and re-sending the same write finishes the remaining steps. Wire: error on the write intent object, { code, message, retryable, hint, source_message, step, source_changed }.

A propagated write addressed by a synced record's own id updates that row at the source: write the entity under the _external_id the connected system synced it with (an invoice's CFDI UUID, a vendor code) and the write intent is an update on that record's connection; any other external id creates the row, including a synced record's id sent with connection naming a different connection (a Miter transaction id written to Sage creates there). A repeat of an external id whose create is still pending on that connection updates the record in Valtrix and returns the pending write intent rather than queuing a second create. When the connector's write receives only the fields you sent (a portal or SDK write), the change is applied to that synced record itself: the result's record.id is the synced record's id and record.connector its connector, its documents and table rows stay where they are, and no second record appears beside it. A propagated write that carries attachments hands the files to the connector's write when it takes them, the way an invoice's XML and PDF are uploaded into a supplier portal. A propagated write that carries lines reaches the source as one unit: a purchase order lands in the accounting system with every line in a single transaction, and the applied write intent's sourceExternalId is the number the source assigned. examples/estimate-to-purchase-order.ts is the whole loop, estimate lines in Sage Estimating to a purchase order in Sage 100 Contractor.

result.writeIntent is { id, status, connectionId } and is only present on propagated writes. When an organization has more than one connection that can take the write, pass connection with a connection id from the org object to pick one. The org's connections[].writes map tells you which entity types and operations (create, update, delete) will propagate on each connection, and valtrix.connectors.list() exposes per-connector writes under writes, one { create, update, delete, verified } entry per entity type, where verified says a write of that kind has already run against a real account. If the connector cannot take the operation the call fails with write_not_supported; write_ambiguous means you need to pass connection; write_fields_unsupported means the connection sends none of the fields in data on that operation (the message names the ones it sends), so nothing was written. A field the connection does not send is stored in Valtrix and named in the result's warnings, so a write never reports applied while the source was left untouched.

Before a propagated write, read writeGuidance on the connection (connections[].writeGuidance on the org object, or valtrix.connectors.list() per connector), keyed by entity type: fields lists the keys each operation sends with their meaning and whether they are required, and a field that acts as a command carries values, each with the effect the source performs (FACEL's review_status takes Auditado, which accepts the invoice and cannot be undone); documents lists the files an operation carries by media type; notes are the connector's own rules on preconditions, side effects, and refusals. Send what it names instead of inferring from column names.

SDK connectors also accept sourceFields (wire: source_fields) for source-specific fields and operations. These options require propagate: true, stay on the write intent, and do not become canonical record columns. For Sage Estimating:

await valtrix.records.estimate.upsert({
    externalId: 'estimate-request-42',
    data: { title: 'Warehouse extension' },
    propagate: true,
    idempotencyKey: 'estimate-request-42-create',
    sourceFields: {
        fields: { 'ProjectInfo.City': 'Boston', 'CoverPageInfo.Estimator': 'Alex' },
        collections: [{ collection: 'Locations', op: 'create', key: 'First floor', fields: {} }],
    },
})

Sage supports create, update, and delete for estimates, estimate lines, phases, and catalog items through its Windows SDK adapter. A new phase takes sourceFields.estimate_id; a new line takes document_id or that source estimate id. Unit and phase references use Sage's exact spelling and case. sourceFields.lines supports explicit nested line operations; updates and deletes require source_id as EstimateId:ItemId. SDK writes can contain several native saves. Inspect a failed intent and its Windows receipt before submitting a replacement intent, because earlier saves may have succeeded.

Write intents are queryable on their own:

const pending = await valtrix.writeIntents.list({ status: 'pending', entityType: 'customer' })
const { writeIntents, nextCursor } = await valtrix.writeIntents.page({ status: 'failed' })
for await (const writeIntent of valtrix.writeIntents.iterate({ recordId: 'rec_9f2k7d' })) {
    inspect(writeIntent)
}

Rather than polling, subscribe a webhook endpoint (valtrix.webhooks.create, or the console) to the write_intent.applied and write_intent.failed events: each carries the write intent's id, status, op, entity_type, record_id, the source_external_id it was written under (on a failed event, filled when the source took the record before a later step failed, so re-sending finishes the rest; null otherwise), the error on a failed event, and steps when the connector reports them. Always pass idempotencyKey on propagated writes you might retry, so a retry never creates a second record in the source system.

Reacting to changes

Your tables are the database, so there is nothing to copy or keep in sync. The change feed is how you react when data changes: kick off a workflow, notify someone, refresh something you derived.

Let Valtrix tell you when to read it. Register a webhook endpoint for table.changed: each delivery names the table that moved, and your handler drains that table's feed from the cursor you stored. receiveWebhook verifies the signature and parses the body:

import { receiveWebhook } from '@valtrix/sdk'

app.post('/webhooks/valtrix', express.text({ type: 'application/json' }), (req, res) => {
    const event = receiveWebhook({ secret: process.env.VALTRIX_WEBHOOK_SECRET!, rawBody: req.body, signatureHeader: req.header('valtrix-signature') })
    res.sendStatus(200)
    if (event.type === 'table.changed' && event.data.table === 'customers_clean') void drainCustomers()
})

async function drainCustomers() {
    for await (const change of valtrix.customersClean.changes({ cursor: await loadCursor() })) {
        if (change.type === 'delete') await closeOpenTasks(change.recordId)
        else if (change.changedColumns?.includes('owner')) await notifyAccountManager(change.row, change.previous)
        await saveCursor(change.cursor)
    }
}

setInterval(drainCustomers, 30 * 60 * 1000)

The feed is ordered and resumable. Store the cursor and pick up where you left off. Every upsert carries changedColumns, the columns that differ from the row's previous version (null for a row new to the table), and previous, the values those columns held before, so you can react to the change you care about without diffing rows yourself. A row whose record left or re-entered the connector's sync window arrives as an update whose changedColumns is ['_frozen'], and a row whose record gained a newer attachment, with nothing else different, as an update whose changedColumns is ['_attachment_id']. Records have no change feed; query them live instead.

The feed only moves when a sync of the organization's connections finishes or a record is written, so a timer finds nothing almost every time. Keep one slow timer anyway, running the same drain every 15 to 60 minutes as a reconcile that covers a delivery you missed, and without webhooks poll no more often than every 15 minutes. Run one drain per table at a time. A read that finds no changes, repeated with the same cursor less than 60 seconds after the first empty one, is answered with poll_too_frequent (429, with Retry-After) instead of an empty list, and changes() ends as caught up, warning through onWarning; a read that finds changes is always served.

For an alert on one column, subscribe to table.row_changed instead. Each delivery is one change with the same fields as a feed entry, so you can act on it without reading the feed:

app.post('/webhooks/valtrix', express.text({ type: 'application/json' }), (req, res) => {
    const event = receiveWebhook({ secret: process.env.VALTRIX_WEBHOOK_SECRET!, rawBody: req.body, signatureHeader: req.header('valtrix-signature') })
    res.sendStatus(200)
    if (event.type === 'table.row_changed' && event.data.changedColumns?.includes('status')) {
        postToSlack(`${event.data.row?.name} moved from ${event.data.previous?.status} to ${event.data.row?.status}`)
    }
})

verifyWebhookSignature and parseWebhookEvent are exported separately when you want the two steps apart. Deliveries can repeat, so deduplicate on event.id.

Webhook endpoints

const endpoint = await valtrix.webhooks.create({
    url: 'https://yourapp.com/webhooks/valtrix',
    eventTypes: ['table.row_changed', 'connection.broken', 'write_intent.failed'],
    tables: [{ table: 'customers_clean', columns: ['status'], changeTypes: ['update'] }],
})
await storeSecret(endpoint.secret)

const outcome = await valtrix.webhooks.test(endpoint.id)
const endpoints = await valtrix.webhooks.list()
const current = await valtrix.webhooks.get(endpoint.id)
await valtrix.webhooks.update(endpoint.id, { tables: [{ table: 'customers_clean' }], enabled: true })
const { deliveries, total } = await valtrix.webhooks.deliveries(endpoint.id, { limit: 20 })
const { events } = await valtrix.webhooks.events(endpoint.id, { status: 'dead' })
const { replayed } = await valtrix.webhooks.replay(endpoint.id)
const rotated = await valtrix.webhooks.rotateSecret(endpoint.id)
await valtrix.webhooks.delete(endpoint.id)

create registers the URL and returns the signing secret once; rotateSecret is the only other call that returns one. Omit eventTypes to receive every event type except table.row_changed; passing tables adds table.row_changed for you. Each table subscription takes columns, the columns an update must touch to be delivered, and changeTypes, any of insert, update, and delete; columns never holds back a new row or a delete, so an alert on one column sets both, as above. Leave changeTypes out for all three. update replaces whichever of eventTypes and tables you send and pauses or resumes delivery with enabled; a paused endpoint keeps queuing events until it is resumed. update also moves an endpoint with url (and relabels it with description): the queue, secret, and subscriptions stay, the failure count resets, and every waiting event is sent to the new URL straight away, so a wrong URL costs nothing but the delay. test posts an endpoint.test event and reports the response, and deliveries reads the last 14 days of attempts with their status, HTTP status, error, the body that was posted (requestBody), and the first 2 KB of what the receiver answered (responseBody). Every endpoint carries pendingEvents, oldestPendingAt, deadEvents, and discardedEvents, and valtrix.webhooks.get(id) reads one endpoint back with those counters; events lists the queue (filter by status) and replay re-queues dead and discarded events, all of them or the eventIds you name; an event goes dead after deliveries have failed for about 3 days. A table subscription whose resyncRequiredAt is set fell behind the change retention window: do a full read of that table, then send it in update again to clear the flag. Managing endpoints requires a platform-wide key with manage access.

When a receiver comes back after being down, decide what happens to what queued while it was away. update(id, { queuedEvents: 'send' }) clears the failure count and delivers every waiting event at once rather than letting each wait out its backoff, which is right for a receiver that syncs data and must not miss a change. update(id, { queuedEvents: 'discard' }) drops the queue so only new changes arrive, which is right for a receiver that posts alerts and would otherwise announce days of old changes at once; discarded events stay listed and replay brings them back within 14 days. Pass queuedEvents with enabled: true to choose as you resume a paused endpoint. To never face the choice on an alerting endpoint, set staleAfterSeconds (on create or update, 60 seconds to 30 days): an event older than that is discarded instead of delivered, so a day-long outage replays a day of changes at most.

await valtrix.webhooks.update(endpoint.id, { enabled: true, queuedEvents: 'discard' })
await valtrix.webhooks.update(endpoint.id, { staleAfterSeconds: 24 * 60 * 60 })

An endpoint can post a body of your own shape instead of the Valtrix event. Set payloadTemplate to a JSON string with {{path}} placeholders that are filled from each event, and Valtrix posts the rendered document; a chat tool's incoming webhook is the typical receiver:

const preview = await valtrix.webhooks.preview({
    payloadTemplate: '{ "text": "{{data.row.name}} moved from {{data.previous.status | default \'new\'}} to {{data.row.status}}" }',
    tables: [{ table: 'customers_clean', columns: ['status'] }],
})
console.log(preview.body, preview.placeholders)
await valtrix.webhooks.update(endpoint.id, { payloadTemplate: preview.skipped ? null : '{ "text": "{{data.row.name}} is now {{data.row.status}}" }' })

Placeholders start with id, type, occurred_at, or data, and go down to data.row.<column> and data.previous.<column> for table.row_changed; they are checked against the endpoint's event types and tables when saved. A string that is exactly one placeholder keeps the value's own JSON type, placeholders inside text are interpolated, | default 'text' fills in a missing value, and | escape writes a value with &, <, and > as &amp;, &lt;, and &gt; (the two combine in either order). To post straight into Slack, register the Slack incoming webhook URL as the endpoint url and set the template to { "text": "..." } and nothing else, with | escape on placeholders that carry free text, since Slack reads those three characters as markup. An event whose placeholders all resolve to nothing is skipped rather than posted, and test on such an endpoint sends the template rendered against the same sample event preview uses. Pass payloadTemplate: null to go back to the standard event.

Organizations and Connect

Each of your customers is an organization. They authorize their systems once through a hosted Connect session, and the org object tells you what is connected:

const session = await valtrix.connect.createSession({
    orgDisplayName: 'Acme Construction',
    externalId: 'acct_42',
    redirectUri: 'https://app.example.com/integrations/done',
})
redirect(session.connectUrl)

const org = await valtrix.orgs.get(session.orgId)
for (const connection of org.connections) {
    inspect(connection.id, connection.connector, connection.status, connection.writes)
}

for await (const org of valtrix.orgs.iterate({ status: 'active' })) sync(org)

valtrix.orgs.list(), valtrix.orgs.page(), and valtrix.orgs.iterate() filter by status and externalId.

When a connector captures both a product sign-in and API credentials on one Connect page, credentialSections restricts the session to one of the two, 'login' or 'api'. Omit it to ask for everything the connector captures, which is the default and what most integrations want.

When you hold a system's login for an organization that has already confirmed your access, pass link: 'sign_in' with org and connector to mint a sign-in link instead of a connect link: whoever opens it enters that system's sign-in details with no Valtrix account and no access to any data, it grants nothing, it expires after 7 days, and the organization's owners are told when it is used. The result's link says which kind you got. It is refused with grant_required until the organization's grant is active and covers every entity the connector supplies, so it never widens what you can read; send a connect link in that case and let the organization approve.

const link = await valtrix.connect.createSession({ org: orgId, connector: 'doordash', link: 'sign_in' })
open(link.connectUrl)

scope narrows the access the link asks for. Name entity types with { entityTypes: [...] }, or name data categories with { categories: [...] } when you do not yet know which system the organization will connect: a category is a business-function group such as payables, receivables, ledger, or workforce, and expands to its entity types before the link is minted, line items following the documents that carry them. valtrix.schema() lists every category with the entity types it expands to under categories. Omit scope for full access.

const link = await valtrix.connect.createSession({ orgDisplayName: 'Acme Inc', scope: { categories: ['payables', 'ledger'] } })

On an organization-scoped key (vlt_org_), connect.createSession always mints the organization's own sign-in link for one system: connector is required, and orgDisplayName, externalId, scope, redirectUri, and link are ignored there.

Connections

Each connected system is a connection. Read them across organizations, decide how far back history loads, run a full sync on demand, and pause one while you read its data still:

const session = await valtrix.connect.createSession({ org: orgId, connector: 'sage-100-contractor', historyFrom: '2022-01-01' })

for await (const connection of valtrix.connections.iterate({ status: 'connected' })) inspect(connection)

const run = await valtrix.connections.sync(connectionId)
const finished = await valtrix.connections.getRun(connectionId, run.id)
inspect(finished.report, finished.tables)

await valtrix.connections.pause(connectionId, { until: '2026-10-01T00:00:00Z' })
await valtrix.connections.resume(connectionId)

valtrix.connections.list() returns one page of connections, valtrix.connections.page({ cursor }) walks them a page at a time, and valtrix.connections.get(connectionId) reads one. sync(connectionId, { mode: 'full' }) queues a full re-read of every resource; full is the only mode and the default, because incremental syncs run on the connection's own schedule. sync(connectionId, { entityTypes: ['activity'] }) re-reads only the resources that feed those entity types, for the records already synced, and leaves everything else alone. A requested sync has a cooldown: another one asked for within one of the connection's sync intervals of the last one finishing throws sync_cooldown (409, with a Retry-After header), and an entity type the connection does not sync throws invalid_entity_types. historyFrom is a one-time backfill of a connection's date-bounded history and never narrows it; valtrix.connections.update(id, { historyFrom }) widens a live one and queues a full sync. The first sync loads from that date; every later sync fetches the connector's default trailing window and keeps the backfilled records without refreshing them. getRun returns a per-resource report (counts, failed scopes, the effective window start and why, and for a resource that failed an error sentence plus the errorCode to branch on) and, once the organization's tables have rebuilt, one change-feed cursor per table: read the tables, then replay changes({ cursor }) from those cursors to pick up everything after that run. A paused connection keeps its data readable and its credentials stored; a pause with until resumes on its own. A platform can pause only a connection it is the sole reader of; otherwise the organization pauses it from its console.

Connectors

const connectors = await valtrix.connectors.list()

const result = await valtrix.connectors.add({ providerName: 'Aspire', homepageUrl: 'https://www.youraspire.com' })
if (result.outcome === 'product_choice_required') pick(result.candidates)

const requests = await valtrix.connectors.listRequests()

Each connector carries entityTypes and a writes map of { create, update, delete, verified } per entity type. add requests a connector for a system Valtrix does not cover yet and returns created, attached, already_available, or product_choice_required with candidates. Call add again with productChoice set to the productName of the candidate you picked to file the request for that product. It needs write access on either key kind; organization-scoped keys may pass platform to file the request for a platform that has access to the organization (omit it to request through the organization's own Valtrix access). listRequests returns the platform's requests on a platform-wide key and the organization's on an organization-scoped one.

API keys

const key = await valtrix.keys.create({ org: 'org_acme', name: 'Acme app', access: 'write' })
const keys = await valtrix.keys.list({ org: 'org_acme' })
await valtrix.keys.revoke(key.id)

keys.create mints an organization-scoped key and returns the secret once. Managing keys requires a platform-wide key with manage access.

Attachments

Records can carry files synced from the source system (contracts, invoices, photos):

const attachments = await valtrix.records.attachments('rec_9f2k7d')
const { url, expiresIn } = await valtrix.attachments.url(attachments[0].id, { disposition: 'inline' })
const { data, mediaType, filename } = await valtrix.attachments.download(attachments[0].id)

Schema

await valtrix.schema() returns the live schema: published tables with their columns and versions, every record entity type with its fields, and categories, the data categories a Connect session's scope may name, each with the entity types it expands to. The generated client is built from the same response.

Transformations as code

The tables your client queries are produced by transformations, and creating a transformation is the only way to create a table: a new table is a definition file whose table is its name, deployed. Each has a canonical YAML definition that can live in your repository and deploy like code:

npx valtrix pull                 # export every definition to ./transformations
npx valtrix pull customers_clean # or just one table
npx valtrix plan                 # diff local files against the workspace, exit 1 when changes exist
npx valtrix deploy --yes         # plan, then publish the changes

Every command accepts --url <base url>. pull, plan, and deploy read and write the definition directory and accept --dir <dir>, which defaults to ./transformations; generate writes its client to --out <path>, which defaults to valtrix/client.ts. plan and deploy accept --force-adopt, which takes over the tables that are managed in the dashboard so they are managed in code instead: without it a plan marks each of them blocked and deploy stops before publishing anything. Deploying requires a platform-wide key with manage access. The definition format (envelope fields, step types, expression functions, conditions, and quality rules) is documented in the API reference under Transformations.

Applets

Applets are the small interfaces an organization's members open in the Valtrix console to work across their connected systems (an import list with a button, a purchase order form, a queue of invoices to post). Each is a JSON definition, and the client creates applets from definitions and publishes new versions of them, so a definition can live in your repository and ship like code. The API takes finished definitions only; the assistant that writes one from a description exists in the organization console alone.

import definition from './applets/work-orders.json'

const applets = await valtrix.applets.list({ org: 'org_acme' })
const created = await valtrix.applets.create({ org: 'org_acme', definition, note: 'First version' })
const current = await valtrix.applets.get(created.id)
const next = await valtrix.applets.update(current.id, { definition, expectedVersion: current.version!, note: 'Adds the placed date column' })

An Applet carries the published definition, its version, publishedBy (a RecordActor, key for a publish through the client), the note sent with it, and hasDraft, whether the console holds unpublished edits. Every create and update validates the definition against the organization's connected systems and data and queries each source once; a definition that does not fit fails with definition_rejected and an issues list (AppletValidationIssue, path and message) and nothing is written. update takes the expectedVersion the last read returned and fails with applet_version_conflict when someone published in between; a definition identical to the published one is a no-op. An applet with unpublished console edits fails with applet_draft_exists unless replaceDraft is true. An organization-scoped key acts for its organization and omits org; a platform-wide key names it. Creating and updating needs write access, and applets have to be enabled for the organization by Valtrix (applets_not_enabled otherwise). Publishing turns off the applet's automation until it is turned on again from the console. The definition format is documented in the API reference under Applets.

Staying in sync with the schema

The generated client is pinned to the schema version it was built from. When your data team publishes a new version, the runtime logs a warning, and valtrix generate --check exits non-zero, so add it to CI next to your typecheck:

npx valtrix generate --check

Agent-built apps

If your platform lets users build apps with coding agents, this package ships the standing instructions those agents need at node_modules/@valtrix/sdk/AGENTS.md. In your app template: add @valtrix/sdk as a dependency, set VALTRIX_API_KEY to an organization-scoped key minted for that app (valtrix.keys.create, or POST /v1/keys), run npx valtrix generate so the typed client is in the tree before the agent starts, and copy AGENTS.md to the project root together with the CLAUDE.md shipped beside it, whose only line is @AGENTS.md, so Claude Code, which reads CLAUDE.md rather than AGENTS.md, picks up the same rules as Codex, Cursor, and Copilot:

cp node_modules/@valtrix/sdk/AGENTS.md node_modules/@valtrix/sdk/CLAUDE.md .

The file versions with the SDK, so regenerating a scaffold always picks up the instructions matching the installed client, including how to write records and when to propagate them. The Embedded coding agents section of the API reference in your console covers the full setup.

Configuration

| Variable | Purpose | | --- | --- | | VALTRIX_API_KEY | Platform API key, required | | VALTRIX_API_URL | API base URL, defaults to the hosted API |

Both are also read from .env / .env.local in the working directory, and can be passed to createClient directly as apiKey and baseUrl, along with fetch, maxRetries, and onWarning. maxRetries defaults to 3, and a wait asked for by a rate limit is capped at 30 seconds. The client retries rate limits and transient server errors with backoff and throws typed errors otherwise: ValtrixError (with status, code, fields, and details), ValtrixAuthError, ValtrixRateLimitError (with retryAfterSeconds), and ValtrixCursorExpiredError. Write-through adds the codes invalid_propagation, invalid_source_fields, unknown_connection, write_not_supported, write_ambiguous, write_fields_unsupported, write_intent_not_found, and write_intent_not_retryable.