Give a tool a secret
This guide gives a tool an API key or a signing key, so its handler gets the value on every call without it appearing in your code or config.
Before you start
Section titled “Before you start”- A pack, on a runtime such as
kindgi dev, with Kindgi 0.1's CLI. This guide uses the quickstart's pack,my-pack. - A flow that runs the tool, to try it. This guide uses
my-pack.sign, a one-step flow. openssl, to make a key for the example.
Declare the secret
Section titled “Declare the secret”A tool that needs a key declares it by name. Kindgi resolves it on every call, checks it, and hands it to the handler in the call's context.
Ask your coding agent: "I want a tool that signs an order receipt with a key, so our customer portal can verify it." Kindgi's skills in your pack tell it how (How it knows Kindgi). You store the key itself, as Store the secret shows. For reference, the tool looks like this:
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;import hashlibimport hmac
from pydantic import BaseModel, Field
from kindgi import ToolContext, tool
class Receipt(BaseModel): order_id: str = Field(alias="orderId") total_cents: int = Field(alias="totalCents")
class Signed(BaseModel): signature: str
@tool( id="my-pack.sign-receipt", mutating=False, needs_spec={"secrets": {"RECEIPT_SIGNING_KEY": {"type": "string", "minLength": 32}}},)def sign_receipt(receipt: Receipt, ctx: ToolContext) -> Signed: """Signs an order receipt so the customer portal can verify it.""" key = ctx.secrets["RECEIPT_SIGNING_KEY"].encode() message = f"{receipt.order_id}:{receipt.total_cents}".encode() return Signed(signature=hmac.new(key, message, hashlib.sha256).hexdigest())package mypack.tools;
import com.kindgi.pack.Tool;import java.nio.charset.StandardCharsets;import java.util.HexFormat;import java.util.Map;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
public final class SignReceipt { public record Input(String orderId, long totalCents) {}
public record Output(String signature) {}
public static final Tool<Input, Output> TOOL = Tool.define("my-pack.sign-receipt") .description("Signs an order receipt so the customer portal can verify it.") .version("0.1.0") .input(Input.class) .output(Output.class) .mutating(false) .set("needsSpec", Map.of( "secrets", Map.of("RECEIPT_SIGNING_KEY", Map.of("type", "string", "minLength", 32)))) .handler((input, ctx) -> { Object key = ctx.secrets().get("RECEIPT_SIGNING_KEY"); if (key == null) throw new IllegalStateException("RECEIPT_SIGNING_KEY is not set"); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key.toString().getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] signed = mac.doFinal((input.orderId() + ":" + input.totalCents()).getBytes(StandardCharsets.UTF_8)); return new Output(HexFormat.of().formatHex(signed)); });
private SignReceipt() {}}needsSpec goes in with set("needsSpec", …), as the API names it.
ctx.secrets() holds the declared secrets, by name.
package mypack.tools
import com.kindgi.pack.scaladsl._import java.nio.charset.StandardCharsets.UTF_8import java.util.HexFormatimport javax.crypto.Macimport javax.crypto.spec.SecretKeySpec
object SignReceipt { final case class Input(orderId: String, totalCents: Long) final case class Output(signature: String)
val tool: Tool[Input, Output] = Tool[Input, Output]("my-pack.sign-receipt") .description("Signs an order receipt so the customer portal can verify it.") .version("0.1.0") .readOnly .set("needsSpec", Map( "secrets" -> Map("RECEIPT_SIGNING_KEY" -> Map("type" -> "string", "minLength" -> 32)))) .handler { (in, ctx) => val key = Option(ctx.secrets.get("RECEIPT_SIGNING_KEY")) .getOrElse(throw new IllegalStateException("RECEIPT_SIGNING_KEY is not set")) val mac = Mac.getInstance("HmacSHA256") mac.init(new SecretKeySpec(key.toString.getBytes(UTF_8), "HmacSHA256")) Output(HexFormat.of().formatHex(mac.doFinal(s"${in.orderId}:${in.totalCents}".getBytes(UTF_8)))) }}needsSpec goes in with set("needsSpec", …), as the API names it.
ctx.secrets holds the declared secrets, by name.
- The declaration maps each secret's name to a JSON Schema for its value.
A declared secret is required, unless its schema accepts
null, as An optional secret shows. 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, and checks it against its schema. It reads it in the env the
runtime serves, which
KINDGI_ENVnames. Underkindgi dev, the env islocal, and the secrets are in the pack's env files. If one is missing or doesn't fit, the call fails before the handler runs. - The context holds only what the tool declares.
ctx.secretsis optional in the TypeScript type because a unit test builds its own context.
An optional secret
Section titled “An optional secret”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.
Ask your coding agent: "Also stamp the receipt when a stamp key is set." For reference, the change looks like this:
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 // …},needs_spec={ "secrets": { "RECEIPT_SIGNING_KEY": {"type": "string", "minLength": 32}, "RECEIPT_STAMP_KEY": {"type": ["string", "null"], "minLength": 32}, # optional }}# …stamp_key = ctx.secrets.get("RECEIPT_STAMP_KEY") # None when not set.set("needsSpec", Map.of("secrets", Map.of( "RECEIPT_SIGNING_KEY", Map.of("type", "string", "minLength", 32), "RECEIPT_STAMP_KEY", Map.of("type", List.of("string", "null"), "minLength", 32)))) // optional.handler((receipt, ctx) -> { Object stampKey = ctx.secrets().get("RECEIPT_STAMP_KEY"); // null when not set // …});.set("needsSpec", Map("secrets" -> Map( "RECEIPT_SIGNING_KEY" -> Map("type" -> "string", "minLength" -> 32), "RECEIPT_STAMP_KEY" -> Map("type" -> List("string", "null"), "minLength" -> 32)))) // optional.handler { (receipt, ctx) => val stampKey = Option(ctx.secrets.get("RECEIPT_STAMP_KEY")) // None when not set // …}Store the secret
Section titled “Store the secret”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 it. Here
it reads it from stdin instead:
openssl rand -hex 32 | tr -d '\n' | pnpm exec kindgi secrets set RECEIPT_SIGNING_KEY --env=local --scope=tenant --from-stdinopenssl rand -hex 32 | tr -d '\n' | uv run kindgi secrets set RECEIPT_SIGNING_KEY --env=local --scope=tenant --from-stdinopenssl rand -hex 32 | tr -d '\n' | ./kindgiw secrets set RECEIPT_SIGNING_KEY --env=local --scope=tenant --from-stdinopenssl rand -hex 32 | tr -d '\n' | ./kindgiw secrets set RECEIPT_SIGNING_KEY --env=local --scope=tenant --from-stdinIt prints the secret's name, scope, environment and version as JSON, then:
… Set RECEIPT_SIGNING_KEY at tenant in local.openssl rand -hex 32 | tr -d '\n'makes a 64-character key, without a newline.--envis the env the runtime serves. Underkindgi dev, it'slocal.--scope=tenantstores it for the whole tenant.--from-stdinreads the value from the pipe instead of prompting.
Under kindgi dev, that writes it to Kindgi's own .kindgi/secrets.env. The
next call gets it, and nothing restarts. Run the tool from my-pack.sign.
--flow is the flow, and --input its input:
pnpm exec kindgi runs start --flow=my-pack.sign --input='{"orderId":"ord_1001","totalCents":4200}'uv run kindgi runs start --flow=my-pack.sign --input='{"orderId":"ord_1001","totalCents":4200}'./kindgiw runs start --flow=my-pack.sign --input='{"orderId":"ord_1001","totalCents":4200}'./kindgiw 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.
Test it
Section titled “Test it”A test passes the secret in the context it builds. Ask your coding agent: "Add a test for the signing tool, with a made-up key." For reference, the test looks like this:
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');});from kindgi import ToolContextfrom tools.receipts import Receipt, sign_receipt
def test_signs_with_the_key_from_the_context(): ctx = ToolContext.for_test(secrets={"RECEIPT_SIGNING_KEY": "k" * 32}) signed = sign_receipt(Receipt(orderId="ord_1001", totalCents=4200), ctx) assert len(signed.signature) == 64package mypack.tools;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.kindgi.log.Logger;import com.kindgi.pack.Cancellation;import com.kindgi.pack.ToolContext;import java.util.Map;import org.junit.jupiter.api.Test;
class SignReceiptTest { @Test void signsWithTheKeyFromTheContext() throws Exception { ToolContext ctx = new ToolContext("test-tenant", "test-run", null, null, null, Map.of(), Map.of("RECEIPT_SIGNING_KEY", "k".repeat(32)), Map.of(), Map.of(), new Cancellation(), Logger.noop()); SignReceipt.Output output = SignReceipt.TOOL.call(new SignReceipt.Input("ord_1001", 4200), ctx); assertEquals(64, output.signature().length()); }}ToolContext.forTest() has no secrets: build the context with new ToolContext(…), which takes them. TOOL.call runs the handler alone,
without the schema checks.
package mypack.tools
import com.kindgi.log.Loggerimport com.kindgi.pack.{Cancellation, ToolContext}import java.util.Map as JMap
class SignReceiptSuite extends munit.FunSuite { test("signs with the key from the context") { val ctx = new ToolContext("test-tenant", "test-run", null, null, null, JMap.of(), JMap.of("RECEIPT_SIGNING_KEY", "k" * 32), JMap.of(), JMap.of(), new Cancellation(), Logger.noop()) val output = SignReceipt.tool.call(SignReceipt.Input("ord_1001", 4200), ctx) assertEquals(output.signature.length, 64) }}ToolContext.forTest() has no secrets: build the context with new ToolContext(…), which takes them. tool.call runs the handler alone,
without the schema checks.
A model provider's key isn't a tool's
Section titled “A model provider's key isn't a tool's”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, with
the key ANTHROPIC_API_KEY. 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. Each of these is refused in the same words:
POST /v1/toolsanswers400.- A deploy that brings the tool fails with a
deployment-validation-failedissue for it. - Reinstating a retired version that names the key is refused.
The error reads:
{"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:
pnpm exec kindgi secrets set SUMMARIZER_ANTHROPIC_KEY --env=local --scope=tenantuv run kindgi secrets set SUMMARIZER_ANTHROPIC_KEY --env=local --scope=tenant./kindgiw secrets set SUMMARIZER_ANTHROPIC_KEY --env=local --scope=tenant./kindgiw secrets set SUMMARIZER_ANTHROPIC_KEY --env=local --scope=tenantIt prompts for the value without echoing it. It prints the secret's name, scope, environment and version as JSON, then:
… 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.
Why not the process environment
Section titled “Why not the process environment”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 or os.environ for your app's own settings, such as a service
URL or a database your app owns. Declare them as the pack's environment, as
Declare the environment your code reads shows.
An HTTP tool names its credential in
authorization.secretRef instead, and Kindgi adds it to the request.