Give a tool per-project values
This guide gives a tool values that differ per project, such as a base URL or a region, and reads them in the tool's code.
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 project's id, to set a value for one project.
kindgi projects listlists the projects with their ids.
Declare the values
Section titled “Declare the values”Some of what a tool needs differs from one customer or project to the next, without being secret: a base URL, an account or region id, a queue name. A tool declares these env values by name. You set them for the whole tenant, an org or a single project. On every call, Kindgi hands the tool the most specific value in the call's context.
Ask your coding agent: "I want a tool that looks up an order in our order system. Its address and region differ per project." Kindgi's skills in your pack tell it how (How it knows Kindgi). For reference, the tool looks like this:
import { defineTool } from '@kindgi/sdk/define';import type { ToolId } from '@kindgi/sdk/types';import { z } from 'zod';
const defined = defineTool({ id: 'my-pack.find-order' as ToolId, description: 'Finds an order in the order system.', version: '0.1.0', input: z.object({ orderId: z.string() }), output: z.object({ url: z.string() }), effects: [], mutating: false, needsSpec: { env: { ORDERS_BASE_URL: { type: 'string', pattern: '^https://' }, ORDERS_REGION: { type: 'string', enum: ['eu', 'us'], default: 'eu' }, }, }, handler: async ({ orderId }, ctx) => { const base = ctx.env?.ORDERS_BASE_URL; if (base === undefined) throw new Error('ORDERS_BASE_URL is not set'); return { url: `${base}/${ctx.env?.ORDERS_REGION}/orders/${orderId}` }; },});
if (defined.kind === 'err') { throw new Error(`my-pack.find-order failed to compile: ${defined.error.message}`);}
export default defined.value;from pydantic import BaseModel, Field
from kindgi import ToolContext, tool
class Lookup(BaseModel): order_id: str = Field(alias="orderId")
class Found(BaseModel): url: str
@tool( id="my-pack.find-order", mutating=False, needs_spec={ "env": { "ORDERS_BASE_URL": {"type": "string", "pattern": "^https://"}, "ORDERS_REGION": {"type": "string", "enum": ["eu", "us"], "default": "eu"}, } },)def find_order(lookup: Lookup, ctx: ToolContext) -> Found: """Finds an order in the order system.""" base = ctx.env["ORDERS_BASE_URL"] return Found(url=f"{base}/{ctx.env['ORDERS_REGION']}/orders/{lookup.order_id}")package mypack.tools;
import com.kindgi.pack.Tool;import java.util.List;import java.util.Map;
public final class FindOrder { public record Input(String orderId) {}
public record Output(String url) {}
public static final Tool<Input, Output> TOOL = Tool.define("my-pack.find-order") .description("Finds an order in the order system.") .version("0.1.0") .input(Input.class) .output(Output.class) .mutating(false) .set("needsSpec", Map.of("env", Map.of( "ORDERS_BASE_URL", Map.of("type", "string", "pattern", "^https://"), "ORDERS_REGION", Map.of("type", "string", "enum", List.of("eu", "us"), "default", "eu")))) .handler((input, ctx) -> { Object base = ctx.env().get("ORDERS_BASE_URL"); if (base == null) throw new IllegalStateException("ORDERS_BASE_URL is not set"); return new Output(base + "/" + ctx.env().get("ORDERS_REGION") + "/orders/" + input.orderId()); });
private FindOrder() {}}needsSpec goes in with set("needsSpec", …), as the API names it.
ctx.env() holds the declared values, by name, with a default filled in
where no scope sets one.
package mypack.tools
import com.kindgi.pack.scaladsl._
object FindOrder { final case class Input(orderId: String) final case class Output(url: String)
val tool: Tool[Input, Output] = Tool[Input, Output]("my-pack.find-order") .description("Finds an order in the order system.") .version("0.1.0") .readOnly .set("needsSpec", Map("env" -> Map( "ORDERS_BASE_URL" -> Map("type" -> "string", "pattern" -> "^https://"), "ORDERS_REGION" -> Map("type" -> "string", "enum" -> List("eu", "us"), "default" -> "eu")))) .handler { (in, ctx) => val base = Option(ctx.env.get("ORDERS_BASE_URL")) .getOrElse(throw new IllegalStateException("ORDERS_BASE_URL is not set")) Output(s"$base/${ctx.env.get("ORDERS_REGION")}/orders/${in.orderId}") }}needsSpec goes in with set("needsSpec", …), as the API names it.
ctx.env holds the declared values, by name, with a default filled in
where no scope sets one.
-
The declaration maps each name to a JSON Schema for its value. Values are strings, as in a process environment. The schema checks the string, with keywords such as
enum,patternorminLength. A name with adefaultis optional. Every other declared name is required. Each schema must compile as the runtime compiles it, strictly: an unknown keyword is an error. Adefaultmust be a string. A tool with a schema that doesn't compile is refused when it's defined, registered or deployed:Tool "my-pack.find-order": the schema for needsSpec.env.ORDERS_REGION doesn't compile: strict mode: unknown keyword: "enmu" -
On every call, Kindgi resolves each declared name for the call: the project's value, else its org's, else the tenant's. It reads them in the env the runtime serves, which
KINDGI_ENVnames. Underkindgi dev, the env islocal. A name no scope sets gets itsdefault. A required name with no value, or a value that doesn't fit its schema, fails the call before the handler runs. The failure names the tool and the value, and gives the command that sets it. -
The values a call used are recorded with it. A call re-run after a wait or a retry sees the same ones, even after someone changes them. An approval is one such wait. A comparison that replays the run sends a read-only tool it runs live the values the run's call saw, so a changed value can't skew it.
-
The context holds only what the tool declares.
ctx.envis optional in the TypeScript type because a unit test builds its own context.
Set the values
Section titled “Set the values”kindgi env set with --scope stores a value in the runtime, for a scope
and an env. Set a value for the whole tenant, and override one for a single
project:
pnpm exec kindgi env set ORDERS_BASE_URL https://orders.example.com --scope=tenant --env=localpnpm exec kindgi env set ORDERS_REGION us --scope=project:<project-id> --env=localuv run kindgi env set ORDERS_BASE_URL https://orders.example.com --scope=tenant --env=localuv run kindgi env set ORDERS_REGION us --scope=project:<project-id> --env=local./kindgiw env set ORDERS_BASE_URL https://orders.example.com --scope=tenant --env=local./kindgiw env set ORDERS_REGION us --scope=project:<project-id> --env=local./kindgiw env set ORDERS_BASE_URL https://orders.example.com --scope=tenant --env=local./kindgiw env set ORDERS_REGION us --scope=project:<project-id> --env=localEach command prints JSON with the value's env, scope, key and revision, and whether it replaced a value, then a line that confirms it. The first ends with:
… ✓ Set ORDERS_BASE_URL at tenant in env local. A tool that declares it (`needsSpec.env`) gets it on its next call.The second prints the same line for ORDERS_REGION at project:<project-id>.
--scopeis where the value lives:tenant,org:<orgId>orproject:<projectId>.<project-id>is the project's id. A call in that project now getsORDERS_REGION=us. Every other call gets the default,eu.--envis required with--scope. Name the env the runtime serves, whichKINDGI_ENVnames. Underkindgi dev, it'slocal. A value under another env name never reaches a call.- A new value counts on the next call. Nothing restarts, and no new agent version is needed.
- Changing a value that's already set takes
--force. kindgi env list, with a--scopeand an--env, shows that scope's values.kindgi env unset NAME, with the same two flags, removes one. A call then gets the next scope's value again.
A value nobody set
Section titled “A value nobody set”A call that needs a declared value no scope sets, and that has no default,
fails before the tool runs. The message names the value and the command that
sets it. For a tool acme-orders.needs-account that declares
ACME_ACCOUNT_ID, which nobody set, it reads:
precondition-failed: Tool "acme-orders.needs-account" was not run: env-value-missing: tool "acme-orders.needs-account" needs env value "ACME_ACCOUNT_ID" in env "local", and none is set for project e889c1f5-eae7-45dc-8669-5bd029a5d85c, its org, or the tenant. Set it: kindgi env set ACME_ACCOUNT_ID <value> --scope=project:e889c1f5-eae7-45dc-8669-5bd029a5d85c --env=local (or --scope=tenant, for every project)Test it
Section titled “Test it”A test passes the values in the context it builds. Ask your coding agent: "Add a test for the order lookup tool." For reference, the test looks like this.
invokeToolForTest decides them as a run does:
- a name the test doesn't set takes its schema's
default; - each value is checked against its schema;
- the handler sees the declared names only.
A missing or invalid value is precondition-failed, with the reason
env-value-missing or env-value-invalid, and the handler doesn't run. Its
third argument is the context. What it sets goes over the defaults of a
test's context, which toolContextForTest builds:
import { invokeToolForTest } from '@kindgi/sdk/define';import { expect, test } from 'vitest';
import findOrder from './index.js';
test('builds the URL from the env values', async () => { const result = await invokeToolForTest( findOrder, { orderId: 'ord_1001' }, { env: { ORDERS_BASE_URL: 'https://orders.example.com' } }, ); // ORDERS_REGION isn't set, so it's its default, `eu`, as in a run. expect(result).toEqual({ kind: 'ok', value: { url: 'https://orders.example.com/eu/orders/ord_1001' }, });});from kindgi import ToolContextfrom tools.orders import Lookup, find_order
def test_builds_the_url_from_the_env_values(): ctx = ToolContext.for_test( env={"ORDERS_BASE_URL": "https://orders.example.com", "ORDERS_REGION": "us"} ) found = find_order(Lookup(orderId="ord_1001"), ctx) assert found.url == "https://orders.example.com/us/orders/ord_1001"new ToolContext(…) takes the env values as the handler gets them. Nothing
fills in a default or checks a value against its schema, as
invokeToolForTest does in TypeScript, so the test sets each name the
handler reads.
package 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 FindOrderTest { @Test void buildsTheUrlFromTheEnvValues() throws Exception { Map<String, Object> env = Map.of("ORDERS_BASE_URL", "https://orders.example.com", "ORDERS_REGION", "us"); ToolContext ctx = new ToolContext("test-tenant", "test-run", null, null, null, env, Map.of(), Map.of(), Map.of(), new Cancellation(), Logger.noop()); FindOrder.Output output = FindOrder.TOOL.call(new FindOrder.Input("ord_1001"), ctx); assertEquals("https://orders.example.com/us/orders/ord_1001", output.url()); }}new ToolContext(…) takes the env values as the handler gets them. Nothing
fills in a default or checks a value against its schema, as
invokeToolForTest does in TypeScript, so the test sets each name the
handler reads.
package mypack.tools
import com.kindgi.log.Loggerimport com.kindgi.pack.{Cancellation, ToolContext}import java.util.Map as JMap
class FindOrderSuite extends munit.FunSuite { test("builds the URL from the env values") { val env = JMap.of[String, Object]("ORDERS_BASE_URL", "https://orders.example.com", "ORDERS_REGION", "us") val ctx = new ToolContext("test-tenant", "test-run", null, null, null, env, JMap.of(), JMap.of(), JMap.of(), new Cancellation(), Logger.noop()) val output = FindOrder.tool.call(FindOrder.Input("ord_1001"), ctx) assertEquals(output.url, "https://orders.example.com/us/orders/ord_1001") }}Env values, settings, secrets, process env
Section titled “Env values, settings, secrets, process env”A pack's code gets four kinds of values. Pick by what the value is:
| The value… | Use | The code reads |
|---|---|---|
| should change only with a new agent version, judged before it goes live (a threshold, a prompt parameter) | a settings block | ctx.settings |
| differs per tenant, org or project, and isn't secret (a base URL, a region) | an env value | ctx.env |
| is a credential (an API key, a signing key) | a secret | ctx.secrets |
| is the pack service's own setup (its database, its SDK's config) | the pack's environment | process.env / os.environ |
Env values aren't secret: they're recorded with each run that uses them and
shown in its journal. kindgi env set warns about a name that looks like a
credential, such as …_TOKEN, …_KEY or …_PASSWORD. Store those with
kindgi secrets, as Give a tool a secret shows.