Skip to content

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?"}'
  • id is your own opaque identifier for the person — never a name or an address. It follows the same grammar as OpenAI's user field: letters, digits, . _ : @ -, 1 to 128 characters.
  • attributes is 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:

  1. 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).
  2. 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.
  3. 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 is 400, with header.X-Core-Subject in error.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_in seconds, 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 own expires_at passing. A token that has ended answers 401 with WWW-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 /subjects and everything under management answer 403 with 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 403 with 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 answers 404, 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 subjects capability.

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: payroll and tier: premium say 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-Subject or user. A key that names nobody writes any label, as it always has.
  • The five kinds of row: thread (a thread, and the labels of a stored response), memory_record, knowledge_source, stored_file and datastore_record, each by its own map. A source is held to the map of the kind it is read under, so an upload is a stored_file whichever route stored it — and the purpose of a POST /files upload is its purpose label, refused on the purpose field. 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 departement is refused when the policy is stored, with the path of the part that names it, such as rules[3].when[0].label;
  • a prompt with labels: {"zonne": "north"} is refused on labels.zonne;
  • a request with X-Core-Subject: {"id": "anna", "attributes": {"dept": "payroll"}} is refused on header.X-Core-Subject — 400 on a model-call route, 422 on 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 forbid that matches refuses, whatever any permit says.
  • Nothing is allowed without a permit that matches. The order of the rules does not matter.
  • A rule applies only when its whole when holds. 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 actions covers 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 write alone does not let a person find the thing; give her see as well. Core asks see first and then the action the request needs. A thing the person may not see answers 404. A thing she sees but may not use, change or delete answers 403 with policy-denied, and the audit trail records the rule that decided.
  • A tool is listed by see and offered by use. GET /tools shows the tools a person may see; a turn offers the model only those she may use.
  • A forbid with actions refuses 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 forbid matches, so the default rules hold no rule over direct_turn. A rule over it can only forbid: a permit answers 422. It takes no match and no actions, and in when only nobody_named and attribute_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 except pro", which a rule cannot say. A person whose request asserts no plan is 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, plan must be declared as an attribute, and basic must 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-Subject naming the same person as its subject_id, Core keeps the attributes she asserts sealed on the schedule, and every fire for the next 30 days applies them, so a rule over plan refuses 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_until on the schedule says until when. A schedule written for her by your backend alone, or through the console, fires with her subject_id only, and so does one whose 30 days have passed. A workflow step carries only its run's subject_id. A schedule is checked when it is written for nobody in particular, so a forbid with the condition nobody_named refuses writing a schedule that names a subject_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__salaris does 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 as mcp__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.