Build for people¶
The concept behind keys and policy is explained in Keys, capabilities and policy.
Use a subject when multiple people use your application through the same API key. A subject contains an id chosen by your application and optional attributes, such as a department. Core uses these values to check access for that person.
A subject restricts access to that person's conversations, memory and files. You can also write rules for models, tools, prompts, skills, agents, collections and indexes. These rules compare the person's attributes with labels on a resource.
Core does not manage user accounts for these people. Your application supplies their identity; Core records the subject id with the associated activity. Attributes are evaluated per request; a background run temporarily stores them in sealed form so it can resume. For an application that does not share personal data between users, API key permissions may be sufficient.
Send a subject on a turn¶
The person travels in the X-Core-Subject header, on every route that reads or writes a person's
data: turns, threads, memory, files, artifacts, knowledge sources and search, datastore records,
the reads of models, tools, prompts, skills, agents, collections and indexes, and the writes of
agents, prompts and workflows. The API reference lists the header on exactly the
routes that read it:
curl -s -X POST "$CORE_URL/responses" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-H 'X-Core-Subject: {"id": "anna", "attributes": {"department": ["payroll"], "office": ["jansen"]}}' \
-d '{"model": "<a model id>",
"input": "what happened with the De Vries payroll return last month?"}'
idis your own opaque identifier for the person — never a name or an address. It follows the same grammar as OpenAI'suserfield: letters, digits,. _ : @ -, 1 to 128 characters.attributesis what this request asserts about them: up to 32 names, each with up to 32 values of up to 256 characters. A bare string ("department": "payroll") is read as one value. Core stores none of it anywhere — not on the turn, not in an audit row. Stop sending an attribute and the reach it bought is gone on the very next call. The one exception is a turn that keeps running in the background: it holds the request's attributes, encrypted, for as long as the run lasts, so a second round is decided against the same assertion the first one was, and loses them when the run ends.
Changing an agent, a prompt or a workflow is decided for the person you name. Creating,
changing or deleting one, or giving it a new version (POST /agents, POST /agents/{id}/versions,
DELETE /agents/{id}, the same three under /prompts, and POST /workflows, PATCH and DELETE
/workflows/{id}, POST /workflows/{id}/versions), reads the header. Your rules over that kind's
write and delete decide for her (Separate seeing, using and changing), and the
audit trail of the write names her beside the key. Without the header, the write is your backend's
and is decided as it always was, except under a key or organisation policy that sets
settings.subjects_required: there it is 422, as on every route that reads the header
(below). A subject token never changes the library, whoever it names
(below).
Managing keys (/keys), classifying (PUT/DELETE /labels/...) and every other route whose
reference lists no X-Core-Subject ignore the header. On a workflow run (POST
/workflows/{id}/runs) the person is the body's subject_id; the header is not read there.
There are three ways to name a person, in decreasing order of authority:
- A subject token your backend asked for. It binds every request it is presented on to one
person; naming a different one is
403(below). X-Core-Subject, carrying the JSON above or a token your own identity provider signed (below). The first character decides which it is:{is the JSON, three dot-separated segments are a token.user, the field OpenAI's own clients already send. It is an alias of the id in the header and carries no attributes. Naming both with different values is400, withheader.X-Core-Subjectinerror.param.
The body is OpenAI's. There is no subject field in the body. A body that sends one gets the
refusal any unknown field gets: 400 on POST /responses, 422 on POST /threads,
POST /memory and the two knowledge search routes. Set the header on your client instead; see The request format is
OpenAI's.
A token your identity provider signed¶
If your application already authenticates its people, your own identity provider has handed you a
token saying who someone is and what they belong to. You do not have to retype that in JSON: pass
the token in X-Core-Subject and let Core check your provider's signature. Core reads the person
and their attributes out of it, stores neither the token nor any claim.
Register the provider¶
Core believes only providers your organisation registered — at most eight. In the console, the
Identity providers card sits in the organisation's Settings section
(An organisation). From the API, a key with
management does the same with PUT /organisation/subject-issuers, which replaces the whole list:
curl -s -X PUT "$CORE_URL/organisation/subject-issuers" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"issuers": [{
"issuer": "https://login.jansen.example/",
"audience": "artific-core",
"algorithms": ["RS256"],
"jwks": {"keys": [{"kty": "RSA", "kid": "k1", "n": "...", "e": "AQAB"}]},
"claims": {"id": "sub", "attributes": {"department": "groups"}}
}]}'
| Field | What you fill in |
|---|---|
issuer |
exactly the iss a token from this provider carries. Matched whole, never by prefix |
audience |
exactly the aud your own tokens are minted for. Required: a token for another application at the same provider is not yours |
algorithms |
RS256, ES256 or both; ["RS256"] by default. Pinned here, never read from the token's own header |
jwks |
your provider's public keys, pasted in. This is what we recommend: Core needs no network for it. A JWKS carrying a private half is refused |
jwks_uri |
the https address of those same keys, as an alternative to jwks. Its host must be one of your organisation's allowed hosts and must not be blocked by the deployment; allow it before you fill in the address |
claims.id |
the claim holding the person's own identifier; sub by default. Its value must satisfy the same grammar an id you send yourself does |
claims.attributes |
per attribute, the claim it is read from: at most 32. A dotted path reads a nested claim (org.unit), and a claim whose own name contains dots (https://jansen.example/department) is looked up whole first |
At Jansen, {"department": "groups"} turns the provider's "groups": ["payroll"] into Anna's
department: [payroll]. The rule you had already written over department then decides on what the
identity provider said rather than on what the application typed.
A claim the token does not carry is an attribute this request does not assert: not an error, but
less reach. A claim that is there and is not a string or a list of strings is refused — reading
5 as "5" would let one rule decide two ways.
What a refusal says¶
Every token that fails the check produces the same refusal, about the field
header.X-Core-Subject. On POST /responses that is a 400 in
OpenAI's error object:
{
"error": {
"message": "X-Core-Subject must be a JSON object naming a person, or a token an issuer of this organisation signed.",
"type": "invalid_request_error",
"param": "header.X-Core-Subject",
"code": "invalid_request"
}
}
POST /messages answers the same sentence in Anthropic's envelope. Every other route answers
422 problem+json, with the field in errors.
One sentence, always the same: for an expired token, a signature that does not check out, an aud
that is not yours, and a provider you never registered. That is deliberate. Somebody holding only a
key must not be able to work out how your identities are arranged by trying values. So look for the
cause at your provider and in the registration above, not in the answer.
One case answers differently: if Core cannot fetch the keys at your jwks_uri, it is 503 with
The identity provider's keys could not be reached. That one is safe to retry; the refusal above
is not.
A token for the browser¶
When a browser or a mobile application talks to Core directly — a streamed turn, an upload — it must never carry your key, which acts for everybody. So your backend asks for a subject token, your key narrowed to one person, and hands that to the device:
curl -s -X POST "$CORE_URL/subjects/tokens" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"subject": {"id": "anna", "attributes": {"department": ["payroll"]}}, "expires_in": 900}'
{"token": "ast_...", "subject_id": "anna", "expires_at": "2026-09-20T11:15:00Z"}
If you hold a token from your identity provider, send assertion instead of subject, with that
token as its value — exactly one of the two, both or neither is 422. The person and the attributes
are then your provider's word rather than your backend's; nothing else changes.
A token holds at most 4096 characters. The device sends it as a request header, so Core refuses
to mint a token that would be longer. That is about twenty attributes of ordinary length; the exact
number depends on how long your names and values are, so there is no count to check in advance. A
mint over the limit answers 422 with subject.attributes as the field in errors and the message
The attributes are too large to bind into a token; assert fewer or shorter values. Put only the
attributes your rules read into a token. A subject outside the general limits (32 names, 32 values
per name, 256 characters per value) is refused earlier, with 422 on the offending field under
body.subject.attributes.
The device sends the token as Authorization: Bearer ast_... and names nobody itself: a request
naming a different person is 403. Beyond that:
- It lives for
expires_inseconds, 900 by default and 3600 at most. Core keeps no list of the tokens it minted, so you cannot withdraw a single one. Three things end every token a key ever minted, at once: rotating that key, deleting it, and the key's ownexpires_atpassing. A token that has ended answers401withWWW-Authenticate: Bearer, the answer an unknown key gets. - It reaches that person's data and reads your organisation's library — models, prompts, skills,
agents, tools, collections — so your application can show somebody what they may use. It
administers nothing: every library write,
GET /key, the routes under/subjectsand everything under management answer403with A subject token may not reach this route. Make those calls from your backend. - It changes only rows written for its own person. Changing any other row it can read — a thread
nobody in particular opened, a fact the organisation itself wrote, a file your backend uploaded —
answers
403with A subject token may not reach this route. That covers editing and deleting the row, appending to such a thread and continuing it with a turn. A row the token cannot read at all answers404, as it would for a key. - It reads the traces of its own person's calls only; any other trace answers
404. - Core stores the token nowhere, so you see it once. The call is behind the
subjectscapability.
Calling Core from a web page. The gateway answers cross-origin requests from every origin, so
there is no domain of yours to register. It never allows credentials, which means the browser sends
no cookies to Core: your page sends the token itself, in the Authorization: Bearer header. The
browser's preflight — an OPTIONS request under /api/v1 that carries Origin and
Access-Control-Request-Method — is answered by the gateway, and the browser may reuse that answer
for 600 seconds. Every method and request header the API defines is allowed, X-Core-Subject
included. Your page can read these response headers: Content-Type, Location, Retry-After,
X-Request-Id, Deprecation, Sunset, X-RateLimit-Limit, X-RateLimit-Remaining and
X-RateLimit-Reset.
A key sent from a browser is refused. A request with your key's secret that a browser sent
answers 403 with reason_code key_in_browser, on every route, from every page but this
deployment's own guide, whose explorer takes your key: anyone
who opens the page could read the key and act for your whole organisation. Core recognises a
browser by the Origin and Sec-Fetch-Site headers, which browsers add and a page cannot remove.
Your backend keeps working with the key: curl, Node, Python, the OpenAI and Anthropic SDKs and other
server-side clients send neither header. A backend that passes a browser's request on to Core
should not forward those two headers. X-Api-Key is not an allowed header for a browser at all, so
its preflight fails before the key is sent.
Label your sources at upload¶
A subject's attributes travel with the request; a source's labels are stored on it. Add
labels when you create or upload a knowledge source:
curl -s -X POST "$CORE_URL/knowledge/collections/col_2Pf7.../sources" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"kind": "text", "title": "De Vries payroll file",
"text": "...",
"labels": {"department": "payroll", "client": "devries"}}'
A rule reads these labels to decide who a subject's turn may retrieve the source for. metadata
stays yours, for the search filters Knowledge
describes; for one release a rule still reads it where labels lack the name
(Labels and metadata).
Labels on everything¶
A rule can read a label on fifteen kinds of thing, and every kind asks the same question: which
label does the thing a turn reaches for carry? The names below are the kind a rule is written
with (Write your first rule).
| Kind | Where its label lives |
|---|---|
thread, memory_record, knowledge_source, stored_file, datastore_record |
the row's own labels field, as above, which takes the same values as a library row's; for datastore_record the labels of one record |
prompt, skill, agent, schedule, workflow, knowledge_collection, knowledge_index, knowledge_graph, and as tool everything that produces a tool — API connectors, SQL connectors, scripts, MCP servers, toolsets, tool aliases, search tools |
that row's labels field: at most 32 labels, each holding one value or a list of 1 to 32 different values; a value is a string of 1 to 256 characters |
model, and a tool with no row of its own: a builtin (builtin__*), an operation of Core's own API (core__*) and the uploads tool |
a route of their own, PUT /labels/model/{id} and PUT /labels/tool/{name}, from a key or from the console; the uploads tool's label holds for every key of the organisation |
curl -s -X PUT "$CORE_URL/labels/tool/builtin__web_search" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"labels": {"audience": "everyone"}}'
A PUT replaces that one thing's whole classification; GET /labels?kind=tool shows what is
stored and DELETE takes it away. A tool that does have a row of its own is classified there and
not here: that name answers 422.
A label in a labels field may hold several values. Write a list when one value is not
enough: a prompt that serves two departments carries {"department": ["sales", "finance"]}, and
a rule about department: sales reaches it just as a rule about department: finance does. The
same goes for PUT /labels/model/{id} and PUT /labels/tool/{name}. Once you declare a
vocabulary, a list is accepted only under a label declared
multiple. Where a rule still reads a row's metadata during the
move to labels, it reads one string:
a list there matches no rule.
Uploads and artifacts are a stored_file wherever you reach them. Both are knowledge
sources underneath, but the rules over stored_file decide them in all three places they appear: on
GET /files, in a collection's sources, and in what the knowledge__uploads search finds. Under
the default rules a person reaches her own uploads and the shared ones — uploaded by a request that
named nobody — and never another person's. Every other kind of source (text, records, what a
connector syncs) is a knowledge_source.
A tool inherits its source's label. An API connector, a SQL connector and a script each yield
one tool (api__<name>, sql__<name>, script__<name>), which carries the source's label; every
tool of an MCP server carries the server's, and a search tool its own row's. An alias carries its
own label and is judged on its target besides — two names, two classifications, one call. A toolset
carries its own label, and every tool in it is still judged on its own.
The Jansen example, extended: the MCP server salaris — and so every mcp__salaris__* tool — gets
department: payroll, the builtin builtin__web_search gets audience: everyone, the agent
payroll-assistant gets department: payroll and the model gpt-5 gets tier: premium. Labels
alone change nothing: the default rules permit every tool, agent and model to everybody. Once the
key's policy holds rules over tool, agent and model that compare these labels with a
person's department and tier (Write your first rule writes the ones
for tool), Anna (department: [payroll], no tier) is offered the mcp__salaris__* tools and web
search, may name the payroll agent, and is refused gpt-5 before the provider is called. Bob from
reception is offered web search alone and is told the payroll agent does not exist.
Two rules of thumb keep this workable:
- A label is a classification, never a person's id or an attribute value.
department: payrollandtier: premiumsay what the thing is; who the request is for is what the attributes say. Put somebody's id in a label and you have one row per person to maintain, and your library now records who your users are. - Leaving one blank is not the same as allowing nothing. A thing with no label matches no rule that tests a label, so a permit you write over a label leaves it out. As long as you keep the default rules for that kind nothing changes: they permit every knowledge source and everything in every library.
In the console you write labels on that kind's own screen: a Labels field in the form, and a
Labels column in the list (Prompts,
Models, and so on for every library). To give a label several
values, add one row per value under the same name, such as department: sales and
department: finance; the list shows each value as its own badge.
Which labels a person may write¶
A rule that shows a thread labelled project: apollo to every member of Apollo is only as good as
the label. A row's labels are written by the request, so without anything more a person could label her
own thread with a project she is not in and so hand it to every member of that project. Close that
with writable_labels in the policy's settings: per kind of row, each label name maps to what a
value written under it must match. That is "subject" for her own id, {"attribute": "projects"}
for one of her values for that attribute, or {"value": "open"} for one fixed string.
The rule that shows the thread to the project goes in the policy's rules:
{"id": "threads-of-my-projects", "effect": "permit", "kind": "thread",
"when": [{"atom": "label_among", "label": "project", "attribute": "projects"}]}
and the map that guards the label in its settings:
{"writable_labels": {"thread": {"project": {"attribute": "projects"}, "assignee": "subject"}}}
Add both to the key's document and send it back whole (Write your first rule shows how).
Anna (projects: [apollo]) may now write project: apollo and assignee: anna on a thread, and a
write of project: mercury or assignee: bob answers 422 naming the field (labels.project)
with nothing stored. On POST /responses, whose stored response carries the request's labels
(on an existing conversation, the conversation's plus the request's, and only those you add or
change are held; see inheritance),
the refusal is the vendor's own 400 with error.param set to labels.project, with
store: false too, because the turn's response row carries its labels either way. For one release
a value written under the same name in metadata, where labels lack it, is held to the same map
and refused on metadata.project. What it holds to:
- Every request that names a person — a subject token, and a key that names somebody in
X-Core-Subjectoruser. A key that names nobody writes any label, as it always has. - The five kinds of row:
thread(a thread, and thelabelsof a stored response),memory_record,knowledge_source,stored_fileanddatastore_record, each by its own map. A source is held to the map of the kind it is read under, so an upload is astored_filewhichever route stored it — and thepurposeof aPOST /filesupload is itspurposelabel, refused on thepurposefield. A model writing a memory record or a datastore record inside her turn is held to it too, and is told so as a tool result. - Only what changes. A patch that keeps a label the row already carries — a backend set it — is not refused, and removing a label is always allowed. The same holds for a turn in a labelled conversation: the labels it inherits are kept, not weighed.
- A name the map does not mention is unrestricted. A person who asserts no value for the
attribute — a visitor with no
projects— writes no value for the names mapped to it.
Your organisation's own document and your key's are both asked; a value is written only when
neither refuses it. With a vocabulary declared, each mapped name must
be a label declared for that kind and each attribute one a request may assert, or writing the
policy answers 422 at settings.writable_labels.<kind>.<name>.
Declare your vocabulary¶
Without a vocabulary any name is allowed: any name in a labels map, any word can be
asserted as an attribute, and any name can be written into a rule. A typo is therefore not an
error: departement in a rule is stored without complaint and matches nothing, dept in a header
asserts an attribute no rule reads, and visibility: publik on a dossier hides it from everyone.
Each of those fails silently, and each of them fails closed — exactly the direction that looks
like it is working.
So declare which names your organisation uses first:
curl -s -X PUT "$CORE_URL/organisation/label-vocabulary" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"labels": {
"department": {"values": ["payroll", "reception"],
"kinds": ["knowledge_source", "tool", "agent"],
"multiple": true,
"attribute": true,
"description": "the department a thing belongs to"},
"visibility": {"values": ["public", "internal"], "kinds": ["knowledge_source"]},
"client": {"values": null, "kinds": ["knowledge_source"]}
}}'
Each label declares up to five things. values is the closed set of values, or null for "any string"
— which is what you want for something like a client name. kinds says which of the fifteen kinds
may carry this label, so a label for knowledge sources cannot be written on a prompt or read by a rule
about threads. multiple: true lets a labels field hold a list of values under this name, such
as a tool that belongs to both payroll and reception; without it a list is refused on
labels.<name>. A row's metadata is never checked against the vocabulary. attribute: true
is the separate permission to assert the name about a person; a label that only ever sits on rows
stays false. description is the sentence the console shows
beside the name.
From then on the name is refused the moment it is not in the vocabulary — at write time, with the name in the body:
- a rule over
departementis refused when the policy is stored, with the path of the part that names it, such asrules[3].when[0].label; - a prompt with
labels: {"zonne": "north"}is refused onlabels.zonne; - a request with
X-Core-Subject: {"id": "anna", "attributes": {"dept": "payroll"}}is refused onheader.X-Core-Subject—400on a model-call route,422on every other one.
Labels and rules are checked when you write them, not again during a turn; the subject header is
the one thing checked against the vocabulary on every request. Every key of the organisation may
read the vocabulary with GET /organisation/label-vocabulary; writing it needs management.
Declare it before you write rules and labels. If something you already stored does not fit,
the PUT is refused with a conflicts list naming exactly those rows, and nothing is stored:
clean them up first. With no vocabulary everything stays as it was — every name is allowed — and
DELETE /organisation/label-vocabulary takes you back there.
Write your first rule¶
A policy document is two things: rules, a list, and settings. The rules about people stand in
the same list as every other rule your key is decided by — which models it may call, which tools,
which hosts. Change them from the console, on the key's own page
(Keys), or with PATCH /keys/{key_id} if your key has management.
A rule has these parts:
| Part | What it says |
|---|---|
effect |
permit or forbid |
kind |
the kind of thing the rule is about (the table below) |
match.names |
patterns the thing's name must match one of: *, ? and [a-z], case-sensitive. Leave it out for every name |
match.labels |
labels the thing must carry, each with this value among its values. subject is the row's owner, and "" on it is a row nobody in particular produced |
when |
conditions on the person the request names; all must hold |
actions |
optional. The actions the rule is about: see, use, write, delete (below). Leave it out for every action the kind takes |
id |
optional. A name of your own for the rule: lowercase letters, digits and hyphens, starting with a letter, unique in the document. The audit trail names the rule that decided by it |
A when condition is one of four:
| Condition | Holds when |
|---|---|
{"atom": "nobody_named"} |
the request names no person |
{"atom": "label_is_subject", "label": "assignee"} |
the person's own id is among the thing's values for assignee |
{"atom": "label_among", "label": "department", "attribute": "department"} |
at least one of the thing's values for department is also one of the person's values for the attribute department |
{"atom": "attribute_has", "attribute": "role", "value": "admin"} |
the person's values for the attribute role include admin. It reads nothing on the thing |
With a vocabulary, the value attribute_has asks for must be in that
label's closed set, or writing the policy answers 422 at rules[<i>].when[<j>].value.
Three facts decide every case:
- A
forbidthat matches refuses, whatever anypermitsays. - Nothing is allowed without a
permitthat matches. The order of the rules does not matter. - A rule applies only when its whole
whenholds. A condition that reads the person never holds for a request that names nobody.
A key registered without a policy gets the default rules, written out in its document:
| Kind | A rule of this kind decides | The default rules permit |
|---|---|---|
model, router |
which models and model routers a turn may use | every one |
tool |
which tools the model is offered and may call | every one |
prompt, skill, agent, schedule, workflow, file, knowledge_collection, knowledge_index, knowledge_graph, datastore |
which library items a request reaches by name | every one |
knowledge_source |
which knowledge sources a search or a read reaches | every one, because the labels on those are yours to define and Core cannot guess them |
thread, memory_record, stored_file, datastore_record |
which rows a request reaches | every row when the request names nobody; a person's own rows plus the rows nobody in particular produced when it names one |
egress_host |
which hosts the key may reach, within the organisation's allowed hosts | no rule: the key reaches the organisation's hosts |
artifact_download |
whether files a tool produced may be downloaded | yes |
memory_write |
whether a turn may write memory | no |
direct_turn |
whether a turn that names no agent may run: a chat with a bare model | no rule, and that allows every such turn (below) |
To narrow knowledge sources and tools for the Anna example, read the key's document with
GET /key, take the two default permits with the ids knowledge-source and tool out of
rules, and put these six in their place:
[
{"id": "sources-nobody-named", "effect": "permit", "kind": "knowledge_source",
"when": [{"atom": "nobody_named"}]},
{"id": "sources-own-department", "effect": "permit", "kind": "knowledge_source",
"when": [{"atom": "label_among", "label": "department", "attribute": "department"}]},
{"id": "sources-public", "effect": "permit", "kind": "knowledge_source",
"match": {"labels": {"visibility": "public"}}},
{"id": "tools-nobody-named", "effect": "permit", "kind": "tool",
"when": [{"atom": "nobody_named"}]},
{"id": "tools-own-department", "effect": "permit", "kind": "tool",
"when": [{"atom": "label_among", "label": "department", "attribute": "department"}]},
{"id": "tools-for-everyone", "effect": "permit", "kind": "tool",
"match": {"labels": {"audience": "everyone"}}}
]
Then send the document back whole:
curl -s -X PATCH "$CORE_URL/keys/key_9Nx4..." \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d @policy.json
where policy.json holds {"policy": {"rules": [...], "settings": {...}}} with the edited list.
Anna reaches a source or a tool labelled with her own department, plus everything meant for
everybody; a request that names nobody still reaches every source and every tool.
A document is exactly the rules written in it. PATCH replaces the whole policy document: a
document sent without rules gets the default rules, and one sent with "rules": [] permits
nothing, which is a valid state. Permits add up, so to let somebody see her department's threads
beside her own, keep the three default permits for thread and add a fourth. A document holds at
most 500 rules; a rule at most 200 name patterns, 16 label tests and 16 conditions.
Not every kind takes every part. A row has no name, so match.names is refused on thread,
memory_record, knowledge_source, stored_file and datastore_record, and on
artifact_download and memory_write. match.labels is refused on router, egress_host,
file, datastore, artifact_download and memory_write, whose things carry no labels. when
is refused on router, egress_host, artifact_download and memory_write, which are never
asked about a person, and file and datastore take only nobody_named and attribute_has,
the two conditions that read no label: the person's own rows are decided on the row, as
stored_file and datastore_record. direct_turn takes when alone, with those same two
conditions, and only forbid. A part a kind does not take answers 422.
Threads, stored turns and memory belong to your organisation, not to the key that wrote them.
The rules decide which of them a person reaches, whichever key created them. A subject token minted
by one key therefore reaches a thread that another key of your organisation opened for the same
person, under the rules of the key the token was minted from. Files work differently in one
respect: GET /files without a filter lists only the uploads in the calling key's own collection.
A read by id is not limited that way. Another key's token, or another key naming the same person,
reads the file by its id when its rules reach the collection the file is in and the person matches
(What a file id reaches).
A key that names a person acts for her on every row the rules permit her. Naming Anna in
X-Core-Subject lets your key append to, relabel, delete or continue any thread the rules let
her reach, including a shared thread or, under a rule of your own, a colleague's. The thread keeps
its owner. The audit trail and the new stored turn carry Anna as the person, so under the default
rules the thread's owner then gets 404 on GET /responses/{id} for Anna's turn, while still
reading its items through the thread. Only a subject token is limited to changing its own person's
rows (above).
Rules over knowledge_collection and knowledge_index decide what a request may reach by
id, such as the collection it lists sources of or the index it searches. They never filter search
results; the rules over knowledge_source and stored_file do that. The rules over
knowledge_collection and knowledge_index do not apply to knowledge__uploads, the search over
your key's own collection: a rule of kind tool that names it is what removes it.
Enable model retrieval explains how each applies when the
model searches.
To the caller a refused read looks like absence: a list leaves the row out, an id answers 404, a
model or a tool the person does not reach does not exist for this turn, and a tool is not offered
to the model. Two refusals are not absence. A subject token changing a row it can read but that is
not its own person's answers 403 (above). And an action the rules do
not permit on something the caller may see, or a create or relabel they do not permit, answers
403 and changes nothing (Separate seeing, using and changing): POST /threads or
PATCH /threads/{id} with labels no write rule over thread permits the person is refused this
way. The caller already knows that thing, or it does not exist yet, so there is nothing for a
404 to hide. Your
organisation's audit trail records every refusal, with one of two reasons:
forbidden by a rule when a forbid matched, and no rule permits otherwise.
Separate seeing, using and changing¶
A rule can name the actions it is about. Without them a rule covers everything a request can do with a thing. With them you can, for example, let everybody use an agent while only administrators may change it.
| Action | Asked when |
|---|---|
see |
the thing is listed, or read by its id |
use |
a turn, a run or a call uses it: an agent or a prompt a turn names, a skill it links, a tool offered to the model or called, a model called, an index searched, a file attached, a workflow started |
write |
the thing is created, changed, renamed, relabelled or given a new version; for a conversation or another row, also when something is appended to it |
delete |
the thing is deleted |
{"id": "agents-everyone", "effect": "permit", "kind": "agent", "actions": ["see", "use"]}
A kind takes only the actions that mean something for it:
| Kinds | Actions |
|---|---|
prompt, skill, agent, schedule, workflow, knowledge_collection, knowledge_index, knowledge_graph, datastore, file, tool |
see, use, write, delete |
model |
see, use |
thread, memory_record, knowledge_source, stored_file, datastore_record |
see, write, delete |
router, egress_host, artifact_download, share_link, memory_write, direct_turn |
none |
An action the kind does not take, actions on a kind that takes none, and an empty or repeated
list answer 422 on the rule's actions.
- A rule without
actionscovers every action its kind takes. The default rules name no actions, so a policy written without them decides exactly as before. - No action includes another. A permit for
writealone does not let a person find the thing; give herseeas well. Core asksseefirst and then the action the request needs. A thing the person may not see answers404. A thing she sees but may not use, change or delete answers403withpolicy-denied, and the audit trail records the rule that decided. - A tool is listed by
seeand offered byuse.GET /toolsshows the tools a person may see; a turn offers the model only those she may use. - A forbid with
actionsrefuses only those actions. This rule keeps production agents from being deleted and changes nothing else:{"effect": "forbid", "kind": "agent", "actions": ["delete"], "match": {"labels": {"stage": "production"}}}.
Creating something is a write, decided for the person and with the labels in the body. A
permit for write whose labels match those of the new thing lets the person create it; a write
forbid over a label refuses a create that carries that label. A change of labels is decided on the
old labels and on the new ones, so a person cannot move a thing into, or out of, a department she
may not write in. A policy with no rule about a kind permits nothing of it, so it refuses a new
thing of that kind just as it hides the existing ones.
Worked example: two departments and administrators¶
One organisation has a Sales and a Finance department, an API connector both use, and
administrators who may change agents. The application declares its vocabulary once, as the body of
PUT /organisation/label-vocabulary (Declare your vocabulary):
{"labels": {
"department": {"values": ["sales", "finance"], "kinds": ["tool", "agent"],
"attribute": true, "multiple": true},
"role": {"values": ["member", "admin"], "kinds": ["agent"], "attribute": true},
"stage": {"values": ["draft", "production"], "kinds": ["agent"], "attribute": false}
}}
It labels its library:
| Thing | Labels |
|---|---|
API connector crm (tool api__crm) |
department: "sales" |
API connector boekhouding (tool api__boekhouding) |
department: "finance" |
API connector agenda (tool api__agenda) |
department: ["sales", "finance"] |
agent offerte |
department: "sales", stage: "production" |
agent facturen |
department: "finance", stage: "draft" |
The key keeps the default rules for every other kind. These six rules take the place of the
default permits with the ids tool and agent:
{"rules": [
{"id": "backend-tools", "effect": "permit", "kind": "tool",
"when": [{"atom": "nobody_named"}]},
{"id": "department-tools", "effect": "permit", "kind": "tool", "actions": ["see", "use"],
"when": [{"atom": "label_among", "label": "department", "attribute": "department"}]},
{"id": "backend-agents", "effect": "permit", "kind": "agent",
"when": [{"atom": "nobody_named"}]},
{"id": "department-agents", "effect": "permit", "kind": "agent", "actions": ["see", "use"],
"when": [{"atom": "label_among", "label": "department", "attribute": "department"}]},
{"id": "admins-change-agents", "effect": "permit", "kind": "agent",
"actions": ["see", "write", "delete"],
"when": [{"atom": "attribute_has", "attribute": "role", "value": "admin"}]},
{"id": "production-stays", "effect": "forbid", "kind": "agent", "actions": ["delete"],
"match": {"labels": {"stage": "production"}}}
]}
admins-change-agents uses the condition attribute_has: it looks only at the person, so it holds
for every agent when the request's role includes admin. Each request names its person:
X-Core-Subject: {"id": "anna", "attributes": {"department": ["sales"], "role": ["member"]}}
X-Core-Subject: {"id": "bram", "attributes": {"department": ["finance"], "role": ["admin"]}}
| Request | Anna (Sales, member) | Bram (Finance, admin) | Backend, nobody named |
|---|---|---|---|
GET /api_connectors |
crm, agenda |
boekhouding, agenda |
all three |
| a turn's tools | api__crm, api__agenda |
api__boekhouding, api__agenda |
all three |
GET /agents |
offerte |
facturen, offerte: administrators see every agent |
both |
a turn that names the agent offerte |
allowed | 403: he may see it, but only Sales may use it |
allowed |
a new version of facturen |
404: she cannot see it |
allowed (admins-change-agents) |
allowed |
POST /agents labelled department: sales |
403: no rule lets her write |
allowed | allowed |
DELETE /agents/{agent_id} for offerte |
403 |
403: production-stays forbids it |
403: the forbid holds for everyone |
The agenda connector carries both departments, so one rule reaches it for Anna and for Bram.
Two variations need no new parts. To let administrators change only their own department's agents,
add the label_among condition to admins-change-agents: the conditions of one when must all
hold. To let administrators also use every agent, add use to its actions.
Stop a person chatting with a bare model¶
If you sell plans per person, somebody on a basic plan may be meant to talk to your
organisation's agents, use its tools and search its knowledge, but not chat with a model
directly. Write one forbid over the kind direct_turn, a turn that names no agent:
{"id": "no-playground", "effect": "forbid", "kind": "direct_turn",
"when": [{"atom": "attribute_has", "attribute": "plan", "value": "basic"}]}
A turn for a person whose plan includes basic must then name an agent, in agent or as
agent:<name> in model. One that names none answers 403 with agent in error.param (on
POST /messages the message starts with agent:), and no model is called. The message tells
the caller to name an agent and never says which attribute refused; the audit trail records the
reason direct turn forbidden and the rule's id.
- Without the rule nothing changes. A turn that names no agent is allowed unless a
forbidmatches, so the default rules hold no rule overdirect_turn. A rule over it can only forbid: apermitanswers422. It takes nomatchand noactions, and inwhenonlynobody_namedandattribute_has, because a turn carries no labels. - Rules have no "not". Forbid by a value the people you restrict always carry, such as
plan: basic, never "everyone exceptpro", which a rule cannot say. A person whose request asserts noplanis not refused, and neither is a request that names nobody. - Turns only. A direct tool call, a knowledge search
outside a turn and a toolset served over MCP stay allowed. To refuse those as well, for every
request of a key, use
settings.agents_required(Agents through the API). - Either document refuses. A forbid in your organisation's document or in the key's own both
count. Submitting tool outputs on a turn that named no agent is decided for the person the
submission names. A background run is decided again when a worker
starts it, for the person its request named, so one queued before you wrote the rule fails with
agent_required. - A vocabulary checks the attribute. With a vocabulary,
planmust be declared as an attribute, andbasicmust lie in its closed set if it has one. - A schedule carries attributes only when the person wrote it herself. When you create or
change a schedule with the key it runs as and
X-Core-Subjectnaming the same person as itssubject_id, Core keeps the attributes she asserts sealed on the schedule, and every fire for the next 30 days applies them, so a rule overplanrefuses her scheduled turn as it refuses her live one. Each later write of hers moves the 30 days on, and one asserting nothing removes them;subject_attributes_untilon the schedule says until when. A schedule written for her by your backend alone, or through the console, fires with hersubject_idonly, and so does one whose 30 days have passed. A workflow step carries only its run'ssubject_id. A schedule is checked when it is written for nobody in particular, so a forbid with the conditionnobody_namedrefuses writing a schedule that names asubject_id, although its fires, made for that person, would pass.
Registrations follow the tool rules¶
Toolsets, tool aliases, API connectors, MCP servers, SQL connectors, scripts and retrieval tools are
the registrations that produce tools. For a request that names a person, the rules about tool
decide them as well: which ones their lists show (GET /toolsets, /tool-aliases,
/api_connectors, /mcp_servers, /sql_connectors, /scripts and
/knowledge/retrieval-tools), and whether reading, changing or deleting one is allowed. A
registration is decided with its own labels, which are the labels its tools carry, and with the
action the request asks: see to list or read it, write to change it, delete to delete it. One
the person may not see answers 404; one she sees but may not change or delete answers 403.
Each registration is decided under a tool name:
| Registration | Decided as |
|---|---|
toolset support |
support |
tool alias lookup |
alias__lookup |
API connector salaris |
api__salaris |
SQL connector orders |
sql__orders |
script deck |
script__deck |
retrieval tool handbook |
knowledge__handbook |
MCP server salaris |
its tools, mcp__salaris__<tool> |
An MCP server has many tools, so a rule decides it by the tool names its pattern can match:
- A permit shows the server when its pattern matches at least one possible tool of it:
mcp__salaris__get_*,mcp__*and*do;mcp__salarisdoes not. - A forbid hides the server only when its pattern matches every possible tool of it:
mcp__salaris__*,mcp__*or*. A forbid of some operations, such asmcp__salaris__delete_*, keeps those tools from the model and leaves the server listed.
A request that names nobody is not narrowed: it lists every registration, whatever the key's rules
about tool say. The console lists every registration too. Creating a registration, and reading a
connector's versions or a script's files, are not decided by these rules.
What a request without a subject reaches¶
A rule whose when reads the person does not apply to a request that names nobody: such a permit
grants nothing and such a forbid refuses nothing. What that request reaches is decided by the rules
with no when and by those with nobody_named. Under the default rules that is everything its key
reaches, whoever a row belongs to, so a backend job that never names a person keeps working after
you have classified your library — as long as the rules you write keep a permit for it, as the two
…-nobody-named rules above do. Leave those out and a request without a person reaches only what
the rules without a when permit.
Set settings.subjects_required to true and a request naming no
subject is refused — 400 on a model-call route, 422 on every other one. Use it on a key whose
calls should never be allowed to forget who they are for.
When your organisation has a policy document¶
An organisation can have a policy document of its own, beside every key's. Every decision then asks both: a thing is reached only when the rules of each document allow it. Your key's document can therefore only narrow what the organisation allows, never widen it.
GET /key still answers what stands on your own key and says nothing about the organisation's
document. To read or write that one, use GET/PUT/DELETE /organisation/policy if your key has
management, or the organisation's Settings section in the console
(An organisation). When the organisation wrote no document
of its own, that route answers {"policy": null} and your key decides alone.
DELETE /organisation/policy answers 204 whether or not a document was there, so you can send it
without reading first. A 404 on these routes means your key does not hold management. A 403
means the organisation above yours has reserved key management to itself: you can still read the
document, but not change it.
If you hit a refusal your key's own policy does not explain, look there: the two documents are never merged into one. A forbid in one is never lifted by a permit in the other, and a kind one document holds no permit for is refused by that document, whatever the other says.
What naming a person stores¶
When a request that names a person creates a row, what happens to her id depends on the kind of row.
| Kind of row | What is stored |
|---|---|
a thread, a stored turn, a memory record, a datastore record, an upload to /files, an artifact a turn produced, and a knowledge source you create or upload (text, records, a copied file, an uploaded file) |
her id, as the row's owner. The rules over its kind (thread, memory_record, stored_file, datastore_record or knowledge_source) decide who else reaches it, and DELETE /subjects/{id} erases it with the rest of her data |
| a library definition: an agent, a prompt, a skill, an API connector, an MCP server, a toolset, a tool alias, a search tool, a collection, an index, a label | nothing about her. The definition belongs to your organisation. A write of an agent, a prompt or a workflow is decided for her and its audit row names her (above), but the definition itself keeps no owner |
| a schedule | the person in the body's subject_id field, not the one in the header. Every turn the schedule fires is for that person. Erasing her switches the schedule off instead of deleting it |
A row written by a request that named nobody has no owner. It is shared: every person reaches it under the default rules, and no erasure removes it.
Erase a person¶
DELETE /subjects/{id} removes everything Core holds under one subject id, on your
organisation's own storage:
- that person's conversations with their working states, and the computers still running for them;
- stored turns, memory records, datastore records, uploaded sources and artifacts, and the grants on them;
- pages published for them, exports narrowed to them, and the feed events about their rows;
- finished tasks started for them;
- their workflow runs, with an open run cancelled first;
- improvement proposals drawn from their memory alone.
Some rows stay and lose only the person. A decision assigned to them on a workflow run that stays
keeps the run, without their id or their answer; one still waiting for them fails with
no_assignee, and the run goes on from there. An improvement proposal drawn from several people
keeps its text and loses the ids of their turns. A schedule that fires turns for that person, and
an event trigger that starts workflow runs for them, is switched off rather than deleted, so you
can point it at someone else. It answers 202 — the work runs in the background — and one
audit event records the counts once it finishes. Audit and usage rows keep the id itself, because
that trail is your own record of what happened, and the id alone identifies nobody without the
attributes that came with it.
curl -s -X DELETE "$CORE_URL/subjects/anna" \
-H "Authorization: Bearer $CORE_KEY"
GET /subjects/{id} answers what Core currently holds for that id — a count per kind, and nothing
else, since that is all Core has to answer with. Both routes are behind the subjects capability.
The DELETE answers 202 for an id Core holds nothing for as well, so your backend can pass on an
erasure request without first finding out whether the person is known. What the other answers
mean:
| Answer | Meaning |
|---|---|
404 |
your key does not hold the subjects capability, or the id does not follow the id grammar (letters, digits, . _ : @ -, 1 to 128 characters). A 404 never means that the person is gone |
401 |
the key is not valid, which includes every key of an organisation that has been deleted |
403 |
your organisation is suspended |
409 |
your organisation is still being set up or is being deleted. Try again later |
To see what is left after an erasure, ask GET /subjects/{id}. With the capability on and a valid
id, its 404 means Core holds nothing for that person. A turn that was still running when the
erasure started is left alone and counted as skipped_in_flight in the audit event of the erasure;
send the DELETE again once that turn has finished to remove it too.
If another key of your organisation asks to erase the same person while your erasure is still
queued or running, it gets the same erasure_id and no second erasure starts. That key's trail
shows only its own subject.erasure_requested event; the subject.erased event with the counts is
on your key's trail.
Anna, end to end¶
Putting it together: a virtual employee for accountancy firms, one key per firm, a hundred client
dossiers uploaded once with client and department labels, the MCP server salaris labelled
department: payroll and web search labelled audience: everyone. Anna, who works in payroll at
firm Jansen, asks the assistant her firm bought:
curl -s -X POST "$CORE_URL/responses" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-H 'X-Core-Subject: {"id": "anna", "attributes": {"department": ["payroll"], "office": ["jansen"]}}' \
-d '{"model": "<a model id>",
"input": "what happened with the De Vries payroll return last month?"}'
With the rules from above in force, retrieval reaches only sources labelled
department: payroll or visibility: public — De Vries' payroll file, never a different client's
tax file uploaded under a different department — and the model is offered the
mcp__salaris__* tools plus web search and nothing else. The response:
{
"id": "resp_9Ktq...",
"object": "response",
"status": "completed",
"output": [
{"type": "message", "role": "assistant",
"content": [{"type": "output_text", "text": "The De Vries payroll return was filed on..."}]}
]
}
Bob from reception sends the same request with {"id": "bob", "attributes": {"department":
["front-office"]}}: he is offered web search, not one mcp__salaris__* tool, and the payroll file never
appears in his search results.
Anna's own thread, if this turn is on one, is hers: a later request naming a different subject id
gets the same 404 a thread that never existed gets. Nothing about Anna beyond "anna" and the
attributes on this one request is ever stored.