Primitives

Vault

Secrets injected by reference: use them without ever reading them.

A vault lets an agent call authenticated APIs without ever seeing the credentials. You store a secret in a vault and grant it a narrow policy. The agent refers to the secret by name, and the engine injects the value into one outgoing HTTPS request, then redacts it from the response. The value never enters the agent's context, tool results or logs.

Creating a vault

A vault is a primitive inside a group with an immutable id and a name, and it holds named secrets. A group can hold as many vaults as you need, for example prod and staging, or one per integration, and each has its own grants.

const vault = await group.vault.create('prod');  // 409 already_exists if taken; vault.id is its id
const staging = group.vault('staging');          // by name in the group, resolved on first use
const same = p.vault(vault.id);                  // the same vault, by id
vault = await group.vault.create("prod")

From the CLI: primitives primitive create prod --type vault --group agent-42 (prints the id).

Storing a secret

Secrets are stored by an account caller (your server with a workspace pk_ key, or primitives dev on localhost). Scoped API keys can't store, delete or grant secrets.

await vault.set('GITHUB_TOKEN', process.env.GITHUB_TOKEN!);
await vault.set("GITHUB_TOKEN", os.environ["GITHUB_TOKEN"])

Names match ^[A-Z][A-Z0-9_]{0,63}$, values are up to 64 KiB, and writing an existing name replaces the value and bumps its version. No API returns a value. vault.unset(name) removes a secret and its grant; vault.delete() deletes the whole vault.

Values are encrypted with XChaCha20-Poly1305 under a data key per group, which is wrapped by the engine's master key. Locally the master key comes from PRIMITIVES_MASTER_KEY, your OS keychain, or <data>/.master-key.

Granting

A secret can't be used until it has a grant. A grant says which hosts and methods the secret may be sent to, and for how long:

vault.ts
await vault.grant('GITHUB_TOKEN', {
  allow: { hosts: ['api.github.com'], methods: ['GET', 'POST'] },
  ttl: '15m',
});
vault.py
await vault.grant(
    "GITHUB_TOKEN",
    allow={"hosts": ["api.github.com"], "methods": ["GET", "POST"]},
    ttl="15m",
)
FieldDescription
allow.hostsHost patterns the secret may be sent to. Required. Wildcards such as *.github.com are allowed; no schemes, ports or paths.
allow.methodsAllowed HTTP methods. Empty or omitted means every method.
ttlLifetime of the grant, such as '15m'. Omit it for a grant that doesn't expire. After it expires, uses fail with 410 secret_grant_expired.

Each secret has at most one grant: granting again replaces it.

const items = await vault.list();     // [{ name, version, created_at, updated_at, grant }], never values
const grants = await vault.grants();  // [{ name, allow, expires_at, created_at }]
await vault.revoke('GITHUB_TOKEN');   // remove the grant; the secret stays

Using a secret

vault.fetch makes the request on the engine, with the secret injected:

const res = await vault.fetch('https://api.github.com/user', {
  auth: { bearer: 'GITHUB_TOKEN' },
  headers: { accept: 'application/vnd.github+json' },
});
// { status: 200, headers: { … }, body: '{"login":…}', body_encoding: 'utf8', truncated: false }
const user = JSON.parse(res.body);
res = await vault.fetch("https://api.github.com/user", auth={"bearer": "GITHUB_TOKEN"})
user = json.loads(res.body)

Options are method (default GET), headers, body (a string is sent as is; other JSON is serialized and sent as application/json) and auth:

authInjected as
{ bearer: 'NAME' }Authorization: Bearer <value>
{ header: { name: 'X-Api-Key', secret: 'NAME' } }A custom header
{ basic: { username: 'me', secret: 'NAME' } }Authorization: Basic base64(me:<value>)
{ query: { param: 'api_key', secret: 'NAME' } }A query parameter

The request is checked against the grant before any value is decrypted. If there is no grant, or the host or method isn't allowed, it fails with 403 secret_policy_denied and nothing is sent. A secret that doesn't exist fails the same way.

What comes back

  • Every occurrence of the value in the response headers and body, including its base64, Basic and URL-encoded forms, is replaced with [REDACTED].
  • Set-Cookie headers are dropped. Redirects are not followed.
  • Bodies over 1 MiB are cut and truncated is true. Non-UTF-8 bodies are base64 with body_encoding: 'base64'.
  • Upstream failures and timeouts (30 s) are 502 upstream_error.

Guard rails

  • The caller can't set Authorization, Proxy-Authorization, Cookie, Host or the header named in auth.
  • URLs must be https and can't carry credentials.
  • In the cloud, destinations that resolve to private, loopback, link-local or other reserved addresses (including cloud metadata endpoints) are refused, and the request connects only to the addresses that were checked. primitives dev allows private networks and plain http so you can test against local services.

Forks

Forking a vault copies every secret and grant, re-encrypted under the target group's key. The target group must exist, or the fork fails with 404 not_found. Moving a vault with vault.moveTo(group) re-encrypts it the same way and keeps its id. Forking is explicit, so nothing else opts a sub-agent into your credentials: to give an untrusted sub-agent no credentials, fork only the primitives it needs into a new group and leave the vault out.

await vault.fork({ group: 'agent-42-trusted', name: 'prod' }); // the sub-agent's group gets its own copy, with a new id

A vault can be snapshotted, restored and paused on its own like any other primitive.

Audit

Every use is recorded as vault.use with the host, method, path and upstream status, and every refusal as vault.denied with the reason. Setting, deleting, granting and revoking are audited too. Values never are. Read the trail with group.audit().

MCP tools

ToolRead-onlyArguments
vault_listyesprimitive?
vault_usenoprimitive?, url, auth, method?, headers?, body?

primitive is the vault to use: its id or its name from primitive_list. It can be left out when the group has exactly one. No MCP tool returns a secret's value. See Auth & scoping.