From 79d377a885078f340b38238689df64bdd92f4df5 Mon Sep 17 00:00:00 2001
From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com>
Date: Fri, 9 Oct 2026 18:45:10 +0000
Subject: [PATCH 1/3] Document scoped API keys
---
info/api-keys.mdx | 126 ++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 123 insertions(+), 3 deletions(-)
diff --git a/info/api-keys.mdx b/info/api-keys.mdx
index 8d7ae74b..b1e1bff3 100644
--- a/info/api-keys.mdx
+++ b/info/api-keys.mdx
@@ -88,6 +88,123 @@ func main() {
```
+## Scope a key to specific resources
+
+By default, an API key can do anything in its organization or project. Add a `policy` when you create a key to limit it to specific actions on specific resources. A key with a policy is denied everything the policy doesn't grant.
+
+Use scoped keys when you hand a credential to code you trust less than your backend, such as an AI agent, a customer-facing worker, or a short-lived job. Keys created without `policy` work exactly as before.
+
+### Write a policy
+
+A policy is a list of allow statements. Each statement pairs one `action` with one `resource`:
+
+| Resource | Matches |
+| --- | --- |
+| `organizations/{org_id}/*` | Everything in the organization. |
+| `projects/{project_id}/*` | Everything in the project. |
+| `projects/{project_id}/{kind}/{id}` | One browser, profile, vault, or proxy, for example `projects/proj_staging_9f3k/profiles/prof_7h2kq9x4m1`. |
+| `{kind}/{id}` | One object in the key's project, or anywhere in the organization for an org-scoped key. |
+
+`kind` is `browsers`, `profiles`, `vaults`, or `proxies`. Kernel checks every exact ID when you create the key. IDs that don't exist return `404`, and resources outside the key's organization or project return `400`.
+
+| Action | Allows |
+| --- | --- |
+| `browsers:create` | Create browsers. Grant it on a project or organization, not an ID. |
+| `browsers:use` | Inspect, connect to, and control browsers. |
+| `browsers:write` | Update browser configuration. |
+| `browsers:delete` | Delete browsers. |
+| `profiles:create` / `profiles:read` | Create profiles / view profile metadata. |
+| `profiles:use` | Attach profiles to browsers and export them. |
+| `profiles:write` / `profiles:delete` | Rename profiles and save browser state into them / delete profiles. |
+| `vaults:create` / `vaults:read` | Create vaults / view vault and item metadata. |
+| `vaults:use` | Fill credentials and run vault operations. |
+| `vaults:write` / `vaults:delete` | Create, update, and delete vault items / delete vaults. |
+| `proxies:create` / `proxies:read` | Create proxies / view proxy configuration. |
+| `proxies:use` | Select saved proxies for browsers and run health checks. |
+| `proxies:write` / `proxies:delete` | Rename proxies / delete proxies. |
+| `projects:read` / `organizations:read` | View project or organization metadata and limits. |
+
+Resources a browser references need their own grants:
+
+- **Creating a browser** needs `browsers:create` and `browsers:use` on its project or organization, because the response includes connection URLs.
+- **Profiles:** attaching a profile needs `profiles:use` on it. Saving changes back to it also needs `profiles:write`.
+- **Vaults:** linking a vault needs `vaults:use` on every vault.
+- **Proxies:** selecting a saved proxy needs `proxies:use` on it.
+- **Existing browsers:** a key can only use a browser if it can also use that browser's attached profile and vaults.
+
+List endpoints return only the resources the key can access, so a key without a matching grant gets an empty list rather than an error. A profile appears in `GET /profiles` only with `profiles:read`, so grant it alongside `profiles:use` if your code looks profiles up by listing them.
+
+### Create a scoped key
+
+This key can create and control browsers in one project, only with profile `prof_7h2kq9x4m1` or no profile, and it expires in one hour:
+
+
+```typescript TypeScript
+import Kernel from '@onkernel/sdk';
+
+const kernel = new Kernel({
+ apiKey: process.env.KERNEL_API_KEY,
+});
+
+const project = 'projects/proj_staging_9f3k';
+
+const apiKey = await kernel.apiKeys.create({
+ name: 'checkout-agent-run-4812',
+ project_id: 'proj_staging_9f3k',
+ expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
+ policy: {
+ statements: [
+ { action: 'browsers:create', resource: `${project}/*` },
+ { action: 'browsers:use', resource: `${project}/*` },
+ { action: 'profiles:use', resource: `${project}/profiles/prof_7h2kq9x4m1` },
+ ],
+ },
+});
+
+console.log(apiKey.key); // Save this value now. Kernel won't show it again.
+console.log(apiKey.policy);
+```
+
+```python Python
+import os
+from datetime import datetime, timedelta, timezone
+
+from kernel import Kernel
+
+client = Kernel(api_key=os.environ["KERNEL_API_KEY"])
+
+project = "projects/proj_staging_9f3k"
+
+api_key = client.api_keys.create(
+ name="checkout-agent-run-4812",
+ project_id="proj_staging_9f3k",
+ expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
+ policy={
+ "statements": [
+ {"action": "browsers:create", "resource": f"{project}/*"},
+ {"action": "browsers:use", "resource": f"{project}/*"},
+ {"action": "profiles:use", "resource": f"{project}/profiles/prof_7h2kq9x4m1"},
+ ],
+ },
+)
+
+print(api_key.key) # Save this value now. Kernel won't show it again.
+print(api_key.policy)
+```
+
+
+To limit a key to vault `vlt_3m8q2r7k1x` instead, grant `vaults:use` on `projects/proj_staging_9f3k/vaults/vlt_3m8q2r7k1x`. Add `vaults:read` if the key also needs to read the vault's metadata.
+
+### Expiry and revocation
+
+- `expires_at` sets an exact expiry time, so a key can live for minutes rather than whole days. It can't be combined with `days_to_expire`.
+- A scoped key can't outlive the credential that creates it. If your key expires, set `expires_at` or `days_to_expire` no later than that.
+- An expired key is rejected immediately. A deleted key is rejected within about 10 seconds.
+- Neither closes browser connections that are already open. Delete those browsers to cut off access immediately.
+- Rotating a scoped key gives the replacement the same policy.
+
+Scoped keys can't create, list, or delete API keys, and they get `403` with `insufficient_scope` on any endpoint that doesn't support scoped access yet. Manage keys with an unscoped key.
+
## Deployment API keys
When you deploy an app, Kernel mints a **deployment-scoped API key** for that deployment and injects it into the deployment (and every invocation it runs) as the `KERNEL_API_KEY` environment variable. Because the SDKs read `KERNEL_API_KEY` from the environment by default, your app can call the Kernel API as itself without you managing a key.
@@ -102,7 +219,7 @@ If you need a credential whose lifetime you control (for CI, a persistent backen
## List and inspect API keys
-List keys to audit what exists. List and retrieve responses include `masked_key`, `project_id`, `project_name`, `created_by`, and expiry metadata, but they don't include the plaintext key.
+List keys to audit what exists. List and retrieve responses include `masked_key`, `project_id`, `project_name`, `created_by`, expiry metadata, and `policy` for scoped keys, but they don't include the plaintext key.
```typescript TypeScript
@@ -228,7 +345,7 @@ func main() {
## Rotate a key
-`rotate` issues a replacement key in a single call and keeps the old key working for a short grace period, so your workload can switch over without downtime. The new key copies the rotated key's name and project scope, and—like create—Kernel returns the plaintext `key` only once.
+`rotate` issues a replacement key in a single call and keeps the old key working for a short grace period, so your workload can switch over without downtime. The new key copies the rotated key's name, project scope, and policy, and—like create—Kernel returns the plaintext `key` only once.
Two optional parameters control the timing:
@@ -308,5 +425,8 @@ To cut over immediately instead of using a grace window, pass `expire_in_days: 0
| --- | --- | --- |
| `400 Bad Request` | The name is missing, `days_to_expire` is outside `1`-`3650`, `expire_in_days` is outside `0`-`3650`, or `project_id` is empty. | Send a name, choose a valid expiry, or omit `project_id` for an org-scoped key. |
| `400 Bad Request` (rotate) | `days_to_expire` is shorter than `expire_in_days`, so the new key would expire before the old key's grace window ends. | Raise `days_to_expire` or lower `expire_in_days`. |
+| `400 Bad Request` (policy) | A statement uses an unknown action, a malformed resource, or a resource outside the key's organization or project. | Check the action and resource against [Write a policy](#write-a-policy). A project-scoped key can't be granted `organizations/{org_id}/*`. |
+| `400 Bad Request` (expiry) | `expires_at` is in the past or combined with `days_to_expire`, or a scoped key would outlive the credential creating it. | Send one expiry field, no later than your own key's expiry. |
| `401 Unauthorized` | Kernel couldn't authenticate the request. | Set a valid `KERNEL_API_KEY`. |
-| `404 Not Found` | The project or API key doesn't exist, or the caller can't access it. | Check the ID. If you're using a project-scoped key, you can only rotate keys in that same project. |
+| `403 Forbidden` (`insufficient_scope`) | A scoped key is missing a permission or called an endpoint scoped keys can't use yet. | The message names the missing action and resource. Add it to the policy, or use an unscoped key. |
+| `404 Not Found` | The project, API key, or a resource named in the policy doesn't exist, or the caller can't access it. | Check the ID. If you're using a project-scoped key, you can only rotate keys in that same project. |
From aa0abe6b2dba6dc606c173e6b5c8ddb0f152ab4b Mon Sep 17 00:00:00 2001
From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com>
Date: Fri, 9 Oct 2026 19:49:33 +0000
Subject: [PATCH 2/3] Define scoped key actions once and tighten the section
---
info/api-keys.mdx | 88 ++++++++++++++++-------------------------------
1 file changed, 29 insertions(+), 59 deletions(-)
diff --git a/info/api-keys.mdx b/info/api-keys.mdx
index b1e1bff3..3f0eba08 100644
--- a/info/api-keys.mdx
+++ b/info/api-keys.mdx
@@ -90,53 +90,34 @@ func main() {
## Scope a key to specific resources
-By default, an API key can do anything in its organization or project. Add a `policy` when you create a key to limit it to specific actions on specific resources. A key with a policy is denied everything the policy doesn't grant.
+By default, an API key can do anything in its organization or project. Add a `policy` when you create a key to limit it to specific actions on specific resources. The key is denied everything the policy doesn't grant. Keys created without `policy` work exactly as before.
-Use scoped keys when you hand a credential to code you trust less than your backend, such as an AI agent, a customer-facing worker, or a short-lived job. Keys created without `policy` work exactly as before.
+Use scoped keys when you hand a credential to code you trust less than your backend, such as an AI agent or a short-lived job.
### Write a policy
-A policy is a list of allow statements. Each statement pairs one `action` with one `resource`:
+A policy is a list of statements, each pairing an `action` with a `resource`.
-| Resource | Matches |
-| --- | --- |
-| `organizations/{org_id}/*` | Everything in the organization. |
-| `projects/{project_id}/*` | Everything in the project. |
-| `projects/{project_id}/{kind}/{id}` | One browser, profile, vault, or proxy, for example `projects/proj_staging_9f3k/profiles/prof_7h2kq9x4m1`. |
-| `{kind}/{id}` | One object in the key's project, or anywhere in the organization for an org-scoped key. |
+A resource is one of:
+
+- `projects/{project_id}/*` or `organizations/{org_id}/*` for everything in a project or organization.
+- `projects/{project_id}/{kind}/{id}` for one object, where `kind` is `browsers`, `profiles`, `vaults`, or `proxies`.
-`kind` is `browsers`, `profiles`, `vaults`, or `proxies`. Kernel checks every exact ID when you create the key. IDs that don't exist return `404`, and resources outside the key's organization or project return `400`.
+An action is `{kind}:{verb}`, for example `browsers:use`:
-| Action | Allows |
+| Verb | Allows |
| --- | --- |
-| `browsers:create` | Create browsers. Grant it on a project or organization, not an ID. |
-| `browsers:use` | Inspect, connect to, and control browsers. |
-| `browsers:write` | Update browser configuration. |
-| `browsers:delete` | Delete browsers. |
-| `profiles:create` / `profiles:read` | Create profiles / view profile metadata. |
-| `profiles:use` | Attach profiles to browsers and export them. |
-| `profiles:write` / `profiles:delete` | Rename profiles and save browser state into them / delete profiles. |
-| `vaults:create` / `vaults:read` | Create vaults / view vault and item metadata. |
-| `vaults:use` | Fill credentials and run vault operations. |
-| `vaults:write` / `vaults:delete` | Create, update, and delete vault items / delete vaults. |
-| `proxies:create` / `proxies:read` | Create proxies / view proxy configuration. |
-| `proxies:use` | Select saved proxies for browsers and run health checks. |
-| `proxies:write` / `proxies:delete` | Rename proxies / delete proxies. |
-| `projects:read` / `organizations:read` | View project or organization metadata and limits. |
-
-Resources a browser references need their own grants:
-
-- **Creating a browser** needs `browsers:create` and `browsers:use` on its project or organization, because the response includes connection URLs.
-- **Profiles:** attaching a profile needs `profiles:use` on it. Saving changes back to it also needs `profiles:write`.
-- **Vaults:** linking a vault needs `vaults:use` on every vault.
-- **Proxies:** selecting a saved proxy needs `proxies:use` on it.
-- **Existing browsers:** a key can only use a browser if it can also use that browser's attached profile and vaults.
-
-List endpoints return only the resources the key can access, so a key without a matching grant gets an empty list rather than an error. A profile appears in `GET /profiles` only with `profiles:read`, so grant it alongside `profiles:use` if your code looks profiles up by listing them.
+| `create` | Create objects of that kind. Grant it on a project or organization, not an ID. Creating a browser also needs `browsers:use`, because the response includes connection URLs. |
+| `read` | View metadata. Lists only include objects the key can read, or for browsers, use. |
+| `use` | Control a browser, attach a profile, fill from a vault, or select a saved proxy. Using a browser also needs `use` on its attached profile and vaults. |
+| `write` | Update an object. For profiles, this includes saving browser state into them. For vaults, it covers creating, updating, and deleting items. |
+| `delete` | Delete an object. |
+
+Browsers support every verb except `read`. `projects:read` and `organizations:read` view project and organization metadata.
### Create a scoped key
-This key can create and control browsers in one project, only with profile `prof_7h2kq9x4m1` or no profile, and it expires in one hour:
+This key can create and control browsers in one project, only with profile `prof_7h2kq9x4m1` or no profile, and expires in one hour:
```typescript TypeScript
@@ -146,23 +127,20 @@ const kernel = new Kernel({
apiKey: process.env.KERNEL_API_KEY,
});
-const project = 'projects/proj_staging_9f3k';
-
const apiKey = await kernel.apiKeys.create({
name: 'checkout-agent-run-4812',
project_id: 'proj_staging_9f3k',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
policy: {
statements: [
- { action: 'browsers:create', resource: `${project}/*` },
- { action: 'browsers:use', resource: `${project}/*` },
- { action: 'profiles:use', resource: `${project}/profiles/prof_7h2kq9x4m1` },
+ { action: 'browsers:create', resource: 'projects/proj_staging_9f3k/*' },
+ { action: 'browsers:use', resource: 'projects/proj_staging_9f3k/*' },
+ { action: 'profiles:use', resource: 'projects/proj_staging_9f3k/profiles/prof_7h2kq9x4m1' },
],
},
});
console.log(apiKey.key); // Save this value now. Kernel won't show it again.
-console.log(apiKey.policy);
```
```python Python
@@ -173,37 +151,30 @@ from kernel import Kernel
client = Kernel(api_key=os.environ["KERNEL_API_KEY"])
-project = "projects/proj_staging_9f3k"
-
api_key = client.api_keys.create(
name="checkout-agent-run-4812",
project_id="proj_staging_9f3k",
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
policy={
"statements": [
- {"action": "browsers:create", "resource": f"{project}/*"},
- {"action": "browsers:use", "resource": f"{project}/*"},
- {"action": "profiles:use", "resource": f"{project}/profiles/prof_7h2kq9x4m1"},
+ {"action": "browsers:create", "resource": "projects/proj_staging_9f3k/*"},
+ {"action": "browsers:use", "resource": "projects/proj_staging_9f3k/*"},
+ {"action": "profiles:use", "resource": "projects/proj_staging_9f3k/profiles/prof_7h2kq9x4m1"},
],
},
)
print(api_key.key) # Save this value now. Kernel won't show it again.
-print(api_key.policy)
```
-To limit a key to vault `vlt_3m8q2r7k1x` instead, grant `vaults:use` on `projects/proj_staging_9f3k/vaults/vlt_3m8q2r7k1x`. Add `vaults:read` if the key also needs to read the vault's metadata.
-
### Expiry and revocation
-- `expires_at` sets an exact expiry time, so a key can live for minutes rather than whole days. It can't be combined with `days_to_expire`.
-- A scoped key can't outlive the credential that creates it. If your key expires, set `expires_at` or `days_to_expire` no later than that.
-- An expired key is rejected immediately. A deleted key is rejected within about 10 seconds.
-- Neither closes browser connections that are already open. Delete those browsers to cut off access immediately.
-- Rotating a scoped key gives the replacement the same policy.
+- Use `expires_at` for an exact expiry, such as minutes from now, instead of `days_to_expire`. A scoped key can't outlive the key that creates it.
+- An expired key is rejected immediately, and a deleted key within about 10 seconds. Neither closes browser connections that are already open, so delete those browsers to cut off access.
+- Rotating a scoped key keeps its policy.
-Scoped keys can't create, list, or delete API keys, and they get `403` with `insufficient_scope` on any endpoint that doesn't support scoped access yet. Manage keys with an unscoped key.
+Scoped keys can't manage API keys, and they get `403` on endpoints that don't support scoped access yet.
## Deployment API keys
@@ -425,8 +396,7 @@ To cut over immediately instead of using a grace window, pass `expire_in_days: 0
| --- | --- | --- |
| `400 Bad Request` | The name is missing, `days_to_expire` is outside `1`-`3650`, `expire_in_days` is outside `0`-`3650`, or `project_id` is empty. | Send a name, choose a valid expiry, or omit `project_id` for an org-scoped key. |
| `400 Bad Request` (rotate) | `days_to_expire` is shorter than `expire_in_days`, so the new key would expire before the old key's grace window ends. | Raise `days_to_expire` or lower `expire_in_days`. |
-| `400 Bad Request` (policy) | A statement uses an unknown action, a malformed resource, or a resource outside the key's organization or project. | Check the action and resource against [Write a policy](#write-a-policy). A project-scoped key can't be granted `organizations/{org_id}/*`. |
-| `400 Bad Request` (expiry) | `expires_at` is in the past or combined with `days_to_expire`, or a scoped key would outlive the credential creating it. | Send one expiry field, no later than your own key's expiry. |
+| `400 Bad Request` (scoped key) | The policy has an unknown action or a resource outside the key's organization or project, or the expiry is invalid or later than your own key's. | Check the policy against [Write a policy](#write-a-policy) and send one expiry field. |
| `401 Unauthorized` | Kernel couldn't authenticate the request. | Set a valid `KERNEL_API_KEY`. |
-| `403 Forbidden` (`insufficient_scope`) | A scoped key is missing a permission or called an endpoint scoped keys can't use yet. | The message names the missing action and resource. Add it to the policy, or use an unscoped key. |
+| `403 Forbidden` (`insufficient_scope`) | A scoped key is missing a permission. | The message names the missing action and resource. Add it to the policy. |
| `404 Not Found` | The project, API key, or a resource named in the policy doesn't exist, or the caller can't access it. | Check the ID. If you're using a project-scoped key, you can only rotate keys in that same project. |
From b45520225669216cb3a1fa2e9e4450b5aa09ac39 Mon Sep 17 00:00:00 2001
From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com>
Date: Fri, 9 Oct 2026 19:56:01 +0000
Subject: [PATCH 3/3] Move scoped key permissions to a reference page
---
docs.json | 1 +
info/api-key-permissions.mdx | 85 ++++++++++++++++++++++++++++++++++++
info/api-keys.mdx | 26 +++--------
3 files changed, 93 insertions(+), 19 deletions(-)
create mode 100644 info/api-key-permissions.mdx
diff --git a/docs.json b/docs.json
index 567cde9e..3e33ce15 100644
--- a/docs.json
+++ b/docs.json
@@ -266,6 +266,7 @@
"introduction/manage",
"info/projects",
"info/api-keys",
+ "info/api-key-permissions",
"info/audit-logs",
"info/network-access"
]
diff --git a/info/api-key-permissions.mdx b/info/api-key-permissions.mdx
new file mode 100644
index 00000000..4333580f
--- /dev/null
+++ b/info/api-key-permissions.mdx
@@ -0,0 +1,85 @@
+---
+title: "API Key Permissions"
+description: "Actions and resources you can grant in a scoped API key policy"
+---
+
+This page lists everything you can grant in a scoped API key's `policy`. To create a scoped key, see [Scope a key to specific resources](/info/api-keys#scope-a-key-to-specific-resources).
+
+## Resources
+
+| Resource | Matches |
+| --- | --- |
+| `organizations/{org_id}/*` | Everything in the organization. |
+| `projects/{project_id}/*` | Everything in the project. |
+| `projects/{project_id}/{kind}/{id}` | One object. `kind` is `browsers`, `profiles`, `vaults`, or `proxies`. |
+
+Resources must be inside the new key's organization, or its project for a project-scoped key. Kernel checks that every object ID exists when you create the key.
+
+## Actions
+
+### Browsers
+
+| Action | Allows |
+| --- | --- |
+| `browsers:create` | Create browsers. Grant it on a project or organization. |
+| `browsers:use` | List, inspect, connect to, and control browsers. |
+| `browsers:write` | Update browser configuration. |
+| `browsers:delete` | Delete browsers. |
+
+### Profiles
+
+| Action | Allows |
+| --- | --- |
+| `profiles:create` | Create profiles. Grant it on a project or organization. |
+| `profiles:read` | List profiles and view their metadata. |
+| `profiles:use` | Attach profiles to browsers and download them. |
+| `profiles:write` | Rename profiles and save browser state into them. |
+| `profiles:delete` | Delete profiles. |
+
+### Vaults
+
+| Action | Allows |
+| --- | --- |
+| `vaults:create` | Create vaults. Grant it on a project or organization. |
+| `vaults:read` | List vaults and view vault and item metadata. |
+| `vaults:use` | Link vaults to browsers, fill credentials, and run vault item operations. |
+| `vaults:write` | Create, update, and delete vault items. |
+| `vaults:delete` | Delete vaults and their items. |
+
+### Proxies
+
+| Action | Allows |
+| --- | --- |
+| `proxies:create` | Create proxies. Grant it on a project or organization. |
+| `proxies:read` | List proxies and view their configuration, without credentials. |
+| `proxies:use` | Select saved proxies for browsers and run health checks. |
+| `proxies:write` | Rename proxies. |
+| `proxies:delete` | Delete proxies. |
+
+### Projects and organizations
+
+| Action | Allows |
+| --- | --- |
+| `projects:read` | View project metadata and limits. |
+| `organizations:read` | View organization entitlements and limits. |
+
+## Operations that need more than one action
+
+| Operation | Requires |
+| --- | --- |
+| Create a browser | `browsers:create` and `browsers:use` on the project or organization, because the response includes connection URLs. Add `profiles:use` on its profile, `profiles:write` if it saves profile changes, `vaults:use` on each linked vault, and `proxies:use` on a saved proxy. |
+| Any operation on an existing browser | The browser action, plus `profiles:use` on its attached profile and `vaults:use` on every linked vault. If the browser saves profile changes, `profiles:write` on that profile too. |
+| Update a browser | `browsers:write` and `browsers:use`, plus `use` on any profile, vault, or proxy the update adds. |
+| Fill from a vault | `vaults:use` on the vault and `browsers:use` on the browser. |
+| Run a proxy health check | `proxies:use` and `proxies:read`. |
+
+List endpoints return only the objects the key can access, so a key without a matching grant gets an empty list.
+
+## Not available to scoped keys
+
+Scoped keys get `403` with `insufficient_scope` on:
+
+- API key management, including creating, listing, rotating, and deleting keys.
+- Browser pools.
+- Creating browsers that use saved extensions, telemetry export, or app invocations.
+- Any other endpoint not covered by the actions above.
diff --git a/info/api-keys.mdx b/info/api-keys.mdx
index 3f0eba08..92f2daf2 100644
--- a/info/api-keys.mdx
+++ b/info/api-keys.mdx
@@ -90,30 +90,18 @@ func main() {
## Scope a key to specific resources
-By default, an API key can do anything in its organization or project. Add a `policy` when you create a key to limit it to specific actions on specific resources. The key is denied everything the policy doesn't grant. Keys created without `policy` work exactly as before.
+By default, an API key can do anything in its organization or project. Add a `policy` when you create a key to limit what it can do. Keys created without `policy` work exactly as before.
Use scoped keys when you hand a credential to code you trust less than your backend, such as an AI agent or a short-lived job.
-### Write a policy
+### How policies work
-A policy is a list of statements, each pairing an `action` with a `resource`.
+A policy is a list of **statements**. Each statement allows one **action** on one **resource**, and the key is denied everything no statement allows.
-A resource is one of:
+- A **resource** is either one object, `projects/{project_id}/{kind}/{id}`, or everything in a container, `projects/{project_id}/*` or `organizations/{org_id}/*`.
+- An **action** is `{kind}:{verb}`, such as `browsers:use` or `vaults:read`.
-- `projects/{project_id}/*` or `organizations/{org_id}/*` for everything in a project or organization.
-- `projects/{project_id}/{kind}/{id}` for one object, where `kind` is `browsers`, `profiles`, `vaults`, or `proxies`.
-
-An action is `{kind}:{verb}`, for example `browsers:use`:
-
-| Verb | Allows |
-| --- | --- |
-| `create` | Create objects of that kind. Grant it on a project or organization, not an ID. Creating a browser also needs `browsers:use`, because the response includes connection URLs. |
-| `read` | View metadata. Lists only include objects the key can read, or for browsers, use. |
-| `use` | Control a browser, attach a profile, fill from a vault, or select a saved proxy. Using a browser also needs `use` on its attached profile and vaults. |
-| `write` | Update an object. For profiles, this includes saving browser state into them. For vaults, it covers creating, updating, and deleting items. |
-| `delete` | Delete an object. |
-
-Browsers support every verb except `read`. `projects:read` and `organizations:read` view project and organization metadata.
+Some operations need more than one action. For example, creating a browser with a profile needs `browsers:create` and `browsers:use` on the project, plus `profiles:use` on that profile. See [API key permissions](/info/api-key-permissions) for every action and what each operation requires.
### Create a scoped key
@@ -396,7 +384,7 @@ To cut over immediately instead of using a grace window, pass `expire_in_days: 0
| --- | --- | --- |
| `400 Bad Request` | The name is missing, `days_to_expire` is outside `1`-`3650`, `expire_in_days` is outside `0`-`3650`, or `project_id` is empty. | Send a name, choose a valid expiry, or omit `project_id` for an org-scoped key. |
| `400 Bad Request` (rotate) | `days_to_expire` is shorter than `expire_in_days`, so the new key would expire before the old key's grace window ends. | Raise `days_to_expire` or lower `expire_in_days`. |
-| `400 Bad Request` (scoped key) | The policy has an unknown action or a resource outside the key's organization or project, or the expiry is invalid or later than your own key's. | Check the policy against [Write a policy](#write-a-policy) and send one expiry field. |
+| `400 Bad Request` (scoped key) | The policy has an unknown action or a resource outside the key's organization or project, or the expiry is invalid or later than your own key's. | Check the policy against [API key permissions](/info/api-key-permissions) and send one expiry field. |
| `401 Unauthorized` | Kernel couldn't authenticate the request. | Set a valid `KERNEL_API_KEY`. |
| `403 Forbidden` (`insufficient_scope`) | A scoped key is missing a permission. | The message names the missing action and resource. Add it to the policy. |
| `404 Not Found` | The project, API key, or a resource named in the policy doesn't exist, or the caller can't access it. | Check the ID. If you're using a project-scoped key, you can only rotate keys in that same project. |