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.27.0; a call available only on a newer client is marked (SDK 0.26+),
and everything else also runs on a 0.23 client. Nothing on this page is 0.26-only.
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 / orgs / clients), client.auth.*
(contexts, roles, access profiles, scoped keys, token minting), and client.records.* /
client.search.* to exercise a scoped credential.
Create an org and a user
Goal: model one customer organization and one person inside it.
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.
// 1. Create the org. `externalId` is YOUR id for it; create is idempotent on it.
const org = await client.identity.createOrg({
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 everywhere below.
// 2. Create a HUMAN user. Users carry `email`, not `name`.
const user = await client.identity.createUser({
externalId: 'user-jdoe',
email: 'jdoe@example.com',
payload: { profile: { role: 'clinician' } },
});
// 3. Create a client (an external customer you serve), owned by the org.
const customer = await client.identity.createClient({
externalId: 'cust-1042',
name: 'Jane Doe',
orgId: org.id!,
});
Expected result: each call returns the created identity with status: 'ACTIVE' and its
Vectros id. Re-running any create with the same externalId returns the existing
identity unchanged (the other fields on the second call are ignored) — so these calls are
safe to repeat.
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" --org <orgId>
# Make a SERVICE user (machine principal) instead of a HUMAN one:
vectros identity create --type user --external-id agent-bot --service
Look identities up by your own id with vectros identity list --type org --external-id clinic-001.
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({
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.
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. 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.
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.
# 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 an orgId (or clientId) 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.
// Mint a token confined to one org.
const minted = await client.auth.mintToken({
scope: {
allowedActions: ['records:r', 'search:r'],
dataScope: { orgId: ['<org id>'] },
},
});
const scoped = new VectrosClient({ token: minted.token, environment: 'https://api.vectros.ai' });
// The call MUST carry orgId — strict scope requires the field explicitly.
const list = await scoped.records.listRecords({ type: 'intake_form', orgId: '<org id>' });
// 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,
orgId: '<org id>',
});
Expected result: the credential sees only rows owned by that org. Omitting orgId on the
call is rejected with a message naming the required field. 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: { orgId: ['<org id>', null] }. The null is never implied.
The same pattern works for clientId and userId — substitute the field on both the mint
and the call.
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.
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, scopes, profiles, and the three credential types.
- The blueprint walkthroughs (getting-started, clinical-intake, coding-agent-memory, 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.