Company Skill Policy
Skills are how your agents pick up new abilities — and left unguarded, an agent can create, import, install, edit, or remove them at will. The company skill policy is the guardrail. It is a single per-company document that says which agents may perform which skill actions, on which skills, and from which sources.
The policy is one document per company, with a revision number for safe concurrent edits. You read it, replace it wholesale, reset it back to open, and — usefully — ask the server to explain a decision before you make a change.
Every route below is company-scoped, and company access is checked on every request. If you are new to the API, read the API Overview first for base URL, authentication, and error-code conventions; this page builds on those and won't repeat them.
The open default
A brand-new company has no stored policy at all. Reading the policy in that state gives you an open default:
{
"schemaVersion": 1,
"revision": 0,
"defaultEffect": "allow",
"rules": [],
"materialized": false
}
The materialized flag is the one to watch. When it is false, no policy row exists — nothing has been written yet, and every skill action is allowed. Once you save a policy it becomes true and revision starts counting up from 1.
That distinction also shows up in evaluation. An unmaterialized policy short-circuits to "allowed" with the reason no_policy_default, without walking any rules.
What a policy document looks like
A policy has three parts: a schema version, a default effect, and an ordered set of rules.
| Field | Notes |
|---|---|
schemaVersion |
Must be 1. |
defaultEffect |
allow or deny. Applied when no rule matches. |
rules |
Array of rule objects, up to 1,000. Rule id values must be unique. |
Documents are strict — unknown fields are rejected.
Rules
| Field | Required | Notes |
|---|---|---|
id |
yes | 1–128 characters, matching ^[a-zA-Z0-9][a-zA-Z0-9._:-]*$. |
priority |
yes | Integer between -1000000 and 1000000. Lower numbers are considered first. |
effect |
yes | allow or deny. |
subject |
yes | Who the rule applies to — see below. |
actions |
yes | At least one skill action, no duplicates. |
resources |
no | Narrows the rule to particular skills or sources. Omit it to match every resource. |
Subjects
The subject object is a tagged union on type:
type |
Shape | Matches |
|---|---|---|
all_agents |
{ "type": "all_agents" } |
Any agent principal. |
agents |
{ "type": "agents", "agentIds": [...] } |
The listed agents. 1–500 unique UUIDs. |
roles |
{ "type": "roles", "roles": [...] } |
Any principal whose role matches, compared case-insensitively after trimming. 1–500 unique strings. |
all_agents and agents only ever match agent principals. roles matches on the principal's role string, so a board principal — which evaluates with the role board — can be targeted with a roles rule.
Actions
| Action | Covers |
|---|---|
skills.create |
Creating a new skill. |
skills.import |
Importing a skill. |
skills.install |
Installing a skill. |
skills.edit |
Editing an existing skill. |
skills.update |
Updating a skill from its source. |
skills.test |
Running a skill test. |
skills.reset |
Resetting a skill. |
skills.remove |
Removing a skill. |
Resource selectors
resources narrows a rule. At least one of these keys must be present, and unknown keys are rejected:
| Key | Notes |
|---|---|
skillIds |
1–500 unique skill UUIDs. |
skillKeys |
1–500 unique strings, each 1–512 characters. |
sourceTypes |
One or more of workspace, catalog, git, external_package, generated, unknown. Must be unique. |
sourceLocators |
1–500 unique locator strings, each up to 2,048 characters. |
Every listed selector has to match for the rule to apply. A rule that names skillKeys will never match an evaluation that doesn't carry a skillKey.
Source locators are normalized. Both the values you store in a rule and the values you pass at evaluation time are canonicalized the same way — repo-style https URLs get a lowercased host (www.github.com becomes github.com), a lowercased owner and repo, and a stripped .git suffix. That matters: without matching normalization on both sides, a deny rule could silently never fire.
Locators are also screened for embedded credentials. A locator with a username or password in the URL, or with a query or fragment parameter that looks like token, secret, password, api_key, or authorization, is rejected with the message "Source locators must not contain credentials or secret query or fragment parameters".
Who can read and who can change
Reading the policy — and evaluating against it for yourself — needs nothing more than access to the company.
Changing it (PUT, DELETE, or evaluating on behalf of another agent) requires skill policy administration authority, which means one of:
- a board actor from the implicit local session, or an instance admin;
- a board user holding the
users:manage_permissionspermission in that company; - an agent holding the
users:manage_permissionspermission in that company.
Anything else comes back as 403 Forbidden with code skill_policy_admin_required and the remediation "Ask a company administrator to manage the skill policy."
Two more boundaries apply to every route here:
- An unauthenticated caller gets
401with codeskill_authentication_required. - An agent key scoped to a different company gets
403with codeskill_company_boundary_denied.
Get the Policy
GET /api/companies/{companyId}/skill-policy
Return the company's effective policy — either the stored document (with materialized: true and its current revision) or the open default described above.
Replace the Policy
PUT /api/companies/{companyId}/skill-policy
Policies are replaced wholesale, never patched. Send the complete document plus the revision you believe is current:
| Field | Required | Notes |
|---|---|---|
schemaVersion |
yes | 1. |
defaultEffect |
yes | allow or deny. |
rules |
yes | The full rule set. Send [] for none. |
expectedRevision |
yes | Non-negative integer. Use 0 when no policy exists yet. |
On success the server bumps the revision by one and returns the saved document with materialized: true.
If expectedRevision doesn't match what is stored, the write is rejected with 409 Conflict, code skill_policy_revision_conflict, and the details carry expectedRevision and currentRevision so you can re-read, re-apply your change, and retry.
A document that fails validation returns 422 Unprocessable Entity with { "error": "Invalid skill policy document" }, code skill_policy_validation_failed, and an issues array describing what went wrong.
A successful replace records a company.skill_policy_replaced activity entry against entity type company_skill_policy, with the previous and new revision, the default effect, and the rule count.
Example
curl -sS -X PUT \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
"https://paperclip.example.com/api/companies/{companyId}/skill-policy" \
-d '{
"schemaVersion": 1,
"defaultEffect": "allow",
"expectedRevision": 0,
"rules": [
{
"id": "no-external-installs",
"priority": 10,
"effect": "deny",
"subject": { "type": "all_agents" },
"actions": ["skills.install", "skills.import"],
"resources": { "sourceTypes": ["external_package", "git"] }
}
]
}'
Reset the Policy
DELETE /api/companies/{companyId}/skill-policy
Delete the stored policy and return to the open default. The response is the open default document (defaultEffect: "allow", no rules, materialized: false).
Resetting a company that had a policy records a company.skill_policy_reset activity entry with the previous revision and a new revision of 0. Resetting a company that never had one is a no-op and still returns the default.
Evaluate a Decision
POST /api/companies/{companyId}/skill-policy/evaluate
Ask the policy engine what it would decide, without performing the action. This is the endpoint to reach for when you want to explain a denial, or to test a rule set before relying on it.
Request body:
| Field | Required | Notes |
|---|---|---|
action |
yes | One of the eight skill actions. |
resource |
no | skillId, skillKey, sourceType, sourceLocator — any subset. Defaults to {}. |
principal |
no | { "agentId": "<uuid>" }. Evaluate as that agent instead of yourself. |
Without principal, the evaluation runs as you: an agent caller evaluates as its own agent (with that agent's role), and a board caller evaluates as a board principal with the role board.
With principal, you are asking about someone else — so the route requires skill policy administration authority, exactly like a write. The named agent must exist (404 Agent not found) and must belong to this company (403, code skill_company_boundary_denied).
How a decision is reached
- If the policy isn't materialized, the answer is allow, reason
no_policy_default. - Otherwise rules are sorted by
priorityascending, ties broken byidin lexicographic order, and the first rule whose actions, subject, and resource selectors all match wins. The decision follows that rule'seffect, reasonexplicit_rule, with the rule's id inmatchedRuleId. - If nothing matched and
defaultEffectisdeny, one compatibility check runs: a principal holding askills:createorskills:suggest-changespermission grant in the company is allowed with reasonlegacy_compatibility. Those grants historically authorized the whole skill mutation surface, and this preserves that scope only as a default-deny fallback — explicit rules still take precedence. - Otherwise the answer follows
defaultEffect, reasonpolicy_default.
The decision object
{
"allowed": false,
"action": "skills.install",
"reason": "explicit_rule",
"policyRevision": 4,
"matchedRuleId": "no-external-installs",
"remediation": "Contact a company administrator to change the skill policy."
}
remediation is null when the action is allowed.
reason |
Meaning |
|---|---|
no_policy_default |
No policy is stored, so the action is allowed. |
explicit_rule |
A rule matched; matchedRuleId names it. |
policy_default |
No rule matched; defaultEffect decided it. |
legacy_compatibility |
Default-deny, but a legacy skills:create / skills:suggest-changes grant allowed it. |
platform_invariant |
A platform-level rule the skill routes enforce ahead of the policy. |
Where to go next
- Agents — the agents you name in
subject.agentIdsand inprincipal.agentId. - Activity — where
company.skill_policy_replacedandcompany.skill_policy_resetland. - API Overview — base URL, authentication, company scoping, and the shared error-code table.