Ontology SDK

TypeScript

Read objects and apply actions from TypeScript.

The TypeScript client has no dependencies. It runs in browsers, Node.js 18+ and Deno.

Install

Add the Converge registry to your .npmrc, then install the package. See Add the SDK to a repository for the .npmrc lines.

npm install @convergehq/osdk-<ontology>

Always install with the @convergehq:registry= line in .npmrc, including in CI and with npx. Your ontology package is only on Converge, so without that line npm looks for it on the public registry and fails.

Create a client

import { createClient } from "@convergehq/osdk-<ontology>";

const client = createClient({
  baseUrl: "https://cnvrg.si/api",
  token: () => process.env.FOUNDRY_TOKEN!,
});

token can be a string or a function, and the function can be async. The client calls it before every request, so you can refresh the token there. Pass fetch to use your own HTTP stack, for example in tests.

Two more options match Palantir's createClient. $ontologyRid must be the ontology the package was generated for; any other value throws a TypeError. The package exports it as $ontologyRid. logger takes any object with debug, info, warn or error methods, such as console or pino. It gets one debug line per request (method, path and status) and the errors your subscription callbacks throw. The client never logs a token.

baseUrl must be https, or a path on the page's own origin such as /api. Plain http is only accepted for the local machine (localhost), and a user name or password in the URL is refused. The client refuses every HTTP redirect, so your token is only ever sent to the host you named.

Sign in

The package includes OAuth helpers like @osdk/oauth. They sign in against Converge's Keycloak (realm converge), which they find at <origin>/kc/realms/converge/.well-known/openid-configuration. Both return a token function that createClient takes as token.

import { createClient, createPublicOauthClient } from "@convergehq/osdk-<ontology>";

const auth = createPublicOauthClient("my-app", "https://cnvrg.si", `${window.location.origin}/callback`);
const client = createClient({ baseUrl: "https://cnvrg.si/api", token: auth });

createPublicOauthClient(clientId, baseUrl, redirectUrl, opts) is for browser apps. It uses the authorization code flow with PKCE. The first request without a token sends the page to the Keycloak sign-in page. When the browser comes back to redirectUrl, the next request exchanges the code and removes code and state from the address bar. Later requests refresh the token with the refresh token. Tokens are kept in memory. With opts.storage: "sessionStorage", the refresh token survives a reload of the tab. auth.signIn(), auth.signOut() and auth.refresh() are there for your own buttons. opts.scopes defaults to ["openid"]. opts.realm or opts.issuer point the helper at another realm or issuer. The configuration must name that same issuer, and its endpoints must be on the issuer's origin, so a tampered configuration cannot send your code or secret to another host. If your realm's issuer is not <origin>/kc/realms/converge, pass it as opts.issuer. Concurrent requests share one refresh. A failed refresh signs you out only when the server refuses the refresh token (a 4xx), not on a 5xx or a network error.

createConfidentialOauthClient(clientId, clientSecret, baseUrl, scopes?) is for servers and scripts. It uses the client credentials grant and caches the token until shortly before it expires.

  • Never put a client secret in a browser bundle: everyone who loads the page can read it. createConfidentialOauthClient throws when it runs in a browser. In the browser, use the public client.
  • The client id must be a client in the converge realm (public for the browser, confidential with service accounts for a server), and the API must accept it: an administrator adds it to OIDC_ALLOWED_CLIENTS.
  • The helpers never log tokens. An error from the token endpoint is an OsdkError that carries only the server's error and error_description.

Read objects

const customers = client.objects["Customer"];

// One page, filtered and sorted
const page = await customers
  .where({ tier: "gold" })
  .fetchPage({ $pageSize: 20, $orderBy: { revenue: "desc" } });

// Every match, page by page
for await (const c of customers.where({ revenue: { $gt: 1000 } }).asyncIter()) {
  console.log(c.$rid, c.name);
}

// One object by primary key
const one = await customers.fetchOne("c1");

Add $select: ["name", "tier"] to fetchPage or asyncIter to load only those properties. $rid always comes back, and a property you may not see stays hidden whatever you select.

