Identity & access — how-to

Goal-oriented, runnable guides for modeling who acts in your tenant and scoping what they can touch. Every snippet uses synthetic data only and is grounded in calls that run against the live platform. For the concepts behind these steps, read explanation.md; for every option and limit, see reference.md.

Snippets use the Node SDK unless the CLI is the more natural surface. The API spec is currently at 0.38.0; every first-party client is on a matching pin. A call available only on a newer client is marked (SDK 0.26+) — relevant only if your own integration pins an older build. The generic identity-entity surface (/v1/entities/{namespace}, /v1/namespaces) shipped in 0.35.0 — it replaced the dedicated org/client endpoints in a clean break, so it needs at least a 0.35 client.

Construct a client

Every guide assumes a constructed client. You build it once with a token and an environment (the API base URL), then call sub-clients grouped by area.

import { VectrosClient } from '@vectros-ai/sdk';

const client = new VectrosClient({
  token: process.env.VECTROS_API_KEY!,      // sk_live_* for production, sk_test_* for dev
  environment: 'https://api.vectros.ai',    // staging: https://api.staging.vectros.ai
});

Sub-clients you'll use here: client.identity.* (users, identity entities, namespaces), client.auth.* (contexts, roles, access profiles, scoped keys, token minting), and client.records.* / client.search.* to exercise a scoped credential.


Create identity entities and a user

Goal: model one customer organization and one person inside it, using the built-in org namespace.

Prerequisites: a root sk_* key (or any credential permitted to manage identities).

Identities are tenant-wide, idempotent by the externalId you supply, and orthogonal to app contexts — you create them once and reference them everywhere. org and client are two namespaces that are always registered for you; you create entities in them exactly the same way you'd create entities in a namespace you register yourself (next section).

// 1. Create the org entity. `externalId` is YOUR id for it; create is idempotent on it
//    within the `org` namespace.
const org = await client.identity.createEntity({
  namespace: 'org',
  body: {
    externalId: 'clinic-001',
    name: 'Northside Family Clinic',
    payload: { region: 'northeast' },   // free-form attributes, round-tripped as-is
  },
});
// org.id is the Vectros-assigned UUID — use it in a `scopes` entry ("org:<org.id>") below.

// 2. Create a HUMAN user. Users carry `email`, not `name`, and are not namespaced.
const user = await client.identity.createUser({
  body: {
    externalId: 'user-jdoe',
    email: 'jdoe@example.com',
    payload: { profile: { role: 'clinician' } },
  },
});

// 3. Create a client entity (an external customer you serve), owned by the org.
const customer = await client.identity.createEntity({
  namespace: 'client',
  body: {
    externalId: 'cust-1042',
    name: 'Jane Doe',
    scopes: [`org:${org.id}`],          // parent edge — must be a DIFFERENT namespace than "client"
  },
});

Expected result: each call returns the created identity with status: 'ACTIVE' and its Vectros id. An entity's scopes on read is its effective scopes — its own reference followed by its parents, e.g. ["client:<customer.id>", "org:<org.id>"]. Re-running any create with the same externalId (within the same namespace, for entities) returns the existing identity unchanged (the other fields on the second call are ignored) — so these calls are safe to repeat. Pass ?upsert=true on an entity create if you want a repeat call to overwrite instead.

To create a machine identity instead of a person, pass type: 'SERVICE' to createUser. A service user is the principal a long-running agent or scoped key acts as.

The same flow on the CLI:

vectros identity create --type org    --external-id clinic-001 --name "Northside Family Clinic"
vectros identity create --type user   --external-id user-jdoe  --email jdoe@example.com
vectros identity create --type client --external-id cust-1042  --name "Jane Doe" --scope org:<orgId>

# Make a SERVICE user (machine principal) instead of a HUMAN one:
vectros identity create --type user --external-id agent-bot --service

--type takes user or any namespace — org and client are built in, and any namespace you've registered (next section) works identically. Look identities up by your own id with vectros identity list --type org --external-id clinic-001.


Register a custom namespace and create entities in it

Goal: model an ownership axis of your own — a team — the same way org/client work, so it can be created, listed, looked up, existence-checked, and used to scope credentials.

Prerequisites: a root sk_* key. Registering (or changing) a namespace is a root-key-only operation; reading the registry is open to any credential.

