Secrets
How Kortix stores project credentials and controls where each value is allowed to go.
A secret is a per-project credential — an API key, token, or connection string — that a session needs but that must not live in the repository. Kortix stores secrets on the project, never on the account, and encrypts every value with AES-256-GCM using a key derived per project.
Identifier and name
Each secret has two names. The identifier is the handle you use in the
CLI and in an agent's secrets grant. The name is the uppercase
environment-variable key injected into the sandbox, for example
STRIPE_API_KEY. In most projects the identifier and the name match. They
differ only when a project holds several candidate values for one name —
for example, a primary and a backup Google Maps key can both resolve to
GOOGLE_MAPS_API_KEY.
Every access rule uses the identifier. None of them uses the name.
Shared and personal scope
A secret is either shared or a personal override:
- Shared — the project-wide value. Every project member with read access sees every shared secret. Kortix has no per-member sharing control for a single secret.
- Personal override — your own value for one name, used instead of the shared row for sessions you start. Today Kortix uses this only for one OAuth login credential.
Kortix never lets an LLM provider key, for example ANTHROPIC_API_KEY,
become a personal override. The model gateway always reads the shared row.
An agent only receives the secrets its manifest grants. In kortix.yaml,
the agent's secrets field lists identifiers, or is empty to deny all —
the default when you omit it. See Agents.
Delivery
Delivery decides where the value is allowed to go. Every secret has one delivery mode. The default is Sandbox.
| Delivery | The sandbox receives | Use it for |
|---|---|---|
| Sandbox | The plaintext value, as an environment variable | A local process or script that must read the value |
| Kortix service | Nothing | The LLM gateway, a connector, Git, or one policy-bound HTTPS request Kortix makes for you |
| Network boundary | Nothing | An ordinary HTTP client in the sandbox that calls one known host over HTTPS |
| Disabled | Nothing | Keeping a value on file without letting anything use it |
Sandbox is the only mode that puts plaintext in the sandbox. Agent code can read, print, and forward it. Every other mode keeps the value outside.
Change the mode in the project's Secrets page, or from the CLI:
kortix secrets delivery STRIPE_API_KEY deniedSession-scoped delivery needs a named agent grant
Network boundary and the HTTPS broker attach a credential to one running
session. For those two modes an agent's secrets list must name the identifier
explicitly. secrets: all does not count, and a project with no agents: block
in kortix.yaml never receives one. The Secrets page can write that grant for
you — see Grant a secret to an agent.
Add a secret
Set the value from the CLI
kortix secrets set STRIPE_API_KEY=sk_live_...Kortix saves it as a shared secret for the project. To store more than one
value under the same name, add --identifier <id>. Names can't start with
KORTIX_ — Kortix reserves that prefix for platform values.
Or use the dashboard
Open the project's Secrets page, enter the key and value, and save. Kortix encrypts the value immediately.
Grant it to an agent
Pick an agent on the Secrets page, or add the identifier to that agent's
secrets list in kortix.yaml yourself. A session only receives the secrets
its agent is granted. See Grant a secret to an
agent.
Grant a secret to an agent
A session receives a secret only when the agent it runs names the identifier in
its secrets list. Matching uses the identifier, not the name, and ignores
case.
From the dashboard
The Secrets page marks a secret no agent can receive: No agent can receive
this secret. Choose an agent there and confirm. Kortix edits kortix.yaml and
commits it as chore(agents): grant <IDENTIFIER> to <agent>.
The grant works whether or not the manifest already declares that agent. An
agent the manifest does not declare gets a new entry holding this one secrets
list. An agent that is already declared keeps every other field — model, tools,
connectors — and the identifier joins its existing list. An agent that already
admits the identifier needs no commit, and Kortix makes none.
An agent on secrets: all is a special case. all cannot carry a
session-scoped secret, so Kortix writes an explicit list: every identifier the
project has today, plus this one. Nothing the agent receives today changes. A
secret you add later needs its own grant.
Two cases refuse the grant:
- A project on
kortix_version: 1(kortix.toml) has no agents map to edit. The request fails with400andmanifest_v1_unsupported. Edit the manifest by hand, or move the project tokortix_version: 2. - A secret whose delivery is Disabled has nothing to deliver. The request
fails with
409andsecret_not_grantable. Choose a delivery mode first.
If the project has no agents: block yet, read The first agents: block
changes the whole
project before you confirm.
That one edit changes secret access for every other agent.
By hand
The same grant, written directly:
kortix_version: 2
agents:
my-agent:
secrets: [STRIPE_API_KEY]The first agents: block changes the whole project
Declaring one agent denies the rest
A project with no agents: block — or with no kortix.yaml at all — is
ungoverned: every agent receives every Sandbox secret, and no agent receives a
session-scoped one.
The moment the project declares its first agent, every agent that is not listed receives no project secret at all — including Sandbox secrets that worked a minute earlier. Listing one agent revokes the rest.
So list every agent that needs secrets, not only the one you are fixing. This
is why the dashboard asks you to confirm the first time: after that commit,
agents: is the project's allow-list, and an agent missing from it runs with no
project secrets.
List your secrets
Run kortix secrets ls to see which secrets a project declares and which
ones have a value set.
The list is configuration metadata. It never returns secret values. A scoped
agent token sees only identifiers in its agent grant. A session-specific
secrets_allowlist controls delivery into that session, but it does not hide
configuration metadata that the agent grant permits.
Rotate a secret
Set a new value
Run the same command with the new value, or set it again on the project's Secrets page:
kortix secrets set STRIPE_API_KEY=sk_live_new...Kortix pushes it to running sessions
Kortix pushes the new value to every sandbox with an active session for the project, on a best-effort basis. For model or gateway credentials, Kortix restarts the OpenCode compatibility process.
Remove a secret
Run kortix secrets unset STRIPE_API_KEY (or unset <identifier>), or
delete it from the project's Secrets page.
Removal is immediate, propagation is not
Kortix deletes a shared secret right away. Push to already-running sandboxes is best-effort, the same as rotation.
Share a value without seeing it
Run kortix secrets request STRIPE_API_KEY to create a link. Anyone with the
link can enter the value. You never see it. Links stay valid for 7 days by
default; adjust with --expires <minutes> (max 30 days). An expired link shows
a clear "expired" page — mint a fresh one with the same command.
Network boundary
Network boundary is the strictest delivery mode. Kortix registers the value with the Platinum sandbox provider. Platinum adds the value to matching outbound requests at its own egress edge, after the request has left the sandbox.
The sandbox never receives the value. It is not an environment variable, it is not a file, and no alias or placeholder stands in for it. Grepping the sandbox environment for the identifier returns nothing. The agent writes an ordinary request with no credential in it, and the header appears in flight.
Set it up
Three things must be true, or the header is silently missing
Nothing inside the sandbox can detect a wrong setup. The request leaves without
the header, and the upstream API answers 401.
Pin the project to Platinum
Only Platinum injects at the boundary. Open Customize → Feature flags → Sandbox provider and set it to Platinum. "Automatic" is not enough: it follows the platform default, which can place a session on another provider where nothing injects the header.
New sessions use the pinned provider. A session that is already running keeps the provider it started on.
Name the secret in an agent's secrets list
kortix_version: 2
agents:
my-agent:
secrets: [STRIPE_API_KEY]The list holds identifiers, and matching ignores case. The Secrets page writes
this grant for you from the secret's warning — read Grant a secret to an
agent first, because a project's first agents:
block changes secret access for every agent in the project.
secrets: all does not work here. It grants Sandbox delivery only, and a
network-boundary secret behaves exactly as if no grant existed. A project with
no agents: block in kortix.yaml delivers nothing here until one exists.
Start the session with an agent whose list names the secret.
Write the header value template
The template is what Platinum puts in the header. {{secret}} is where the
value goes.
| Template | Header sent |
|---|---|
Bearer {{secret}} | authorization: Bearer sk_live_... |
{{secret}} | x-api-key: sk_live_... |
| (blank) | authorization: sk_live_... |
A blank template sends the bare value with no scheme. That is correct for
x-api-key. It is wrong for a bearer token, and the API answers 401. Write
Bearer {{secret}} when the API expects one.
From the CLI, the same configuration is one command:
kortix secrets delivery STRIPE_API_KEY egress \
--allow-host api.stripe.com \
--inject-header authorization \
--template 'Bearer {{secret}}'Hosts match exactly
List every host you call, one exact hostname per line. There is no pattern matching, and the boundary accepts nothing narrower than a host.
| Rejected | Reason |
|---|---|
A wildcard host, *.example.com | The boundary needs exact hosts |
A path, api.example.com/v1 | Platinum cannot enforce path restrictions |
A method filter, POST only | Platinum cannot enforce method restrictions |
api.example.com does not cover uploads.api.example.com. Add the second host
to the same secret, or use a second secret. Kortix rejects an unenforceable
policy with 400 when you save it — it never stores a rule it cannot apply.
Use Kortix service delivery with the HTTPS broker consumer when you need wildcards, paths, or method filters. Kortix makes that request itself, so it can enforce controls Platinum cannot.
One header per host
A host and a header can be claimed by one secret only. Saving a second secret
that targets the same pair fails with 409 and
secret_boundary_destination_conflict, naming the secret that already holds it.
Two secrets on the same host with different headers are fine. Platinum injects both.
HTTPS only
Platinum has to terminate TLS to rewrite a header, so a policy host must be called over HTTPS. Plain HTTP is refused before the request leaves:
egress to "api.stripe.com" is blocked: this sandbox's policy puts a secret in a
request header for this host, and that requires HTTPSThat message is a policy refusal, not a network fault.
Verify it with two probes
An echo service is the worst possible test target
If a response would send the secret back into the sandbox, Platinum kills the
connection. curl reports curl: (52) Empty reply from server. A correct setup
therefore looks exactly like a broken one when you test against an endpoint that
echoes request headers.
Run both probes from inside the sandbox, against a host that is in the policy.
The example below uses postman-echo.com, which serves one endpoint of each
kind. Add it as an allowed host for the duration of the test.
# 1. Reachability — an endpoint that does NOT echo request headers.
curl -s -o /dev/null -w '%{http_code}\n' https://postman-echo.com/status/200
# expected: 200
# 2. Injection — an endpoint that DOES echo request headers.
curl -sS https://postman-echo.com/get
# expected: curl: (52) Empty reply from serverRead the pair together. Probe 1 first — it is the only one that tells you the host is reachable.
| Probe 1 | Probe 2 | Meaning |
|---|---|---|
200 | curl: (52) | Working. The header was injected and the echo guard blocked the reply. |
200 | 200, and the echoed headers show no credential | The header was not injected. Re-check the three prerequisites. |
| anything else | — | The host is not reachable. Probe 2 proves nothing until this passes. |
Also confirm the value never reached the guest:
env | grep -c STRIPE_API_KEY
# expected: 0A policy host presents Platinum's per-sandbox certificate, which the sandbox already trusts:
curl -sv https://postman-echo.com/status/200 2>&1 | grep 'issuer:'
# issuer: O=Platinum; CN=Platinum egress proxy (sandbox sbx_...)A host that is not in the policy passes through untouched and presents its own origin certificate.
End-to-end example
kortix.yaml:
kortix_version: 2
default_agent: my-agent
agents:
my-agent:
secrets: [STRIPE_API_KEY]Secret configuration:
kortix secrets set STRIPE_API_KEY=sk_live_...
kortix secrets delivery STRIPE_API_KEY egress \
--allow-host api.stripe.com \
--inject-header authorization \
--template 'Bearer {{secret}}'The agent then calls Stripe with no credential of its own:
curl -s -o /dev/null -w '%{http_code}\n' https://api.stripe.com/v1/customers
# expected: 200The same request from a session on another provider, or from an agent whose
secrets list omits STRIPE_API_KEY, returns 401. Nothing else changes.
CLI commands
| Command | What it does |
|---|---|
kortix secrets ls | List secrets declared and set for the project |
kortix secrets set KEY=VALUE [--identifier <id>] | Create or update a secret. KEY=- reads the value from stdin |
kortix secrets unset IDENTIFIER | Remove a secret |
kortix secrets delivery IDENTIFIER runtime|broker|egress|denied | Set the delivery mode and its policy |
kortix secrets call IDENTIFIER URL | Send one HTTPS broker request |
kortix secrets sync | Re-push project secrets to this session's sandbox |
kortix secrets request NAME [--scope runtime|connector] [--expires <min>] | Create a link so someone else can enter a value |
kortix env push --from <path> | Upload a .env file as secrets |
kortix env pull [--out <path>] [--force] | Export secret names, not values, to a .env file |
Run kortix secrets --help for every delivery flag.
Names and permissions
Format rules for the two names:
| Name | Format |
|---|---|
identifier | ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ |
name | ^[A-Z_][A-Z0-9_]{0,63}$ |
Reading or writing a secret needs the project.secret.read or
project.secret.write permission. A custom project role can withhold
either permission, even from a manager.
REST routes
All routes sit under /v1/projects/{projectId}.
| Method | Path | Description |
|---|---|---|
| GET | /secrets | List secrets. Scoped to the caller's grant if the caller is a scoped agent token. |
| POST | /secrets | Create or update the shared value. Body: {name, identifier?, value}, plus an optional delivery policy. |
| PUT | /secrets/{identifier}/strategy | Change the delivery mode and its policy. |
| POST | /secrets/{identifier}/grant | Add the identifier to one agent's secrets list in kortix.yaml. Body: {agent}. |
| DELETE | /secrets/{name} | Delete the shared value. Personal overrides stay in place. |
| PUT | /secrets/{name}/personal | Set or turn on the caller's personal override. |
| DELETE | /secrets/{name}/personal | Remove the caller's personal override. |
POST /secrets rejects names that start with KORTIX_. It returns 409
if the identifier already exists with a different name. It rejects the
exact name CODEX_AUTH_JSON with 400 — Kortix manages that secret
through ChatGPT subscription onboarding.
Both write routes return a delivery_sync object when the change had to reach
running sandboxes. ok: false means the value is saved but at least one live
session still uses the previous one; the listed sessions pick it up on restart.
A secret in the list carries delivery_blocked_reason. The value
no_agent_grant means no agent can receive this secret. null means it is
granted, the mode needs no grant, or Kortix could not read the manifest.
POST /secrets/{identifier}/grant clears that reason. It returns
already_granted: true when the agent's list already admits the identifier, in
which case Kortix commits nothing. It returns adopted_governance: true when
the edit added the project's first agents: block — the change described
above. It answers 400 manifest_v1_unsupported
for a kortix.toml project and 409 secret_not_grantable for a disabled
secret.
Rotation and propagation
A secret write does not wait for a session restart. Kortix pushes the change to every active sandbox in the project:
- Kortix builds a new environment snapshot, using the running agent's
secretsgrant. - The sandbox writes the snapshot to the live agent environment. New tool calls pick up the change right away.
- If the changed secret is an LLM provider credential, Kortix restarts OpenCode.
This push is best-effort. The API call that changes the secret returns before the push finishes. A failed push is only logged, not retried. A sandbox with a failed push keeps the old value until the next successful push, or until the session restarts.
Rotating a network-boundary secret updates the Platinum replica in place. The sandbox does not restart, because it never held the value.
Model credentials
A project on Kortix's managed model access needs no key of its own. To bring your own, set the provider variables your OpenCode provider config references.
Do not use a generic provider verification result as runtime proof. It cannot prove the selected model, region, entitlement, and API dialect. Send a real prompt through the exact model.