$applyModifiers on fetchPage or asyncIter reads a property in another form. applyReducers turns an array property with reducers into its selected element. applyMainValue turns a struct with a main value into that field, or into an object of the fields when there are several. applyReducersAndExtractMainValue reduces a struct array to one struct, then returns its main value. The object type sets reducers and main values (see Object types):

const page = await sites.fetchPage({ $applyModifiers: { readings: "applyReducers", visits: "applyReducersAndExtractMainValue" } });
const latest: number | undefined = page.data[0].readings; // not number[]

Each property only takes the modifiers its configuration supports, so any other is a type error, and the result type follows what you asked for. The server answers 400 for an unsupported modifier and 403 for a masked property. Filters and $orderBy still use the full value. fetchOne has no $applyModifiers, as in the Palantir OSDK. createIntegrationServer({ metadata: $metadata }) applies modifiers the same way.

fetchOne takes either the primary key ("c1") or the object's $rid ("customer/c1"). It works for keys made of letters, digits, _ and -. For any other key, filter on the key property instead: where({ id: "a1/b" }).

An equality value made of letters, a /, then letters, digits, _ or - (such as a/b or customer/c-1) is read as an object reference, so it never matches a text property. a1/b, 1/2 and a/b/c are plain text. For a text key of the reference shape, use a regex clause, which matches the whole value: where({ type: "regex", property: "id", value: "a/b" }).

A filter is { property: value } for equality, { property: null } for a missing value, or an operator object: $eq, $gt, $gte, $lt, $lte, $startsWith, $containsAnyTerm, $containsAllTerms, $containsAllTermsInOrder and $isNull (true or false). $gt, $gte, $lt, $lte and range take a number or an ISO 8601 date or timestamp ("2026-01-31", "2026-01-31T12:00:00Z"). Any other string answers 422. { created: someDate } throws before anything is sent; inside an operator such as $gt, a Date is sent as its ISO string. $startsWith is case-sensitive: { name: { $startsWith: "Ac" } } does not match acme. Use text search to ignore case. Several keys in one object must all match. Combine filters with $and, $or and $not, or chain where calls. { $and: [a, { $not: b }] } is the objects of a that are not in b.

Text search matches whole words, ignoring case. Terms are separated by whitespace or ASCII punctuation, so red-box is the two words red and box, while a letter such as é is part of its word (caf does not match café). containsAnyTerm needs one of the words, containsAllTerms all of them in any order, and containsAllTermsInOrder the words as an adjacent phrase. In raw form, containsAllTermsInOrderPrefixLastTerm also lets the last word be a prefix (big re matches big red). A query holds 1 to 20 words and at most 256 characters. Fuzzy matching is not supported: fuzzy: true is refused.

Every object the client returns carries $rid, $apiName, $primaryKey and $link (see below). Only $rid is enumerable, so the other three never show up in JSON.stringify, a spread { ...o }, or a request body you send back.

A geopoint property is typed GeoJsonPoint, { type: "Point", coordinates: [lon, lat] }. The server may store a point as "lat,lon" text; the client turns that into GeoJSON on read. A geoshape property is typed GeoJsonGeometry. A stored value that is neither comes back as the server sent it. In where, a geo property still takes the stored text, for example { loc: "52.1,4.3" }. Geo filters such as withinDistanceOf are raw clauses.

Two more clause types are available in raw form: { type: "range", property, value: [lo, hi] } and { type: "regex", property, value }. A regex always matches the whole value, so ^ and $ are refused, and so are nested quantifiers such as (a+)+. Pages hold 1 to 1000 objects.

Follow links

const customer = await client.objects["Customer"].fetchOne("c1");
const page1 = await customer.$link.orders.fetchPage();      // the same as linked("c1", "orders")

const orders = client.objects["Customer"].linked("c1", "orders");
const page = await orders.fetchPage({ $pageSize: 50 });
for await (const o of orders.asyncIter()) console.log(o.$rid);
const owner = await client.objects["Order"].linked("o1", "placedBy").fetchOne("c1");