// Register the namespace. entityBacked: true is what turns "team:<anything>" from a
// free-form label into an anchored, existence-checked entity reference. specificityRank
// is required — a unique integer position in your account's specificity order, used to
// break a tie when a caller holds two scope dimensions at once during schema resolution.
// Leave headroom between values (10, 20, 30, …) so a later namespace can slot in between.
await client.identity.registerNamespace({
  body: { namespace: 'team', entityBacked: true, specificityRank: 10 },
});

// Create entities in it exactly like org/client.
const team = await client.identity.createEntity({
  namespace: 'team',
  body: {
    externalId: 'team-eng-platform',
    name: 'Platform Engineering',
    scopes: [`org:${org.id}`],   // a team can itself have a parent org
  },
});

Expected result: GET /v1/namespaces now lists team alongside the built-in org and client. From this point, a scope:team value anywhere on the platform (record scopes, a token's dataScope, a profile's identityOverrides) must resolve to a real team entity or the write is rejected — the same guarantee org/client have always had. A namespace cannot be deleted, or flipped back to entityBacked: false, while entities still exist in it.

Registering a namespace is a deliberate, root-key action — it is not something the CLI bootstrap loader or a blueprint's scope gate can do on your behalf (see explanation.md). A blueprint's identities: block can declare entities to create in a namespace you've already registered; it cannot register the namespace itself.


Create an app context and operate inside it

Goal: give one app (or one customer) its own isolated data partition.

Prerequisites: a root sk_* key. Creating or deleting an app context is a root-only operation — a scoped key or token (ssk_* / st_*) cannot create or tear down a context, even one carrying the wildcard * scope.

A context is the hard isolation boundary — all records, documents, folders, and schemas live inside one. The id must match ^[a-z][a-z0-9-]{2,30}$.

const ctx = await client.auth.createAppContext({
  body: {
    contextId: 'clinic-intake',
    name: 'Clinic Intake App',
    description: 'Intake records for the Northside pilot',
  },
});
vectros context create clinic-intake --name "Clinic Intake App"
vectros context list
vectros context get clinic-intake

Once the context exists, data written under a credential scoped to it is partitioned there and is unreachable from any other context — the isolation is enforced by the platform, not by your filters.

Tearing a context down is deliberate and irreversible. The delete is a confirm-gated asynchronous cascade: you must pass a confirm token equal to the contextId, and the context then drains all of its children in the background.

// Without `confirm` this rejects with 400 and touches nothing.
await client.auth.deleteAppContext({ contextId: 'clinic-intake', confirm: 'clinic-intake' });
// The context flips to `purging` immediately, reaching `deleted` once the drain completes.
# The CLI equivalent asks you to re-type the context id before doing anything;
# --force skips the prompt (required in scripts, where there is no terminal).
vectros context destroy clinic-intake

The dashboard's Contexts page offers the same teardown behind a typed confirmation dialog. All three paths run the identical confirm-gated cascade on the server.


Define a role and grant a principal access to a context

Goal: create a reusable, identity-agnostic permission shape and bind a principal to it inside a context.

Prerequisites: an existing context; a principal id (usr_<userId> or key_<keyId>).

A role is defined once and reused; an access profile binds a principal to either a role or inline scopes. Always author explicit resource:op action forms.

# A reusable read-only role in the context.
vectros role create --context clinic-intake \
  --role-id intake-reader --name "Intake Reader" \
  --actions records:r,search:r

# Bind a user principal to that role (the binding is a separate step from issuing a key).
vectros access grant --principal usr_<userId> --context clinic-intake --role intake-reader

# Or bind inline single-clause scopes without a named role:
vectros access grant --principal usr_<userId> --context clinic-intake --actions records:r,search:r

The same through the SDK, using inline scopes (the wire form is snake_case allowed_actions):

await client.auth.createAccessProfile({
  contextId: 'clinic-intake',
  body: {
    principalId: 'usr_<userId>',
    scopes: [{ allowed_actions: ['records:r', 'search:r'] }],
    status: 'active',
  },
});

Expected result: the profile is created (or, on a repeat, the existing one is returned unchanged). A profile carries exactly one of scopes or roleId — set one and the other is cleared. Binding to a roleId that doesn't exist in the context is rejected with 400 naming it, rather than being stored as a profile that could never authenticate. To see every context a principal can reach: vectros access list --principal usr_<userId>.

A profile-create accepts one scope clause per request today. For multi-clause shapes, define a multi-clause role (in a blueprint) and reference it by roleId.


