Blueprints
Explanation — what & why
A blueprint is an app model as configuration. One file declares everything a
small Vectros app needs to exist — the record schemas, a least-privilege access
profile, a service principal, optional seed data, and optional roles — with
stable identifiers so applying it twice converges instead of duplicating (the
loader always reconciles server-side via ?upsert=true, so re-applying the
same blueprint is always safe). A schema whose typeName is already claimed
by a different owner — e.g. a second blueprint modeling the same conceptual
type — must declare basedOn (see the schema reference below).
Blueprints exist to collapse "stand up a new app's data layer" from a sequence of
API calls into a single reviewable artifact. You write the file once; the CLI's
bootstrap and blueprint-test commands turn it into provisioned
infrastructure and a working scoped key (see cli.md).
Two boundaries define what a blueprint is and is not:
- It describes format, not trust. The
@vectros-ai/blueprintspackage owns the format and its structural validation only. The security boundary — the scope gate that confines a blueprint to data-plane access — lives in the CLI binary. A blueprint is untrusted input; the binary is the trust boundary. - It is data-plane only. A blueprint can request scopes on records, schemas, search, documents, folders, and inference — nothing else. Control-plane scopes are hard-rejected when the CLI applies it.
This doc is the format reference the blueprint walkthroughs build on. For narrated, end-to-end builds, see the walkthroughs (getting-started, clinical-intake, agentic-sdlc, second-brain).
How-to
Scaffold, validate, and apply
The full authoring loop runs through the CLI:
vectros blueprint init my-app # scaffold ./my-app.blueprint.yaml
vectros blueprint validate ./my-app.blueprint.yaml
vectros blueprint plan ./my-app.blueprint.yaml
vectros blueprint apply ./my-app.blueprint.yaml --tenant test # provision it, no key (needs CLI 0.23.0+; CI: add --yes --confirm-existing-principal)
vectros bootstrap --blueprint ./my-app.blueprint.yaml # provision it AND mint a key for your MCP client
You can scaffold from a bundled exemplar with init --from <name>. See
cli.md for the lifecycle commands.
A minimal blueprint
Blueprints are authored as YAML or JSON. A minimal one declares a context, one schema, an access profile, and a service principal:
name: my-app
version: 1.0.0
description: A minimal example app.
contextId: my-app
contextName: My App
schemas:
- typeName: note
displayName: Note
indexMode: HYBRID # HYBRID | SEMANTIC | TEXT
fields:
- { fieldId: title, fieldType: string, required: true, searchable: true }
- { fieldId: body, fieldType: string, searchable: true }
lookupFields: [title] # extra exact-match index(es). Do NOT list externalId:
# it's the record's first-class id (sent top-level on
# each record), with its own finder — declaring it as a
# field or lookup is rejected.
accessProfile:
allowedActions: [records:r, records:c, records:u, search:r, schemas:r]
servicePrincipal:
externalId: my-app
displayName: My App
indexMode, the per-field validation/renderHints, and the schema-level
capabilities/active/ownership fields are all optional — the example stays
minimal on purpose. Note the deliberate absence of records:d in
allowedActions: least privilege is the authoring default.
Reference — what a blueprint can express
Top-level fields
| Field | Required | Meaning |
|---|---|---|
name | yes | Stable blueprint id — the --blueprint <name> selector and idempotency key. |
version | yes | Blueprint version string. |
description | yes | Human-readable description. |
contextId | yes | The app context the profile and key bind to. 3–31 chars; lowercase letter first, then lowercase letters/digits/dashes. |
contextName | no | Human-readable context name; defaults from name. |
schemas | no | The record/surface schemas to provision (see below). |
accessProfile | yes | The least-privilege scope the bootstrap mints for the blueprint's own key. |
servicePrincipal | yes | The service principal the key is bound to. |
seed | no | Deterministic seed records. |
roles | no | Reusable, multi-clause scope rules. |
identities | no | Declared principals (see the glue caveat below). |
issuers | no | Trusted third-party IdP issuers to register, for BYO-IdP token exchange (see below). |
namespaces | no | Entity-namespace registrations to declare (see below). |
scripts | no | The versioned JS source a trigger's scriptRef runs (see below). |
triggers | no | Rules that invoke a script on a schema record's CREATE/UPDATE/DELETE event (see below). |
identityProjectionClaims | no | Which captured IdP claim names get projected, read-only, onto access profiles in this context (see Issuers below). |
inputs | no | Install-time variables (resolved with --set/--values). |
Schemas
Each schema entry declares a record type:
| Field | Meaning |
|---|---|
typeName | The record type name. |
displayName | Human-readable label. |
indexMode | HYBRID (keyword + semantic), SEMANTIC, or TEXT. |
fields[] | Field definitions (see below). |
lookupFields[] | Fields to index for direct lookup. Bare field name (equality), or { fieldName, unique?, rangeEnabled?, sortBy?, allowOverflow? } — or, for a composite lookup over 2–3 fields together, { fieldNames: [...], sortBy?, allowOverflow? } (see Composite lookups below). rangeEnabled adds ordered from/to/prefix queries (use it for dates/sequences/scores; the order is lexical, so it's wrong for ordinal enums). sortBy sets the order an equality lookup returns — createdAt (default), lastUpdated, or another field on the schema. You can also range over that sort key (sortFrom/sortTo on a value lookup — e.g. one session's records since a timestamp). The sorted field does not need to be required: records that carry no value for it are ordered ahead of those that do, and are never inside a bounded window. sortBy and the sorted field's type are both migration-locked, so choose them when you declare the lookup; an array or object field cannot be a sortBy target. Each schema has 7 fast equality slots (range lookups use a row, not a slot; ownership ids + externalId ride their own); an 8th equality lookup needs allowOverflow. The index shape is migration-locked — pick it deliberately. A sensitive field may be an equality (blind-index) lookup, never rangeEnabled, and never a sortBy target. Up to 10. |
allowedSurfaces[] | Which typed surfaces may bind the schema: record, document, user, entity (identity entities in any namespace — org, client, or one you register). Defaults to [record]. |
capabilities.auditHistory | Whether writes emit version history (platform default on). |
capabilities.triggersEnabled | Whether records of this schema fire trigger rules. Defaults to false, and is the opt-in a schema must declare before a trigger may fire on it — a blueprint declaring a trigger against a schema without it is refused at plan time. Independent of auditHistory. |
active | Whether the schema accepts new records. |
userId / scopes | Schema-level ownership defaults. scopes is an array of <namespace>:<value> entries (e.g. org:acme), the same grammar records/entities use. |
basedOn | Id of an existing schema this one customizes, required when a schema named typeName already exists in this context under a different owner. Omit when this is the first schema under that name (it becomes that name's shared base — root/unscoped credential only, no userId/scopes). Must point directly at the base (one hop); immutable once set. Mirrors the platform's basedOn schema field (data-model/reference.md). |
Each field definition supports:
| Field key | Meaning |
|---|---|
fieldId | Field name. |
fieldType | Field type (e.g. string, array, reference). |
required / searchable / filterable | Per-field flags. |
enumValues[] | Allowed values for an enumerated field. |
validation | Validation rules: required, minLength/maxLength, min/max, pattern, email/url/phone, step/multipleOf, minItems/maxItems. |
renderHints | UI hints: label, widget (text/textarea/select/date/checkbox), order, section, helpText, displayField (mark the record's headline column — at most one per schema). |
sensitive | Marks the field as sensitive: it is redacted/destroyed at write time, blind-indexed for lookups, excluded from the search index, and masked on read unless a token carries the reveal scope. |
inline | Keep the field on the record row when the payload is stored out of line, so it appears in list and lookup projections without includePayload — and so a trigger rule may project it into input.record. Cannot be combined with sensitive. |
targetTypeName / targetSurface / targetField / cardinality | On a reference field, the typed link target. targetTypeName and targetSurface are both required — the same typeName can exist on multiple surfaces, so the surface disambiguates the lookup. targetSurface is record, document, user, or the name of an entity-backed namespace — org, client, or one you registered via POST /v1/namespaces (see identity-access/reference.md; the namespace must already be registered and entity-backed, or the schema is rejected at authoring time). targetField defaults to the target's externalId (must be a unique lookup); cardinality = one (default) / many. The target's existence is enforced at write by default. |
Composite lookups
A lookup can span 2–3 fields matched together as one index, declared with
fieldNames in place of fieldName:
lookupFields:
- fieldNames: [status, area] # queried together as one exact-match index
- Order is fixed at declare time. A query can match a leading run of the declared fields — the first field alone, the first two, and so on — but never a later field alone; declare a separate lookup for that. Redeclaring the same fields in a different order creates an independent lookup that only sees writes made after it was declared, not the history under the old one.
- Exact-match only. A composite lookup can't be
uniqueorrangeEnabled— declare those as a separate, single-field lookup instead. - Record schemas only.
allowedSurfacesmust be exactly['record']; document, user, and entity schemas can't declare a composite lookup. - Each leg must be a plain, single-valued, non-range field. A leg can't be
an
arrayorobjectfield (one sort-key position holds one value), areferencefield withcardinality: many, or a field that is itself declaredrangeEnabledelsewhere on the schema — any of those would index only one of several values and the conjunction would silently return a subset while looking complete. - A leg cannot also be the composite's own
sortBytarget. Within a fixed tuple every matching record shares the same value for that leg, so the sort payload would be constant and asortFrom/sortTowindow would return either everything or nothing, at double the sort-key cost. Sort by a different field,createdAt, orlastUpdatedinstead. sortByandallowOverflowstill apply otherwise:sortByorders results within a group when a query supplies fewer values than the lookup declares (seelookupFields[]above).
Access profile
accessProfile:
allowedActions: [records:cru, search:r, schemas:r]
dataScope:
"scope:org": [org_acme, null]
allowedActions— the scopes the minted key carries. Author explicitresource:opforms; coarse verbs andresource:*grant nothing at runtime. Subject to the data-plane scope gate.roleIds— alternative toallowedActions/dataScope: compose the profile from one or more roles this same blueprint declares underroles:(below), instead of an inline clause:The effective grant is each named role's own clauses, concatenated in the order listed — never merged, so each clause keeps meaning exactly what its own author wrote. Mutually exclusive withaccessProfile: roleIds: [case-handler, hr-admin] roles: case-handler: - allowedActions: [records:r:case, records:u:case] hr-admin: - allowedActions: [records:r:hr]allowedActions; aroleIds- composed profile carries nodataScope/capabilitiesof its own — author those on the referenced roles instead. Every id must resolve to a role declared in this same blueprint, and no id may repeat.dataScope— optional ownership binding (userIdorscope:<namespace>→ value lists;scope:org/scope:clientfor those reserved namespaces, orscope:<namespace>for one you registered — org/client must be registered too, same as any other). Anullelement is the documented null sentinel: it additively grants access to tenant-level (owner-less) records in addition to the listed owners. Omittingnullrestricts the key to the listed owners only — so it will not see tenant-level/seed records.capabilities— optional list of platform capability names (distinct from a schema'scapabilities.auditHistory), e.g.capabilities: [member-lifecycle]. This package validates the shape only (non-blank, no duplicates, lowercase kebab-case, no'*') — it does not itself know which names the platform actually grants, since that set is a platform property (six names as of this release:member-lifecycle,forensic-read,context-directory-read,delegate-mint,delegate-principal-stamp,trigger-control-plane-grant— see identity-access/reference.md). This field parses and validates; whether it has any effect depends on whether the tool applying your blueprint reads and forwards it. Each role clause below accepts the same field.
Service principal, seed, roles
servicePrincipal:
externalId: my-app
displayName: My App
seed:
- typeName: note
externalId: seed-welcome
fields: { title: Welcome, body: Created by the bootstrap loader. } # externalId is the seed's top-level field above, NOT a payload field
roles:
editor:
- allowedActions: [records:cru, search:r]
dataScope:
userId: ['${{ self.userId }}']
servicePrincipal— the principal the minted key binds to.seed— deterministic records (keyed byexternalIdfor idempotency).vectros bootstrapwrites them unless you pass--no-seed;vectros blueprint applywrites them only when you pass--seed.roles— a map of role id → ordered clauses. Each clause is an(allowedActions, dataScope)pair, evaluated per-clause at runtime. Roles are identity-agnostic and reusable; bind them to principals withvectros access grant --role <id>. Every role clause is also gated to the data plane, so a role cannot be a control-plane back door aroundaccessProfile.${{ self.* }}placeholders are a runtime per-principal sentinel, resolved per-request by the platform. They are only valid inside a role clause'sdataScope; using one anywhere else is an authoring error.
fragments — authoring sugar for a repeated dataScope. A top-level map
of name → dataScope, referenced from a clause with dataScopeRef instead of
repeating an identical dataScope verbatim:
fragments:
ownOrg:
"scope:org": ['${{ self.scope.org }}']
roles:
case-handler:
- allowedActions: [records:cru:case]
dataScopeRef: ownOrg
- allowedActions: [search:r]
dataScopeRef: ownOrg
dataScope and dataScopeRef are mutually exclusive on the same clause. A
dataScopeRef is resolved to its fragment's literal dataScope before
anything downstream (the loader, the wire payload it sends) ever sees it — a
purely local authoring convenience, never itself provisioned.
roleAssumable — which values a role's holder may become. A top-level map
of roleId → grant, naming which values, per scope:<namespace>, a holder of
that role may assume via POST /v1/auth/token/assume
(identity-access/reference.md):
roleAssumable:
hr-admin:
"scope:org": [org_engineering, org_sales]
roles:
hr-admin:
- allowedActions: [records:r]
Every key must resolve to a role this same blueprint declares under roles.
Its value grammar is narrower than a clause's dataScope: every key must be a
namespaced scope:<ns> (the principal — userId — can never be named here),
and no value may be null. vectros blueprint plan previews a role's
assumable grant alongside its clauses, since it's security-relevant — what a
holder of the role may become, not just that the role was declared.
assignableRoles — both accessProfile and each role clause may carry an optional roleId
allow-list restricting which named roles that clause may compose into a delegated access profile
(roleIds composition). Orthogonal to allowedActions/dataScope: it narrows which roles the
clause's authority may hand out, not how much data it reaches. Omitting it means no restriction; an
empty list is rejected rather than read as "compose nothing". At most 20 entries, duplicates
rejected. Entries are deliberately not resolved against this blueprint's own roles — a clause
may legitimately name a role that already exists in the context. Rejected on a roleIds-composed
accessProfile, which has no inline clause to carry it. Once a clause carries the field it can
compose only the roles it names, so name every roleId the restricted clause still needs before
adding it.
Scripts
A blueprint may declare a top-level scripts — a map of script name → { source, declaredInputContract? } — carrying the source a trigger's scriptRef runs, so a blueprint that
declares a trigger can ship the code that trigger executes. Applied in-context, before the
triggers that reference it: a scriptRef is validated when the rule is declared rather than when
it fires, so a rule naming a script with no versions in the target context is refused outright.
scripts:
notify-assignee:
source: |
export default function (input) {
return { notified: input.recordId };
}
declaredInputContract: "{ recordId: string, record: { status: string } }"
- The map key is the script's name — what a
scriptRef.nameresolves against. 1–64 characters of letters, digits,_and-. source— required, non-blank, at most 300,000 UTF-8 bytes. Nothing parses it, here or on the platform, so a syntax error stays invisible until the version first runs.declaredInputContract— optional, at most 50,000 UTF-8 bytes, and descriptive only: it documents the input shape for a human reading the blueprint, and nothing enforces it.
Both caps count UTF-8 bytes, not characters, matching the platform.
There is no path:, deliberately. A shipped script runs under its trigger's declared grant, so
it is live authority, and a blueprint is a bundle someone reviews before applying it — keeping the
source inside the bundle keeps what runs inside what was reviewed.
You cannot declare a version, and a trigger on a script this blueprint ships must use
version: latest — a pin is rejected at authoring time. Versions are assigned by the platform and
a stored version is immutable, so a blueprint declares only the current source for a name; a pin
would name a version the blueprint cannot know it produced, and would keep running superseded code
once the source was edited. A scriptRef naming a script this blueprint does not ship may still
pin — that is the "reference a script someone else pushed" case.
Re-applying an unchanged blueprint pushes nothing, and removing a scripts entry does not
delete the script — the deliberate asymmetry with triggers below. A script row carries no record
of which blueprint pushed it, the stored source is the only copy, and an unreferenced version is
inert. Remove one deliberately with vectros scripts delete.
Triggers
A blueprint may declare a top-level triggers — a map of trigger name → declaration, each
naming a schema event that invokes a versioned script under an explicitly declared grant. A
declared trigger fires: a record write on a schema that opts in via
capabilities.triggersEnabled dispatches the rule's script, which runs asynchronously in a sandbox
under the grant declared here. So a trigger's allowedActions/dataScope/capabilities, or the
roles its roleIds composes, are live authority — author them as narrowly as any other
credential's, and review them the same way. Applied per-context, like roles/accessProfile.
schemas:
- typeName: intake
displayName: Intake
capabilities: { triggersEnabled: true }
fields:
- { fieldId: status, fieldType: string, inline: true }
triggers:
on-intake-update:
firingSource: { schemaName: intake, event: UPDATE }
scriptRef: { name: notify-assignee, version: latest }
fields: [status]
allowedActions: [records:r:intake, records:u:intake]
dataScope: { "scope:org": ["${{ input.scope.org }}"] }
manifest: [records.get, records.update]
fields— required. The record fields projected into the script'sinput.record, and on anUPDATEfiring intoinput.previous.fields: []is the explicit "project nothing" declaration (the script still receivesinput.recordId); it is required rather than defaulted so that projecting nothing is something you state. An entry may name only a field the schema keeps inline —inline: true,filterable: true, or a lookup field (a leg or itssortBytarget) — and never one markedsensitive.recordandprevioustogether are capped at 224 KB serialised; a firing that exceeds it is not run.- ⚠️ A rule that declares any
fieldsmust be able to read what it fires on. Its grant must hold arecords:rclause covering the firing schema's type — a grant with no read at all is rejected — and that clause may not carry a literaldata_scopeconstraint (a${{ input.userId }}or${{ input.scope.<namespace> }}placeholder is fine, since it resolves to the firing record's own value). firingSource.schemaName+firingSource.event(CREATE/UPDATE/DELETE) —schemaNamemust name atypeNamethis same blueprint declares.scriptRef.name+scriptRef.version— an exact integer, or the literal"latest". For a script this blueprint ships,latestis the only accepted value (see Scripts above).- A grant —
roleIds(composed from this blueprint'sroles) or an inlineallowedActions/dataScopepair, mutually exclusive, the same XOR shapeaccessProfileuses. manifest(optional) — host-object verbs (e.g.records.get) further narrowing what the script may call on top of what the grant permits. Never widens it.dataScopevalues may use${{ input.<dim> }}, resolving to a dimension stamped on the record whose change fired the trigger. Valid only inside a trigger'sdataScope;${{ self.* }},${{ under.self.* }}and${{ member.* }}are rejected there, because they resolve against the trigger principal's identity rather than the author's.
Removing a trigger de-provisions it. Triggers are the one block an apply reconciles by absence as well as presence: delete an entry and re-apply, and its rule and the access profile behind it are deleted, so a trigger you removed cannot keep acting under a grant that outlived it. Its service principal is kept, so re-adding the same trigger reattaches to one identity. Triggers created outside this blueprint are never touched. Nothing else a blueprint declares is deleted by removing it.
Issuers
issuers:
- issuerId: my-idp
issuer: https://idp.example.com
jwksUri: https://idp.example.com/.well-known/jwks.json
audience: my-vectros-app
contextId: my-context
A blueprint may declare top-level issuers — trusted third-party IdP issuers to
register for BYO-IdP token exchange, each { issuerId, issuer, jwksUri, audience, contextId, subClaim?, emailClaim?, userinfoUri?, restrictedToDomain?, selfSignupPolicies?, capturedClaims? }. userinfoUri points at the IdP's
OIDC userinfo endpoint: when a presented token misses emailClaim (access tokens
generally don't carry email under OIDC unless the IdP was specifically configured to
add it), Vectros falls back to calling userinfoUri with that token as the bearer
credential and reads emailClaim from its response — omit it to leave the fallback
disabled. Unlike schemas/accessProfile/roles (applied
under a per-context token), issuers are applied in the loader's bootstrap-token
phase, alongside app-context/service-principal creation — tenant-wide provisioning
config that needs the bootstrap credential's owner-only authority. (issuer, audience)
must be globally unique across every tenant (an entry without restrictedToDomain claims its pair when it is
verified, not when it is registered), unless the entry sets restrictedToDomain — a
domain already verified for your account, written as a lowercase DNS name such as acme.com
— which scopes the uniqueness to that domain: a token then matches only when its hd claim
equals it (Google Workspace emits hd natively; other identity providers need a claims-mapping
rule that emits a claim named exactly hd), so scoping an issuer whose users sign in without
one locks them out.
Each entry's contextId must equal the blueprint's own contextId — an issuer is a
trust anchor, so a blueprint may only attach one to the context it actually provisions.
@vectros-ai/cli 0.16.0+ handles a blueprint whose issuers target a context other
than the bootstrap credential's own transparently: the credential re-mints itself,
pinned to the blueprint's own context, specifically to register those issuers — no extra
authoring step is needed on your part. The loader always registers (idempotent by
issuerId) and then reconciles: editing an already-registered issuer's
subClaim, emailClaim, userinfoUri, restrictedToDomain, selfSignupPolicies or capturedClaims and
re-running bootstrap now converges on the declared values. Leaving restrictedToDomain out of a re-apply
keeps an existing scope; it does not clear it. ⚠️ The trust-anchor fields
(issuer, jwksUri, audience, contextId) stay immutable, and editing one on an
already-registered issuer now fails the whole apply with the platform's 400 rather
than being silently ignored as it once was. Issuer status is not blueprint-authorable
at all — suspend or reactivate an issuer through the API. ⚠️ From API/SDK 0.45.0, an issuer a
blueprint registers without restrictedToDomain starts pending_verification and accepts no token until its registrant proves
control of the identity provider with the API's verifyIssuer call
(identity-access/how-to.md);
an issuer that already existed is unaffected. An entry that sets restrictedToDomain is active at once:
the domain you verified for your account is what it proves, and it only ever matches that domain's
users. Verifying needs a real login from the IdP after a human
configures a claim rule there, so vectros bootstrap (or vectros blueprint apply) cannot finish it: it still completes and exits 0,
says pending verification beside the issuer, and ends its output, on stderr, with the challenge and the exact
vectros issuers verify command (@vectros-ai/cli 0.23.0 and later). --require-verified-issuers
turns a pending issuer into exit 4 (a one-line notice follows the challenge) for a pipeline that must not go green over it, and an issuer whose
challenge expired while pending is reported as unverifiable, with the delete command; nothing is deleted for
you. Adding
restrictedToDomain to an issuer that is still awaiting verification is refused by the platform (400), so
the apply fails: verify the issuer first, or delete it and apply again. (Registering, reading, updating, or
deleting an issuer directly against the API, outside a blueprint, stays confined to the
calling credential's own context — see
identity-access/reference.md
for the full field grammar, every error code, and the token-exchange contract those issuers
serve; identity-access/explanation.md
for the concept and the flow end to end.)
capturedClaims names additional OIDC claim names — beyond emailClaim, which keeps its own
field — to read from that issuer's tokens on every successful exchange and store as your tenant's
golden identity-provider-asserted copy for the signed-in user. Not a fixed set: name whatever your
provider actually asserts. Each is read from the verified token first, falling back to userinfoUri
(when configured) only for names still missing. Omit to capture nothing beyond email — unchanged
behaviour for every existing blueprint. At most 20 entries of at most 64 characters each; a
duplicate is rejected rather than deduplicated.
The top-level identityProjectionClaims is the other half: it declares which of those captured
names are projected, read-only, onto access profiles in this blueprint's app context, where they
surface as identityProjection on the profile. Capture and projection are separate opt-ins —
capturing stores a claim against the user, projecting exposes it on a profile — and declaring a
name no issuer captures simply never fills. A profile's projection fills once, the first time a
sign-in for that principal can supply a value (for an invited member, not until they accept and sign
in), and never updates again; editing the list therefore affects only profiles not yet filled.
⚠️ It takes effect when the app context is CREATED. Re-applying a blueprint against a context
that already exists does not change the declaration in either direction — a declared list is not
applied, and an empty list does not disable. Change an existing context's declaration through the
platform API. Like issuers, it applies in the bootstrap-token phase, because setting it needs
the platform provisioning capability only that phase's credential carries.
Namespaces
namespaces:
- namespace: team
entityBacked: true
specificityRank: 10
A blueprint may also declare top-level namespaces — entity-namespace
registrations, each { namespace, specificityRank, entityBacked?, membershipRecordType?, membershipTargetField?, membershipLevelField?, membershipLevels? }. Like issuers,
these are applied in the loader's bootstrap-token phase. Every declared namespace
is always owned by the blueprint's own contextId — a blueprint has no way to
register a tenant-wide namespace, since it applies with a context-bound credential (the
platform's namespace registration confines a context-bound caller to its own context
unconditionally, even for a request that would otherwise be tenant-wide).
namespace— 2-32 chars, a lowercase letter first, then lowercase letters/digits/_/-. A closed set of words is rejected as reserved (user,record,document,entity,self,tenant,context,scope,versions,lookup) —org/clientare NOT in that set: they're reserved namespace names, not built-ins, registered the same way as any other (see identity-access/reference.md for the platform endpoint this mirrors). They already exist tenant-wide in every account atspecificityRank1000/2000 (below); a context-owned registration needs a different rank, and shadows the tenant-wide one for this context's own callers.specificityRank— an integer0..1_000_000, this namespace's position in the account's specificity order. Must be unique among this blueprint's own namespaces — a collision with the rest of the account's registrations (includingorg=1000 andclient=2000) surfaces at apply time.entityBacked(optional) — whentrue, every value in this namespace must resolve to an existing identity entity; whenfalse/omitted, values are free-form strings validated by grammar only.membershipRecordType+membershipTargetField— optional, declared together (or both omitted): which record type + field hold grants of this namespace's values.membershipRecordTypemust name atypeNamethis same blueprint declares underschemas:. Declaring this grants nobody anything on its own; a role opts in explicitly with${{ member.scope.<namespace> }}in itsdataScope.membershipLevelField+membershipLevels— optional, declared together: the field naming a grant's level, and the complete set of level labels allowed.
Registration is not idempotent server-side: a re-apply whose declaration matches what's already registered converges silently, but one that disagrees with the live registration fails the apply rather than silently overwriting it.
Inputs (install-time variables)
A blueprint may declare an inputs: block and reference values with
${{ inputs.<name> }} (plus the built-ins ${{ vectros.context }} /
${{ vectros.suffix }}). Supply values at validate/plan/apply time with --set
or --values. Inputs apply to file blueprints only.
Bundled blueprints
Four blueprints ship with the library:
| Name | What it provisions |
|---|---|
task-management | Structured task tracking, shareable across sessions, agents, and users. The authoring exemplar. |
agentic-sdlc | A whole-SDLC system of record for an AI development team — decisions, designs, references, runbooks, and post-mortems as documents, plus controls, conventions, gotchas, and a glossary as records, cross-linked and recalled by meaning. |
second-brain | A personal knowledge base — capture notes, ideas, and links, then ask them anything. |
clinical-intake | A clinical intake data model (synthetic/illustrative). |
List them with vectros blueprint list; apply one with
vectros bootstrap --blueprint <name> (which also mints a key for your MCP client) or
vectros blueprint apply <name> --tenant test|live (which mints none).
Notes & limits — honest glue caveats
- A bridge token is a human prerequisite. Applying a blueprint with
bootstraprequires a bridge token from the developer portal — there is no fully unattended path that mints one for you. identities:blocks are resolved at apply time. Each entry declares akind(user, or an entity namespace —org,client, or one you registered) plus anexternalId; the loader resolves it through the ordinary identity API —createUserforkind: user,createEntity({ namespace: kind, ... })for anything else — using the bridge token's own authority, and the resolved id becomes available to the rest of the blueprint as${{ identities.<name> }}. The CLI fails closed on a blueprint that declares an entity namespace that hasn't been registered, rather than applying it partially.referencefields enforce the target at write. A blueprint can declare a typed reference between record types; by default the platform requires the referenced record to exist when the referencing record is written — so if a seed record references another, seed the target first. (To opt out for an order-independent bulk load, a schema would setcapabilities.validateReferences: false— not exposed in the bundled blueprints.) There is no reverse-reference query on this surface; to ask "which records reference X", declare the reference field as an equality lookup.- No control-plane scopes. A blueprint cannot request keys, profiles, app
contexts, users, billing, admin, or
entities(creating/managing identity entities, includingorg/client) scopes — the CLI scope gate hard-rejects them, mints nothing, and exits non-zero. Declaring principals viaidentities:(above) works around this the other way — the loader resolves them itself, rather than minting anentitiesscope for the bootstrapped key.
Where to go next
- cli.md —
init,validate,plan,bootstrap, andblueprint-testoperate on these files. - mcp.md — the scoped key a blueprint mints is what an MCP agent runs on.
- sdk.md — the operations a blueprint's schemas and scopes govern.
- The blueprint walkthroughs — narrated, end-to-end builds on top of this format.