@kindgi/handler-runtime
npm install @kindgi/handler-runtime · source
Runs a Kindgi pack's own code: its tool handlers and guardrail checks.
- The pack service: a long-lived HTTP server that runs a pack's tools and checks over pack protocol v2. It's the process a pack image runs, and
kindgi devruns the same process locally, behind a supervisor. - The handler runner (
runHandler,runCheck): runs one tool call or one check in process. The pack service runs every call through it. - The build-time indexer (
kindgi-index): writes the pack'sindex.json, the list of its tools, guardrails, agents and flows that the pack service and the runtime read.
Handler runner
Section titled “Handler runner”runHandler({ tool, input, ctx }) runs one tool call:
- Validates a copy of
inputagainst the tool's input JSON Schema (ajv, Draft 2020-12), filling in each property'sdefault. When the module exports a Zod schema (defineTool'sinputZod), it also parses the input with it, so the handler gets what its type says. - Imports
tool.modulePathand resolves the handler: a bare function export, ordefault/handler/run, or adefineToolexport holding one. - Calls
handler(input, ctx), awaiting a promise. - Validates the return value against the tool's output JSON Schema.
runCheck({ check, config, trace, abortSignal? }) runs one guardrail check: it imports the module, resolves its evaluate (default, check, or a bare export), and calls evaluate(config, trace, bindings). bindings carries only abortSignal; a check here has no provider registry, so llm-judge checks run in the server.
Neither throws. Each returns { kind: 'ok', value } or { kind: 'err', error: HandlerError }:
error.code |
When |
|---|---|
input-validation-failed |
The input failed its schema (issues lists why). The handler isn't called. |
output-validation-failed |
The handler's return value failed the output schema. |
handler-import-failed |
Importing the module threw (cause). |
handler-shape-invalid |
The module exports no handler. |
handler-throw |
The handler (or evaluate) threw (cause). |
check-shape-invalid |
The check module exports no evaluate. |
import { runHandler } from '@kindgi/handler-runtime';
const outcome = await runHandler({ tool: { id: 'acme.lookup', modulePath: '/app/dist/tools/lookup.js', inputSchema, outputSchema }, input: { q: 'backpay' }, ctx: { tenantId, runId, abortSignal },});importHandler / importCheck replace the default import(modulePath) (tests use them). The runner is not a sandbox: the pack service's process is the boundary, and pack code is the team's own.
Pack service (protocol v2)
Section titled “Pack service (protocol v2)”A long-lived HTTP server that runs a pack's tool handlers and guardrail checks for a runtime. It's the process a pack image runs, and the same process runs locally during development. Imports happen once at boot, calls run concurrently, and handler console output can't corrupt a response.
KINDGI_PACK_SERVICE_TOKEN=… PORT=8080 node node_modules/@kindgi/handler-runtime/dist/pack-service/main.js \ --index dist/index.json # module paths resolve against --module-root (default: the index's directory) # --bundle-map <path>: a build's map of the index's source paths to their bundles # --host <address>: listen on one address (default: every interface)A pack image runs it bundled (dist/kindgi-pack-service.mjs) with --bundle-map: the index names each module by its source path (tools/echo/index.ts), and the map points it at the bundle that loads (tools/echo/index.mjs).
| Route | Auth | Answer |
|---|---|---|
POST /v1/invoke |
kindgi-pack-token |
200 with a v2 response message |
GET /v1/info |
kindgi-pack-token |
pack id, versions, protocol, tools, checks, and the required env names it lacks (missingEnv) |
GET /healthz |
none | the process is up |
GET /readyz |
none | prewarmed, not draining, and every required env name set (unless the env check is warn) |
- Requests (
@kindgi/handler-runtime/protocol) name a tool ({ id, version? }) or a check ({ id }). The service resolves it from its ownindex.json, so a caller never chooses which module is imported. - Every outcome of running pack code is a message, answered with
200: the result, validation failures (withissues), a throw, an unknown tool or check, a version mismatch,deadline-exceeded(fromkindgi-timeout-ms), andcancelled(the caller disconnected). HTTP statuses are for the transport only:401,404,405,413,415,503withRetry-Afterwhen the service isn't ready, is draining, or is at its concurrency cap, and500if the service itself fails while handling a call. - The declared process env: a pack lists the environment variables its code reads in
kindgi.config(env: { required, optional }), and the index carries them. With arequiredname unset or empty, the service isn't ready:/readyzand every call answer503with{ "error": "missing env", "missingEnv": [...] }, names only.KINDGI_PACK_ENV_CHECK=warnserves anyway and names them in the log (missing-env) and/v1/info;kindgi devuses it.resolvePackEnvandmissingPackEnvare the shared checks. - Cancellation: the handler's
ctx.abortSignalfires on a deadline or a disconnect. Handlers that do slow I/O should pass it on. - Boot fails (exit
1, listing every problem) when the index can't be read, a module it names is missing, or one fails to import. SIGTERM drains in-flight calls and exits0. - Logs are JSON lines on stderr. The
listeningline carries the bound port, so a caller can start the service withPORT=0. - Programmatic:
createPackService({ index, resolveModule, token, … })returns a service whosehandleis anode:httprequest handler.startPackService(config)also listens (@kindgi/handler-runtime/pack-service).
Supervisor (local development)
Section titled “Supervisor (local development)”createPackServiceSupervisor({ command, moduleRoot, env, … }) (@kindgi/handler-runtime/pack-service) runs a pack service as a child process behind a front: one HTTP listener on a fixed loopback address with a session token, for the supervisor's whole life. A caller — a runtime calling tools — holds that one URL and token however often the code changes.
listen()opens the front;start(indexPath)boots a child on the index (loopback, any port) and switches calls to it once it listens. The previous child finishes its in-flight calls and stops; a child that fails to boot leaves the previous one serving; a child that exits on its own is restarted.- The front forwards
POST /v1/invokeandGET /v1/info, and answers503withRetry-Afterwhile no child serves (the call did not run, so a caller may retry) and502when a child died mid-call (it may have run)./healthzanswers while the front listens,/readyzwhile a child serves. commandis the child's argv; the supervisor appends--index,--module-rootand--host. Any pack service with the process contract ofpack-protocol.schema.jsonworks: this package's ([process.execPath, <pack-service-main>]) or the Python SDK's ([python, '-m', 'kindgi.pack', 'serve']).- The child's environment is exactly
env()plus the token andPORT=0. - Forwarding is a separate
relay(a request body plus deadline, run and request ids, and a cancel signal), so a listener other than HTTP can reuse it. stop()stops the child (the front then answers503);close()also closes the front.
Build extensions
Section titled “Build extensions”@kindgi/handler-runtime/build-extensions (re-exported as @kindgi/sdk/build): the types and helpers for image in a TypeScript pack's kindgi.config.*.
ImageConfig { systemPackages?, extensions?, buildEnv? };BuildExtension { name, contextFiles?, systemPackages?, postInstall?, buildEnv? };prisma({ schema, config? }):prisma generateafter the install;defineBuildExtension().
They're data only: kindgi build renders them into the image's Containerfile.
Build-time indexer
Section titled “Build-time indexer”kindgi-index.ts writes a pack's index.json. A pack image's indexer stage runs it over the image's bundles (process entry kindgi-index-main, bundled as dist/kindgi-index.mjs), and the emitted /app/index.json is COPY --from=indexer'd into the runtime image; kindgi build runs the same bundle locally, so the two indexes compare byte for byte. The pack service reads index.json at boot: it serves exactly the tools and checks the index lists.
What the indexer does
Section titled “What the indexer does”- Discovers files under
tools/,guardrails/,agents/,flows/perkindgi.config.ts(.ts/.js/.mjsall accepted). Indexing a build (bundleMap), the map's source paths are the file list, classified by the same patterns — the source tree needn't be there — each imported from its bundle; the index still records the source path. - Kind-maps each file by its containing directory (primary) or by structural detection on the default export (fallback for files matched by custom-discovery globs outside the four default folders).
- Imports each file and reads the
defaultexport — accepting either the raw primitive shape or aResult-wrapped envelope fromdefineTool/defineGuardrail/ etc. Fails loud on: no default export, kind mismatch, or aResult-wrapped error. - Converts Zod schemas —
tool.inputZod/tool.outputZod/check.configZod— to their JSON Schema wire form via@kindgi/schema.toJSONSchemaSync. JSON-Schema-authored slots pass through verbatim. - Writes the
v: 1index.jsonenvelope atomically (write to.tmp-<pid>-<time>, then rename) to the configured output path.
Programmatic form
Section titled “Programmatic form”import { runIndexer } from '@kindgi/handler-runtime';
const outcome = await runIndexer({ packDir: '/path/to/pack', // Optional overrides configPath: '/path/to/kindgi.config.ts', outputPath: '/app/index.json', artifactVersion: '20260920.1', // pin for reproducible builds publishedAt: '2026-09-20T14:32:07.104Z', // pin for byte-determinism});
if (outcome.kind === 'ok') { console.log(outcome.value.counts); // { tools, guardrails, agents, flows } console.log(outcome.value.outputPath);} else { console.error(outcome.error.code, outcome.error.message);}runIndexer never throws — every failure surfaces as a typed IndexerError:
| Code | When |
|---|---|
config-not-found |
No kindgi.config.{ts,mts,mjs,js,cjs} at the pack root. |
config-parse-failed |
Config file imported but is malformed. |
discovery-empty |
Glob patterns matched zero files. |
file-import-failed |
Dynamic import() of a discovered file threw. |
no-default-export |
A discovered file exported no default. |
kind-mismatch |
File under tools/ default-exports a non-tool (or symmetric for the other three folders). |
ambiguous-kind |
Custom-pattern file whose default export doesn't structurally match any primitive shape. |
zod-conversion-failed |
z.toJSONSchema() threw for a specific schema. |
manifest-validation-failed |
Inner manifest didn't match the expected shape (or was a Result-wrapped error). |
output-write-failed |
Filesystem write error. |
Loading kindgi.config.* on its own
Section titled “Loading kindgi.config.* on its own”The indexer's config loader is exported so every tool reads the pack config the same way:
import { loadKindgiConfig } from '@kindgi/handler-runtime';
const r = await loadKindgiConfig('/path/to/pack');if (r.kind === 'ok') r.value.pack.id;// r.kind === 'err': 'config-not-found' | 'config-parse-failed' (message carries the cause)It looks up KINDGI_CONFIG_FILENAMES in order and imports the first that
exists (cache-busted by mtime, so a long-running process sees edits). With
none, a pyproject.toml whose [tool.kindgi] table holds the same keys is
the config — a Python pack (language: 'python', unless the table says
otherwise). It checks pack.id / pack.version and language. Never throws.
findKindgiConfig(packDir)— where the config is ({ path, format: 'module' | 'pyproject' }), orundefined; apyproject.tomlwithout the table is not a pack.packLanguage(config)—'node'or'python': which indexer reads the pack and which pack service runs it.runIndexerrefuses a Python pack (language-mismatch);python -m kindgi.pack indexindexes it.resolveDiscovery(discovery, language)— the patterns with the language's defaults (DEFAULT_DISCOVERY,DEFAULT_PYTHON_DISCOVERY).
Command-line form
Section titled “Command-line form”main(argv) is the command; the kindgi-index-main export runs it as a process (a pack image runs it bundled, as dist/kindgi-index.mjs).
kindgi-index --pack-dir <path> [--config <path>] [--output <path>] [--bundle-map <path> [--module-root <path>]] [--artifact-version <str>] [--published-at <iso>] [--help] [--version]--bundle-map indexes a build: a JSON object of source path → bundle path, the bundle paths relative to --module-root (default --pack-dir).
index.json shape
Section titled “index.json shape”{ "v": 1, "packId": "acme.support", "packVersion": "1.0.0", "artifactVersion": "20260920.1", "publishedAt": "2026-09-20T14:32:07.104Z", "tools": [{ "id": "…", "input": { … }, "output": { … }, "effects": [ … ], "modulePath": "tools/…/index.js", "sandbox": "strict", "limits": { … }, "network": { … } }, …], "guardrails": [{ "id": "…", "kind": "zero-llm", "action": { "on-violation": "halt" }, "severity": "error", "checkModulePath": "guardrails/…/index.js", "checkId": "…", "configSchema": { … } }, …], "agents": [{ "id": "…", "version": "1.0.0", "instructions": "…", "capabilities": [ … ], "tools": [ … ], "modulePath": "agents/…/index.js" }, …], "flows": [{ "id": "…", "version": "1.0.0", "nodes": [ … ], "edges": [ … ], "kernelPayloadVersion": 1, "modulePath": "flows/…/index.js" }, …], "env": { "optional": ["LOG_LEVEL"], "required": ["DATABASE_URL"] } // only when the pack declares env}The pack service:
- Reads the index at boot (
--index, elseKINDGI_PACK_INDEX, else/app/index.json) and imports every module it names (prewarm). A missing or failing module fails the boot. - For a call to tool
X, findsXin the index and runs it withrunHandler, with its module path resolved against--module-root. A guardrail check is found by itscheckId.
Determinism
Section titled “Determinism”An index computed on a developer's laptop must match one computed by the build server for the same inputs — byte-identical inputs must produce byte-identical outputs. To make that work:
- All list fields (
tools/guardrails/agents/flows) are sorted lexicographically byid. - JSON serialization writes keys in sorted order at every level.
publishedAtis accepted as an explicitopts.publishedAt(build-arg from the Dockerfile, SOURCE_DATE_EPOCH-shaped). Default: fresh ISO string — non-deterministic; production builds must override it.
Not in this package
Section titled “Not in this package”- Running the indexer as part of a pack build — that is the build tooling's job.
- Multi-pack workspace indexing (one pack per subfolder).
- Watch mode / hot re-indexing.
- A signed
index.json— the image digest, which contains the file, is what gets signed. - Cross-file dependency validation (an agent referencing a tool the pack doesn't have).
- Bundle-size warnings or thresholds.
- Handler-side telemetry / cost-tracking hooks.
- Streaming output for tools that yield incremental results.
License
Section titled “License”Apache-2.0 — see LICENSE.