@yoctotta/kaman-sdk
v0.1.0
Published
TypeScript SDK for the Kaman 3.2 engine. Operations are generated from the engine's own OpenAPI description so they cannot drift from the server; streaming, auth refresh, retries and typed errors are hand-written on top. Includes an app layer for Kaman-ba
Readme
@yoctotta/kaman-sdk
TypeScript SDK for the Kaman 3.2 engine.
Operations are generated from the engine's own OpenAPI description, so they cannot drift from the server. Streaming, auth refresh, retries and typed errors are hand-written on top, because codegen does those badly.
npm install @yoctotta/kaman-sdkTwo entry points:
| Import | For |
|---|---|
| @yoctotta/kaman-sdk/app | Building an app on Kaman. Start here. |
| @yoctotta/kaman-sdk | The full generated surface — every endpoint. |
The one rule
Server-side only. A platform key in a browser bundle is a credential handed to anyone who opens devtools, and it acts as its owner against every surface that owner can reach.
Import this from Next.js route handlers or server components. Browser code
calls your own app's /api/* routes, which call this.
Quick start
// app/api/ask/route.ts
import { kamanApp } from "@yoctotta/kaman-sdk/app";
const kaman = kamanApp(); // reads KAMAN_BASE_URL + KAMAN_API_KEY
export async function POST(req: Request) {
const { question } = await req.json();
// 1. Retrieve the passages that bear on the question.
const hits = await kaman.queryKb(process.env.KAMAN_KB_ID!, question, 4);
if (hits.length === 0) {
return Response.json({ answer: "That isn't covered by the handbook." });
}
// 2. Answer from ONLY those passages.
const context = hits
.map((h, i) => `[${i + 1}] from ${h.fileName}:\n${h.chunk}`)
.join("\n\n");
const answer = await kaman.chat([
{
role: "system",
content:
"Answer using ONLY the passages given. If they do not cover the " +
"question, say so — do not fill the gap from general knowledge. " +
"Cite the passage number next to the fact it supports.",
},
{ role: "user", content: `${context}\n\nQuestion: ${question}` },
]);
return Response.json({ answer, sources: hits.map((h) => h.fileName) });
}Retrieval before generation is what makes the answer checkable. A model asked "how many days of leave do I get" will always produce a number; the retrieval step is what makes it your organisation's number, and the citation is what lets the reader verify it.
The app surface
queryKb(kbId, query, topK?)
Search a knowledge base. Returns scored passages, best first — each with
chunk, fileName and score.
chat(messages, model?)
One LLM turn through the OpenAI-compatible gateway. model defaults to
"kaman-default", which resolves your deployment's default.
askAgentStructured(agentId, schema, prompt, opts?)
Ask an agent for an answer matching a JSON schema, and get back fields rather than prose.
const triage = await kaman.askAgentStructured(agentId, {
type: "object",
properties: {
severity: { type: "string", description: "low | medium | high" },
summary: { type: "string" },
needsHuman: { type: "boolean" },
},
required: ["severity", "summary", "needsHuman"],
}, `Triage this ticket:\n${body}`);
if (triage.needsHuman) await page(triage.severity, triage.summary);Use this over a plain agent call whenever your code has to act on the answer.
The plain surface is text in, text out — fine for "summarise this", wrong for
anything you branch on, because turning prose back into fields is where apps
break. Ask for a total and one reply says 1,240.50, the next says "about 1240
euros".
It works by handing the agent a tool whose input schema is the shape you want; the model either produces something matching it or it has not answered.
Pass onEvent to watch the run — tool calls arrive as they happen, which is
what a progress UI needs for a turn that takes minutes.
queryLake(lake, schema, sql, limit?) · queryLakeObjects(...)
Read from a KDL lake with SQL. SELECT only — your row-level security and
column masks apply. queryLakeObjects returns row objects instead of the
column/row arrays.
insertRows(lake, schema, table, columns, rows)
Insert is the only write verb the engine exposes; there is no REST update or delete. An app that needs mutable records models them as revisions: add a monotonic column, insert a new row per change, and read through a view that keeps the newest per key.
runWorkflow(workflowId, initialState?)
Run a workflow to completion and return its terminal run — state plus per-node outputs. The wire is SSE; this collects it into one awaited result.
kaman.raw
The full generated client — raw.sessions, raw.lakes, raw.agents,
raw.files, and every other domain. Reaching past the helpers never means
leaving the SDK.
Configuration
| Variable | Meaning |
|---|---|
| KAMAN_BASE_URL | Engine base URL, e.g. https://kaman.example.com |
| KAMAN_API_KEY | A kmn_ platform key. Server environment only. |
Inside a Kaman preview both are injected for you — the key is minted per
preview and short-lived — so an app runs there with no setup. Your app's own
configuration (which knowledge base, which lake) goes in a .env at the
project root.
kamanApp() throws if either is missing, rather than quietly building an
unauthenticated client whose failure surfaces much later as a confusing 401.
Errors
Every failure is a KamanApiError carrying the engine's stable error code, so
you branch on the code rather than matching strings:
import { KamanApiError } from "@yoctotta/kaman-sdk/app";
try {
await kaman.queryKb(kbId, question);
} catch (e) {
if (e instanceof KamanApiError && e.status === 404) { /* no such KB */ }
throw e;
}Requirements
Node 20+. ESM only. Zero runtime dependencies.