Invite a sub-user by email

Goal: bring a person into a context without creating their user record and access profile yourself — let them complete onboarding.

Prerequisites: an existing context; either an existing roleId in it, or the inline scopes you'd give a standalone access profile.

const invite = await client.auth.createInvite({
  email: 'newhire@example.com',
  contextId: 'clinic-intake',
  accessProfile: { roleId: 'intake-reader' },   // or: { scopes: [{ allowed_actions: [...] }] }
  acceptUrl: 'https://my-app.com/accept',       // required unless sendEmail is false
});
// → { userId, inviteExpiresAt, emailSent: true }

To deliver the invitation yourself instead of through Vectros's email, set sendEmail: false — the response then carries the raw token and a ready-made accept link:

const invite = await client.auth.createInvite({
  email: 'newhire@example.com',
  contextId: 'clinic-intake',
  accessProfile: { roleId: 'intake-reader' },
  sendEmail: false,
});
// → { userId, inviteExpiresAt, emailSent: false, inviteToken, acceptLink }

Expected result: a PENDING user exists with its access profile already bound — unusable until accepted. Re-inviting the same email+contextId rotates the token and resends rather than duplicating — unless your credential lacks users:r+users:u, in which case the collision itself returns a 409 instead of silently resending. A 400 names the reason if roleId doesn't exist in the context, acceptUrl isn't an https URL, or ttlSeconds (default 7 days, 1 hour–30 days) is out of range. A 403 means the invite's resolved permissions exceed your own credential's scope. A 409 otherwise means the email already belongs to an active/suspended member of that context, or to an identity elsewhere in your account.

The invitee accepts it themselves — typically from your acceptUrl landing page, after they authenticate with your own login system:

await client.identity.updateUser({
  id: invite.userId,
  body: {
    status: 'ACTIVE',
    inviteToken: '<token from the accept link>',
    externalSubject: '<their id in your auth system>',
    emailVerifiedAttestation: true,   // your attestation that YOU verified their email
  },
});
// The user flips PENDING → ACTIVE. `email` is editable again from this point.

While the invitation is outstanding: email cannot be changed by editing the user directly — delete the pending user and re-invite to redirect it. Resending needs more than creating did, and takes the same request shape as creating — accessProfile and (unless sendEmail: false) acceptUrl are still required by the type, even though a resend cannot change the invitee's already-bound permissions:

await client.auth.resendInvite({
  email: 'newhire@example.com',
  contextId: 'clinic-intake',
  accessProfile: { roleId: 'intake-reader' },   // required by the type; ignored — permissions don't change
  acceptUrl: 'https://my-app.com/accept',       // required unless sendEmail is false
});
// Rotates the token, extends its expiry, and invalidates the previous accept link.
// Requires users:r and users:u in addition to the users:c that creating an invite needs.

Mint a least-privilege scoped key (ssk_*)

Goal: issue a permanent, identity-bearing credential that can never exceed a profile — the right shape for an agent or a bot.

Prerequisites: a principal that already has an access profile in the target context (the previous guide). The key inherits that profile and cannot exceed it. Minting also requires the caller to hold the identity the bound profile carries — issuing a key on behalf of a principal whose identity you don't hold is rejected with 403 (a root key is exempt).

# Issue an ssk_* for the bound principal. The raw secret is shown ONCE.
vectros key issue --principal usr_<userId> --context clinic-intake --name agent-key --format env
# → VECTROS_API_KEY=ssk_live_...

--format env prints VECTROS_API_KEY=ssk_live_… so you can drop it straight into an agent's environment. Other formats: human (a labeled block with the secret), raw (just the secret), json.

Expected result: the command prints the new key id, its binding, and the raw ssk_* once. There is no way to re-read a key's secret. If you lose it — or want to rotate — revoke and re-issue:

vectros key rotate --principal usr_<userId> --context clinic-intake --name agent-key --format env
vectros key list --context clinic-intake
vectros key revoke <keyId>      # stops working within ~5 minutes (authorizer cache)

The key authenticates as its bound principal: every call it makes is attributed to that identity, and it is confined to that principal's profile.


Mint a short-lived token (st_*) and the front-end-safe pattern

Goal: hand a browser a narrowed, short-lived credential without ever exposing a root key.

Prerequisites: a backend holding an sk_* (or ssk_*).

Mint an st_* scoped to exactly one user's data. The token carries its scope internally and cannot widen it.