linked takes the object's primary key or $rid and a link name of that type. object.$link.<link> is the same linked set for an object you already have, with fetchPage, fetchOne and asyncIter. Link names are typed, and so are the linked objects, including their own $link. You only get links to types you can see, and linked objects come back with the same masking and markings as any other read. fetchOne on a linked set answers 404 when the object is not linked.

Aggregate

const res = await customers.aggregate({ type: "sum", field: "revenue", group_by: ["tier"] });
for (const row of res.data) console.log(row.group, row.metrics);

type is one of count, sum, avg, min, max, exactDistinct or approximateDistinct (both distinct counts are exact here, and work on object types with up to 10,000 visible objects; above that they answer 400). Leave out field for count.

A group_by entry can also bucket a number: { field: "revenue", ranges: [[null, 1000], [1000, 10000], [10000, null]] } or { field: "revenue", fixedWidth: 500 }. A range includes its start and excludes its end, and null is open. Each group's value is { startValue, endValue }. A value outside every range lands in the null group. Ranges may not overlap, and each property may appear once in group_by. Bucketing works on object types with up to 10,000 visible objects (400 above that). With a fractional fixedWidth, bucket edges carry ordinary floating-point rounding, for example 0.30000000000000004.

A date or timestamp property buckets by time: { field: "placedAt", duration: 1, unit: "MONTHS" }, with unit one of SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, QUARTERS or YEARS. Buckets are in UTC (a value without an offset is read as UTC), weeks start on Monday, and months, quarters and years follow the calendar. Each group's value is { startValue, endValue } as ISO timestamps, end excluded. A value that is not an ISO date or timestamp lands in the null group.

{ field: "tier", maxGroupCount: 5 } keeps the 5 values with the most objects and drops the rest; the answer's accuracy is then APPROXIMATE. An aggregate may produce at most 10,000 groups; beyond that it answers 400, so use a larger duration or maxGroupCount.

To compute several metrics at once, pass metrics instead of type and field, and optionally order the groups by one of them:

const res = await customers.aggregate({
  group_by: ["tier"],
  metrics: [{ type: "count" }, { type: "sum", field: "revenue", name: "revenue" }],
  order: { metric: "revenue", direction: "desc" },   // groups without a value come last
});

Up to 10 metrics, each named name, or <type> or <type>_<field> by default; names must be unique.

Nearest neighbors

For a vector property (vectorDimension numbers per object), find the objects closest to a query vector:

const { data, skipped } = await client.objects.Doc
  .where({ type: "eq", property: "lang", value: "en" })
  .nearestNeighbors(queryEmbedding, 10, "embedding");
data[0].$score;   // cosine similarity, best first
  • The search is exact cosine similarity over the objects you may read, and the set's filter applies. Ties keep $rid order.
  • numNeighbors is 1 to 500, and the query must have exactly vectorDimension finite numbers, not all zeros. Otherwise the answer is a 422.
  • Pass the vector itself. Text queries are refused (400): an embedding of the text would come from a different model than the stored vectors.
  • skipped counts the objects that were readable but had no comparable vector (none stored, all zeros, or another length).
  • A masked vector property, or a masked property in the filter, answers 403.
  • One call scores at most 5,000,000 numbers (candidates times vectorDimension); beyond that the answer is a 400, so narrow the set with a filter.

Apply an action

await client.actions["updateTier"].applyAction(
  { customer: "c1", tier: "platinum" },
  { $validateOnly: true },   // check the parameters only, change nothing
);

Leave out $validateOnly to apply the action. Add $returnEdits: true to get the edits back. Unlike some OSDK clients, Converge lets you combine the two: { $validateOnly: true, $returnEdits: true } returns the edits the action would make, and commits nothing. A refused action throws OsdkError. Its status field holds the HTTP status and its body field holds the server's reason.

An action that needs approval does not run yet. The server answers 202 with { status: "pending_approval", approval_id, action, requested_by }, without validation or edits. ActionResult types both answers, so check status first:

