Skip to content

Give a tool a secret

A tool that needs an API key or a signing key declares it by name. Kindgi resolves it on every call, checks it, and hands it to the handler in the call's context. The value never appears in your code or your config.

This tool signs a receipt with a key:

tools/sign-receipt/index.ts
import { createHmac } from 'node:crypto';
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
const defined = defineTool({
id: 'my-pack.sign-receipt' as ToolId,
description: 'Signs an order receipt so the customer portal can verify it.',
version: '0.1.0',
input: z.object({ orderId: z.string(), totalCents: z.number().int() }),
output: z.object({ signature: z.string() }),
effects: [],
mutating: false,
needsSpec: {
secrets: { RECEIPT_SIGNING_KEY: { type: 'string', minLength: 32 } },
},
handler: async ({ orderId, totalCents }, ctx) => {
const key = ctx.secrets?.RECEIPT_SIGNING_KEY;
if (key === undefined) throw new Error('RECEIPT_SIGNING_KEY is not set');
const signature = createHmac('sha256', key).update(`${orderId}:${totalCents}`).digest('hex');
return { signature };
},
});
if (defined.kind === 'err') {
throw new Error(`my-pack.sign-receipt failed to compile: ${defined.error.message}`);
}
export default defined.value;
  • The declaration maps each secret's name to a JSON Schema for its value. A declared secret is required, unless its schema accepts null (see An optional secret). Each schema must compile as the runtime compiles it, strictly: a tool with an unknown keyword is refused when it's defined, registered or deployed.
  • On every call, Kindgi resolves each declared secret for the call's tenant, in the environment the runtime serves (KINDGI_ENV; under kindgi dev, local: the pack's env files), and checks it against its schema. If one is missing or doesn't fit, the call fails before the handler runs.
  • The context holds only what the tool declares. ctx.secrets is optional in the TypeScript type because a unit test builds its own context.

A tool that can do without a secret declares it with a schema that accepts null: { "type": ["string", "null"] }. Here my-pack.sign-receipt also stamps the receipt when a stamp key is set. When the env doesn't have it, or has it empty, it's left out of ctx.secrets, and the call goes on. The call's log line names it:

tool my-pack.sign-receipt: optional secret RECEIPT_STAMP_KEY not set in env "local"; the call goes on without …

A value that is set is still checked against the schema. Only a secret never stored, or an empty one, counts as not set: a revoked secret, one whose value is gone at its provider though it's still mapped, or a secrets backend that fails, still fails the call. The schema has to name null: an unconstrained {} stays required.

needsSpec: {
secrets: {
RECEIPT_SIGNING_KEY: { type: 'string', minLength: 32 },
RECEIPT_STAMP_KEY: { type: ['string', 'null'], minLength: 32 }, // optional
},
},
handler: async (receipt, ctx) => {
const stampKey = ctx.secrets?.RECEIPT_STAMP_KEY; // undefined when not set
// …
},

Until the secret exists, calls fail and name it:

"status": "failed",
"failureMessage": "handler-error: Tool \"my-pack.sign-receipt\" handler threw: secret-unavailable: tool \"my-pack.sign-receipt\" needs secret \"RECEIPT_SIGNING_KEY\" in env \"local\": No secret \"RECEIPT_SIGNING_KEY\" for env \"local\" in .env, .env.local, .kindgi/secrets.env at /pack",

Store a value. kindgi secrets set prompts for it without echoing; here it reads it from stdin:

Terminal window
openssl rand -hex 32 | tr -d '\n' | pnpm exec kindgi secrets set RECEIPT_SIGNING_KEY --env=local --scope=tenant --from-stdin
Set RECEIPT_SIGNING_KEY at tenant in local.

Under kindgi dev that writes it to Kindgi's own .kindgi/secrets.env. The next call gets it; nothing restarts. Run the tool (here from my-pack.sign, a one-step flow):

Terminal window
kindgi runs start --flow=my-pack.sign --input='{"orderId":"ord_1001","totalCents":4200}'
"status": "completed",
…
"output": {
"signature": "b02d6ae82a21be7754b7c605ae55210fdb2d6c14ca25f03b56c0d62c6f27e595"
},

A value that doesn't fit the schema fails the call too, without the value in the message:

"failureMessage": "handler-error: Tool \"my-pack.sign-receipt\" handler threw: secret-invalid: secret \"RECEIPT_SIGNING_KEY\" in env \"local\" doesn't match the schema tool \"my-pack.sign-receipt\" declares for it: must NOT have fewer than 32 characters",

To replace a value, set it again with --write-mode=add-version; without it, set refuses a name that exists. Store a secret covers kindgi secrets.

A test passes the secret in the context it builds:

tools/sign-receipt/index.test.ts
import { invokeTool } from '@kindgi/sdk/define';
import type { TenantId } from '@kindgi/sdk/types';
import { expect, test } from 'vitest';
import signReceipt from './index.js';
test('signs with the key from the context', async () => {
const result = await invokeTool(
signReceipt,
{ orderId: 'ord_1001', totalCents: 4200 },
{
tenantId: 'test-tenant' as TenantId,
abortSignal: new AbortController().signal,
secrets: { RECEIPT_SIGNING_KEY: 'k'.repeat(32) },
},
);
expect(result.kind).toBe('ok');
});

A model provider's key is used by its provider only. A tool can't declare or send a secret that a provider registration of the tenant names, in any environment. Here the tenant has Anthropic registered from its preset (key ANTHROPIC_API_KEY), and a tool, acme.notes.summarize, declares ANTHROPIC_API_KEY too.

Under kindgi dev, the tool loads, and each call fails without running it:

"status": "failed",
"failureMessage": "precondition-failed: Tool \"acme.notes.summarize\" was not run: provider-key-refused: `ANTHROPIC_API_KEY` is the key of model provider \"anthropic\": a tool never gets a model provider's key. If it needs to call a model itself, store the key under its own name (the same value is fine) and use that name",

On a deployed runtime, it isn't registered at all. POST /v1/tools answers 400, a deploy that brings the tool fails with a deployment-validation-failed issue for it, and reinstating a retired version that names the key is refused too, all in the same words:

{"error":{"code":"provider-key-refused","message":"`ANTHROPIC_API_KEY` is the key of model provider \"anthropic\": a tool never gets a model provider's key. If it needs to call a model itself, store the key under its own name (the same value is fine) and use that name","details":{"secret":"ANTHROPIC_API_KEY","providerId":"anthropic"},"requestId":"req-…"}}

So a pack you install can't take your model key. A tool that calls a model itself declares a key under a name of its own, and you store the key there, the same value if you like:

Terminal window
kindgi secrets set SUMMARIZER_ANTHROPIC_KEY --env=local --scope=tenant
Set SUMMARIZER_ANTHROPIC_KEY at tenant in local.

With SUMMARIZER_ANTHROPIC_KEY in its needsSpec.secrets, the same call completes:

"status": "completed",
…
"summary": "Ship the provider-key guard in 0.1.6."

The same holds for an MCP endpoint's and a webhook endpoint's secretRef.

The pack service is one process for every tenant your runtime serves, so its environment can't hold a key that belongs to one of them. Keep process.env / os.environ for your app's own settings (a service URL, a database your app owns), and declare them as the pack's environment: Declare the environment your code reads.

An HTTP tool names its credential in authorization.secretRef instead, and Kindgi adds it to the request.