SDKs generated from the cartridge¶
The cartridge is the interface definition. keel sdk <language> <cartridge> writes one typed file for your product's repo, the way protoc does; a small published runtime per language holds the HTTP client and the envelope. Decisions and milestones: the design note.
| Language | Generator | Runtime | Status |
|---|---|---|---|
| TypeScript | keel sdk typescript |
@keel-context/sdk (npm) |
shipped; tested end to end from Bun against keel serve --transport http |
| Python | keel sdk python |
keel.client inside keel-context (stdlib only) |
shipped; tested end to end in pytest |
| Go | keel sdk go --package <pkg> |
github.com/angelkurten/keel/sdk/go |
shipped; tested end to end with go test |
| PHP | keel sdk php --package '<Namespace>' |
keel-context/sdk (Composer, on Packagist) |
shipped; tested end to end on PHP 8.3 |
Every generator reads the same keel schema document and writes the same things: a type per event payload, an emit constructor per cataloged event, subject helpers, the rules and constants, and a client factory. --check on any of them exits 1 when the cartridge moved on, for CI.
Python¶
keel sdk python examples/fleet -o app/keel_fleet.py
from app import keel_fleet as kf
keel = kf.client("http://keel.internal:8000", "acme-logistics", secret=os.environ["KEEL_SECRET"])
keel.events([kf.emit.tire_depth_measured("acme-logistics", kf.subjects.tire("T-9001"), datetime.now(timezone.utc), "tires", {"unit_id": "MXK-4821", "position": "P3", "depth_mm": 2.6})])
keel.context(kf.subjects.tire("T-9001"))["evaluation"]["tire_critical"] # True
Payloads are TypedDicts, so your editor checks the keys; a refusal raises keel.client.KeelError with status and detail. A Python product that already imports keel can skip HTTP and call Tools directly; the generated module is for the ones that want the server boundary.
Go¶
go get github.com/angelkurten/keel/sdk/go
keel sdk go examples/fleet -o internal/keelfleet/keel_fleet.go --package keelfleet
Payloads are structs with JSON tags, enums are typed string constants, nullable fields are pointers, and Emit<Event>(tenant, subject, occurredAt, source, payload) returns a keel.Envelope. Errors are *keel.Error with Status and Detail. The output is gofmt and go vet clean.
PHP¶
composer require keel-context/sdk
keel sdk php examples/fleet -o src/Keel/Fleet.php --package 'App\Keel\Fleet'
Payloads are readonly value objects with toArray(), enums become PHP 8.1 enums, Emit::<event>(...) returns the envelope as an array and Keel::client(...) the client. Errors are Keel\Sdk\KeelError.
TypeScript¶
uv run keel sdk typescript examples/fleet -o src/keel-fleet.ts # in your product's repo
npm install @keel-context/sdk # or bun add
The generated file has a type per event payload, an emit constructor per cataloged event (typed by subject kind and payload), subjects.<kind>(id) helpers, the rules, relations, agents and scopes as constants, and client(options) bound to one tenant and agent.
import { client, emit, subjects } from "./keel-fleet";
const keel = client({ baseUrl: "http://keel.internal:8000", tenant: "acme-logistics", secret: process.env.KEEL_SECRET, agent: "tires-bot" });
await keel.events([
emit.tireDepthMeasured({ tenant: "acme-logistics", subject: subjects.tire("T-9001"), occurredAt: new Date(), source: "tires.measurements", payload: { unit_id: "MXK-4821", position: "P3", depth_mm: 2.6 } }),
]);
const ctx = await keel.context(subjects.tire("T-9001"));
ctx.evaluation.tire_critical; // true
const depths = await keel.history(subjects.tire("T-9001"), { predicate: "depth_mm" });
await keel.annotate({ subject: subjects.tire("T-9001"), predicate: "assessment", value: "replace", scope: "task" });
A refusal from the server (scope policy, agent registry) is a KeelError with the status and the reason. Keep the generated file fresh with keel sdk typescript <cartridge> -o <file> --check in CI: it exits 1 when the cartridge moved on.
The context for a task, in every runtime¶
The parameters of 0.19 (§7.1, §7.3, §7.5 of the spec) are in all four runtimes. The same calls in each:
| Python | TypeScript | Go | PHP | |
|---|---|---|---|---|
| Facts on each result | search(text, detail="concise") |
search(text, { detail: "concise" }) |
SearchWith(ctx, SearchOptions{Text: t, Detail: "concise"}) |
search($t, ['detail' => 'concise']) |
| Best of each kind, in scope | search(t, mode="semantic", related_to=s, per_kind=3, depth=1) |
search(t, { mode: "semantic", relatedTo: s, perKind: 3, depth: 1 }) |
SearchOptions{..., RelatedTo: s, PerKind: 3, Depth: 1} |
['related_to' => $s, 'per_kind' => 3, 'depth' => 1] |
| Many subjects, one call | context([a, b]) |
contexts([a, b]), contextsConcise |
Contexts(ctx, []string{a, b}, o) |
context([$a, $b]) |
| Many texts, one call | search([t1, t2]) |
searches([t1, t2]) |
Searches(ctx, []string{t1, t2}, o) |
search([$t1, $t2]) |
| Several kinds at once | search(t, kind=[k1, k2]) |
search(t, { kind: [k1, k2] }) |
SearchOptions{Text: t, Kinds: []string{k1, k2}} |
['kind' => [$k1, $k2]] |
| By a fact's value (0.20) | search(kind=k, where={"title": t}) |
search("", { kind: k, where: { title: t } }) |
SearchOptions{Kind: k, Where: map[string]any{"title": t}} |
['kind' => $k, 'where' => ['title' => $t]] |
| Two hops through some relations | related(s, ["r1", "r2"], depth=2) |
related(s, ["r1", "r2"], undefined, 2) |
RelatedWith(ctx, s, RelatedOptions{Relations: ..., Depth: 2}) |
related($s, ['r1', 'r2'], null, 2) |
| Just some predicates (0.22) | context([a, b], only=[...]) |
contexts([a, b], undefined, 0, { only: [...] }) |
ContextOptions{Only: ...} |
context([$a, $b], only: [...]) |
| Hide predicates, past instant | exclude=[...], as_of= |
exclude, asOf |
Exclude, AsOf |
'exclude', 'as_of' |
Runtime options¶
Every runtime takes the same connection options besides the base URL, tenant, secret and agent: a traceparent sent as the W3C header on every call (Keel stores it with every write) and a request timeout.
| Runtime | Traceparent | Timeout |
|---|---|---|
Python keel.client.Client |
traceparent= |
timeout= seconds (30) |
TypeScript client({...}) |
traceparent |
none built in; pass your own fetch (one that sets an AbortSignal, or any wrapper) |
Go keel.Options |
Traceparent |
HTTPClient (default &http.Client{Timeout: 30 * time.Second}), plus the context.Context of each call |
PHP Keel\Sdk\Client |
traceparent: |
timeout: seconds (30) |
The generated Python client() and PHP Keel::client() take timeout and traceparent too (m.client(url, tenant, secret=..., timeout=10, traceparent=tp), Keel::client($url, $tenant, $secret, $agent, 10, $tp)). The runtime client works the same, and the generated emit and subjects work with it unchanged:
from keel.client import Client
keel = Client("http://keel.internal:8000", "acme-logistics", secret=os.environ["KEEL_SECRET"], agent="tires-bot", timeout=10, traceparent=traceparent)
$keel = new \Keel\Sdk\Client('http://keel.internal:8000', 'acme-logistics', getenv('KEEL_SECRET'), 'tires-bot', timeout: 10, traceparent: $traceparent);
The Python client keeps one keep-alive connection per thread and reuses it, so only the first call pays the TCP and TLS handshake; close() drops it. It identifies itself as User-Agent: keel-context/<version>, since some proxies (Cloudflare's browser integrity check, error 1010) block urllib's default. headers={...} adds headers to every request or overrides the defaults, User-Agent included; the generated client() takes it too.
For HTTP/2, a proxy, retries or mTLS, pass your own client: anything with httpx's post(url, content=, headers=) carries the requests, with its own timeout and pool. Keel stays stdlib-only; the dependency is yours.
import httpx # pip install "httpx[http2]"
keel = Client("https://keel.example.com", "acme-logistics", secret=os.environ["KEEL_SECRET"], http_client=httpx.Client(http2=True, timeout=10))
keel serve --transport http --http2 speaks HTTP/2 end to end (h2 over TLS, or h2c; see connect); it pays off mostly for many concurrent calls from one process.
Reads follow the memory scopes: agent-scope memory comes back only to the agent that wrote it, so set agent to read your own, and task memory tied to a task id only to calls that pass that task (details).
Server side¶
The SDK talks to keel serve --transport http (see connect): one process for many tenants, X-Keel-Tenant and X-Keel-Agent per request behind a shared secret. The server derives event ids (a content hash) when the product sends none, so retries are safe to reason about.
The HTTP API's defaults are for an agent calling it directly: context answers detail="brief", history "concise" and annotate "ack", and history, related and search cap their lists, leading a cut list with {"more": ...} (and a search ranked by the query's words with {"note": ...}). The clients ask for the shapes their types declare instead: detail "full"; the TypeScript, Go and PHP runtimes also ask for every entry unless you set a limit and drop a leading {"more"} or {"note"}, while keel.client.Client keeps the server's caps (history and related default to limit=50) and returns the lists as they come, marker included. A subject nothing is known about comes back as {subject, found: false, as_of, watermark}; an unknown kind, relation, rule or scope, a key without <kind>: and a malformed time are a 400 whose detail names the valid values.