const res = await client.actions["refund"].applyAction({ order: "o1" });
if (res.status === "pending_approval") console.log("waiting for approval", res.approval_id);

Batches

await client.actions["updateTier"].batchApplyAction(
  [{ customer: "c1", tier: "gold" }, { customer: "c2", tier: "gold" }],
)

A batch takes 1 to 100 parameter sets. For a plain action it is all-or-nothing: the server validates every request first, and if one fails, nothing is applied. Authored actions and actions that need approval run one at a time and stop at the first failure.

Run a query

const value = await client.queries["customer_lifetime_value"]({ customer_rid: "customer/c1" });

A query (function) runs as you and is read-only. The client only lists the queries you may run, with typed parameters, and resolves to the query's value. client.executeFunction("customer_lifetime_value", { customer_rid: "customer/c1" }) does the same, under the name other OSDK clients use.

Attachments

const att = await client.attachments.upload(file, "invoice.pdf");    // a Blob or File
await client.actions["attachInvoice"].applyAction({ order: "o1", invoice: att.rid });
const meta = await client.attachments.get(att.rid);
const bytes = await client.attachments.read(att.rid);                // a Blob

Upload first, then pass the returned rid as the action's attachment parameter. You can read an attachment if you uploaded it, or if you can read an object that references it. Anyone else gets 404, the same as for a rid that does not exist.

Media

A media reference property names an item in a media set. Read it, or upload into it and write the reference with an action:

const photo = await client.media.get("Order", "o1", "photo");
const bytes = await photo.fetchContents();     // a Blob
const meta = await photo.fetchMetadata();      // { path, sizeBytes, mediaType }
photo.reference;                               // the MediaReference the property holds

const ref = await client.media.upload("Order", "photo", file, { mediaItemPath: "orders/o9.png" });
await client.actions["set_photo"]({ order_id: "o9", photo: ref });

The server checks that you can read the object (its visibility, markings and masking) and the media set. The content is untrusted, because the uploader chose its type. Showing an image, video or audio in an <img>, <video> or <audio> element is fine; never open other content (HTML, SVG) as a blob: or data: URL in a frame, tab or link, where it would run on your app's origin.

Browser apps

In the browser the client reads the ontology from <meta name="osdk-ontologyRid" content="..."> if the page has one. This lets one build serve several ontologies. You can also subscribe to changes: see Subscriptions.

Migrating from 1.x syntax

The client uses the 2.x names shown above, but code written for 1.x still runs while you migrate:

1.x2.x
fetchPage({ pageSize, pageToken })fetchPage({ $pageSize, $nextPageToken })
orderBy: { property: "revenue", direction: "desc" }$orderBy: { revenue: "desc" }
client.actions["updateTier"](params)client.actions["updateTier"].applyAction(params)
raw where clause { type: "eq", property, value }{ property: value } or { property: { $eq: value } }
page through nextPageToken yourselffor await (const o of set.asyncIter())

You can move one call at a time. Each 1.x form sends the same request as its 2.x replacement.

From Palantir OSDK 2.x

Most 2.x read code runs unchanged: fetchPage, fetchOne, asyncIter, where with $-operators, $select, $orderBy and applyAction. These differ:

Palantir OSDK 2.xConverge
client(Customer)client.objects["Customer"]
createClient(baseUrl, ontologyRid, auth)createClient({ baseUrl, token: auth, $ontologyRid })
fetchOneWithErrors(pk)fetchOne(pk) in try/catch; the error is an OsdkError with status
$includeRid: trueNot needed: $rid always comes back
aggregate({ $select: { "revenue:sum": "unordered" }, $groupBy: { tier: "exact" } })aggregate({ type: "sum", field: "revenue", group_by: ["tier"] }), or metrics: [...] for several
aggregate({ $select: { $count: "unordered" } })aggregate({ type: "count" }), then res.data[0].metrics[0].value

Port aggregate calls by hand. The TypeScript types reject the 2.x shape, but a call that bypasses them (plain JavaScript or a cast) is not refused: the server ignores $select and $groupBy and answers a plain count.

Converge