// On your backend, in your login handler — NEVER in browser code.
const minted = await client.auth.mintToken({
  scope: {
    allowedActions: ['records:r', 'search:r'],
    dataScope: { userId: ['<that user\'s id>'] },   // narrow to one user's data
  },
  // expiresInSeconds defaults to 3600 (1h); cap is 86400 (24h). Mint short.
});
// → { token: "st_...", expiresAt: <unix-seconds> }

Hand minted.token to the browser. The browser constructs its own client with that token and calls Vectros directly:

// In the browser, with the st_* received from your backend:
const browserClient = new VectrosClient({
  token: minted.token,
  environment: 'https://api.vectros.ai',
});
const myRecords = await browserClient.records.listRecords({ type: 'intake_form' });

Expected result: the root key never leaves your backend; the browser's token is confined to one user for at most its lifetime; if the browser is compromised, blast radius is one user for one token-lifetime. The token cannot be revoked in flight — keep the lifetime short.

The mint endpoint accepts one scope clause per request. For a compound shape, bind the principal to a multi-clause role and mint via the scoped-key path instead.


Restrict a credential to one customer's data with dataScope

Goal: confine reads and searches to a single org's (or client's) records, including the strict-scope rules.

Prerequisites: records tagged with a scopes entry (e.g. org:<id>) you can scope to.

dataScope is enforced as a server-side filter below any filter the caller supplies. It is strict: a scoped call must include the matching filter explicitly. userId is its own fixed dimension; every other dimension is a namespace, keyed scope:<namespace> in dataScope.

// Mint a token confined to one org. `orgId` is a Vectros org entity id — see
// "Create identity entities and a user" above for how to create one.
const orgId = '<org id>';
const minted = await client.auth.mintToken({
  scope: {
    allowedActions: ['records:r', 'search:r'],
    dataScope: { 'scope:org': [orgId] },
  },
});
const scoped = new VectrosClient({ token: minted.token, environment: 'https://api.vectros.ai' });

// The call MUST carry the matching scope filter — strict scope requires it explicitly.
const list = await scoped.records.listRecords({ type: 'intake_form', scope: `org:${orgId}` });
// Only org-tagged records come back; tenant-only (unowned) records are filtered out.

const hits = await scoped.search.content({
  query: 'follow-up appointment',
  mode: 'TEXT',
  limit: 100,
  scope: `org:${orgId}`,
});

Expected result: the credential sees only rows owned by that org. Omitting the scope filter on the call is rejected with a message naming the required dimension. To also reach tenant-level (owner-less) records under the same credential, opt in explicitly with a null in the value list at mint time: dataScope: { 'scope:org': ['<org id>', null] }. The null is never implied.

The same pattern works for any registered namespace (scope:team, scope:client, …) — substitute the dimension on both the mint and the call. ?scope= takes one entry per query; pair it with ?userId= when you need both dimensions on the same call.

A dimension your dataScope never names isn't just unnarrowed on reads — it's also a dimension you have no way to write into. If a role instead of a single mint needs to cover a dimension for every principal it's bound to without enumerating values, its clause can use the ${{ any }}, ${{ under.self.* }}, or "*" default-dimension placeholder forms — see reference.md.


Verify what a credential actually is

Goal: confirm the principal, tenant, and scope a credential resolves to.

A lightweight identity-binding check returns the authenticated principal's shape:

const who = await client.auth.ping();
// For an sk_* root key: principalType 'root_key' (no action list — it's wildcard).
// For an ssk_* scoped key: principalType 'scoped_key' + its allowedActions.
// For an st_* token: principalType 'token' + tokenExpiresAt.
// dataScope.scopes reports the resolved scope:<namespace> bindings, if any.

This is the fastest way to confirm a freshly minted scoped key carries the actions you expect before you ship it. An invalid credential is denied at the edge with a 403.


Where to go next

  • reference.md — every identity/access method, the full scope grammar, the data-plane allowlist, error codes, and an honest "notes & limits."
  • explanation.md — the why behind contexts, namespaces, scopes, profiles, and the three credential types.
  • The blueprint walkthroughs (getting-started, clinical-intake, coding-agent-memory, agentic-sdlc, second-brain) — end-to-end builds that provision a context, principal, profile, and scoped key in one command.
  • The generated API reference (rendered from the OpenAPI specification) — canonical request and response shapes.