@keemakr/agent-sdk 0.15.0 → 0.16.0-dev-20260925092950

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ /** Any card. Open by construction: a client may render a `type` this SDK predates. */
2
+ export type Canvas = {
3
+ type: string;
4
+ } & Record<string, unknown>;
5
+ /** A titled markdown document — a run summary, a written answer, a table of results. */
6
+ export interface ReportCanvas {
7
+ type: 'report';
8
+ markdown: string;
9
+ title?: string;
10
+ }
11
+ /**
12
+ * A picture, shown inline in the chat.
13
+ *
14
+ * Prefer this over putting the URL in `reply`. Clients do preview a URL a reply merely names, but
15
+ * the card is the better copy on both sides of the wire: core validates the URL at ingestion, the
16
+ * card carries its own alt text and dimensions, and a client that shows the card suppresses the
17
+ * duplicate preview of the same URL in your prose.
18
+ */
19
+ export interface ImageCanvas {
20
+ type: 'image';
21
+ url: string;
22
+ /** What the picture shows. This is the fallback text, so write it for someone who cannot see it. */
23
+ alt?: string;
24
+ /** The prompt that produced it, when it was generated. Shown as a caption. */
25
+ prompt?: string;
26
+ width?: number;
27
+ height?: number;
28
+ }
29
+ /**
30
+ * Build an image card, refusing a URL that would not reach a reader.
31
+ *
32
+ * Two different things drop a URL on the way, and both are silent from here — the delegation
33
+ * succeeds, the card never appears, and nothing in your logs says why:
34
+ *
35
+ * - Core's ingestion default-denies anything that is not http(s). A `data:`, `file:` or
36
+ * `javascript:` URL is never persisted.
37
+ * - The dashboards are served over https, so an `http:` image is mixed content the browser
38
+ * refuses after upgrading the request. Core stores it happily; nobody ever sees it.
39
+ *
40
+ * So this is stricter than ingestion on purpose: https only. Throwing at construction turns a
41
+ * picture that quietly never arrives into an error where the mistake was made.
42
+ */
43
+ export declare function imageCanvas(input: {
44
+ url: string;
45
+ alt?: string;
46
+ prompt?: string;
47
+ width?: number;
48
+ height?: number;
49
+ }): ImageCanvas;
@@ -0,0 +1,47 @@
1
+ // Canvases — the inline cards an agent's reply can carry back to the Operator.
2
+ //
3
+ // A department returns `canvases` on its DelegationResult and core persists each one as a content
4
+ // block the clients render. The wire is deliberately permissive (core validates a canvas is
5
+ // `{ type, … }` and nothing more, so a client that knows a card can render it even if core has
6
+ // never heard of it), which is right for the pipe and unhelpful when you are writing the agent:
7
+ // nothing tells you a card exists, what it is called, or which fields it needs.
8
+ //
9
+ // So this is a floor, not a schema. The types name the cards clients render today, and the helper
10
+ // refuses the one mistake that is silent end to end — a URL core will drop at ingestion, leaving
11
+ // an agent that believes it delivered a picture and a user who sees nothing.
12
+ /**
13
+ * Build an image card, refusing a URL that would not reach a reader.
14
+ *
15
+ * Two different things drop a URL on the way, and both are silent from here — the delegation
16
+ * succeeds, the card never appears, and nothing in your logs says why:
17
+ *
18
+ * - Core's ingestion default-denies anything that is not http(s). A `data:`, `file:` or
19
+ * `javascript:` URL is never persisted.
20
+ * - The dashboards are served over https, so an `http:` image is mixed content the browser
21
+ * refuses after upgrading the request. Core stores it happily; nobody ever sees it.
22
+ *
23
+ * So this is stricter than ingestion on purpose: https only. Throwing at construction turns a
24
+ * picture that quietly never arrives into an error where the mistake was made.
25
+ */
26
+ export function imageCanvas(input) {
27
+ let parsed;
28
+ try {
29
+ parsed = new URL(input.url);
30
+ }
31
+ catch {
32
+ throw new TypeError(`imageCanvas: not a URL: ${input.url}`);
33
+ }
34
+ if (parsed.protocol !== 'https:') {
35
+ throw new TypeError(`imageCanvas: images must be https; "${parsed.protocol}" either never reaches the ` +
36
+ 'transcript (core drops non-http(s) at ingestion) or is blocked as mixed content by the ' +
37
+ 'browser that would show it');
38
+ }
39
+ return {
40
+ type: 'image',
41
+ url: input.url,
42
+ ...(input.alt === undefined ? {} : { alt: input.alt }),
43
+ ...(input.prompt === undefined ? {} : { prompt: input.prompt }),
44
+ ...(input.width === undefined ? {} : { width: input.width }),
45
+ ...(input.height === undefined ? {} : { height: input.height }),
46
+ };
47
+ }
@@ -0,0 +1,56 @@
1
+ import type { ChannelAction } from './channel.js';
2
+ export interface KbFixture {
3
+ chunkId: string;
4
+ title: string | null;
5
+ text: string;
6
+ score?: number;
7
+ }
8
+ export interface FakeCoreOptions {
9
+ /** What `ctx.kb.search()` returns. A function, so a fixture can vary by query. */
10
+ kb?: KbFixture[] | ((query: string) => KbFixture[]);
11
+ /** What `ctx.history()` returns. */
12
+ history?: Array<{
13
+ body: string;
14
+ authorKind: string;
15
+ position: number;
16
+ }>;
17
+ tenantId?: string;
18
+ }
19
+ export interface Delivered {
20
+ /** What the pack posted back, or null if it never did. */
21
+ action: (ChannelAction & Record<string, unknown>) | null;
22
+ /** The HTTP status the pack answered the delivery with. */
23
+ status: number;
24
+ }
25
+ export interface FakeCore {
26
+ /** Point the pack here: its `coreUrl`, and the issuer of every grant below. */
27
+ url: string;
28
+ /** And here: the audience every minted grant will name. */
29
+ audienceFor(packUrl: string): string;
30
+ /**
31
+ * Deliver a customer message to a pack and wait for its answer.
32
+ *
33
+ * Waits for the POST-BACK rather than for the 202, because the 202 means only that the
34
+ * pack accepted the work. A test that asserted on the acknowledgement would pass against
35
+ * a pack that never answers — which is the single commonest way a first pack is broken.
36
+ */
37
+ deliver(input: {
38
+ to: string;
39
+ text: string | string[];
40
+ conversationId?: string;
41
+ deliveryId?: string;
42
+ contact?: {
43
+ id: string;
44
+ handle: string | null;
45
+ name: string | null;
46
+ };
47
+ binding?: Record<string, unknown>;
48
+ language?: {
49
+ customer: string | null;
50
+ };
51
+ contract?: number;
52
+ timeoutMs?: number;
53
+ }): Promise<Delivered>;
54
+ close(): Promise<void>;
55
+ }
56
+ export declare function startFakeCore(options?: FakeCoreOptions): Promise<FakeCore>;
@@ -0,0 +1,136 @@
1
+ // A core that is not running, for proving a pack before anything is deployed.
2
+ //
3
+ // WHAT THIS IS FOR. Today the shortest path to knowing whether a pack works is: deploy it,
4
+ // publish an entry, install it on a tenant, bind it to a channel, send a real message, and
5
+ // then read the database. Every one of those steps can fail for reasons that have nothing
6
+ // to do with the pack, and the failure looks the same from the author's side — core's own
7
+ // e2e script says so in its comments: "a dispatch that never reached it and an answer that
8
+ // never came back look identical from here".
9
+ //
10
+ // So this mints grants with a real keypair, serves the three endpoints a pack actually
11
+ // calls, receives the post-back, and hands it to you. A pack test becomes: deliver this,
12
+ // assert that.
13
+ //
14
+ // IT REFUSES TO RUN IN PRODUCTION. It mints grants that its own JWKS validates, which is
15
+ // the whole point in a test and a way to accept forged grants anywhere else.
16
+ import { createServer } from 'node:http';
17
+ import { exportJWK, generateKeyPair, SignJWT } from 'jose';
18
+ const UUID_NS = '00000000-0000-4000-8000-0000000000';
19
+ export async function startFakeCore(options = {}) {
20
+ if (process.env.NODE_ENV === 'production') {
21
+ throw new Error('[keemakr] startFakeCore mints grants its own JWKS validates. That is the point in a ' +
22
+ 'test and a way to accept forged grants anywhere else — it refuses to run in production.');
23
+ }
24
+ const tenantId = options.tenantId ?? `${UUID_NS}01`;
25
+ const { publicKey, privateKey } = await generateKeyPair('RS256');
26
+ const jwk = { ...(await exportJWK(publicKey)), kid: 'testkit', alg: 'RS256', use: 'sig' };
27
+ /** Resolved when a pack posts its answer back for a given delivery. */
28
+ const waiting = new Map();
29
+ const server = createServer((req, res) => {
30
+ const url = new URL(req.url ?? '/', 'http://localhost');
31
+ const json = (status, body) => {
32
+ res.writeHead(status, { 'content-type': 'application/json' });
33
+ res.end(JSON.stringify(body));
34
+ };
35
+ if (url.pathname === '/.well-known/jwks.json')
36
+ return json(200, { keys: [jwk] });
37
+ if (url.pathname === '/api/capability/kb/retrieve') {
38
+ let raw = '';
39
+ req.on('data', (c) => (raw += c));
40
+ req.on('end', () => {
41
+ const query = JSON.parse(raw || '{}').query ?? '';
42
+ const hits = typeof options.kb === 'function' ? options.kb(query) : (options.kb ?? []);
43
+ json(200, {
44
+ hits: hits.map((h) => ({
45
+ chunk_id: h.chunkId,
46
+ title: h.title,
47
+ text: h.text,
48
+ score: h.score ?? 0.5,
49
+ })),
50
+ });
51
+ });
52
+ return;
53
+ }
54
+ if (url.pathname.endsWith('/actions')) {
55
+ let raw = '';
56
+ req.on('data', (c) => (raw += c));
57
+ req.on('end', () => {
58
+ const action = JSON.parse(raw || '{}');
59
+ const resolve = waiting.get(String(action.delivery_id));
60
+ if (resolve) {
61
+ waiting.delete(String(action.delivery_id));
62
+ resolve(action);
63
+ }
64
+ json(200, { ok: true });
65
+ });
66
+ return;
67
+ }
68
+ // Everything else is the history read.
69
+ return json(200, { messages: options.history ?? [] });
70
+ });
71
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
72
+ const port = server.address().port;
73
+ const url = `http://127.0.0.1:${port}`;
74
+ async function mint(conversationId, audience) {
75
+ return new SignJWT({
76
+ tenant_id: tenantId,
77
+ channel_conversation_id: conversationId,
78
+ // The REAL ceiling, not a permissive one. A testkit that minted broader scopes than
79
+ // core does would let a pack pass its own tests while calling something production
80
+ // refuses — which is worse than no test, because it is a test that lies.
81
+ scopes: ['channel:history', 'channel:respond', 'kb:retrieve'],
82
+ external_kee: true,
83
+ trace_id: `testkit-${Date.now()}`,
84
+ })
85
+ .setProtectedHeader({ alg: 'RS256', kid: 'testkit' })
86
+ .setIssuer('keemakr')
87
+ .setAudience(audience)
88
+ .setExpirationTime('5m')
89
+ .sign(privateKey);
90
+ }
91
+ return {
92
+ url,
93
+ audienceFor(packUrl) {
94
+ return new URL(packUrl).origin;
95
+ },
96
+ async deliver(input) {
97
+ const conversationId = input.conversationId ?? `${UUID_NS}c1`;
98
+ const deliveryId = input.deliveryId ?? `${UUID_NS}d${Math.floor(Math.random() * 90 + 10)}`;
99
+ const bodies = Array.isArray(input.text) ? input.text : [input.text];
100
+ const token = await mint(conversationId, new URL(input.to).origin);
101
+ const answered = new Promise((resolve) => {
102
+ waiting.set(deliveryId, resolve);
103
+ setTimeout(() => {
104
+ if (waiting.delete(deliveryId))
105
+ resolve(null);
106
+ }, input.timeoutMs ?? 10_000).unref?.();
107
+ });
108
+ const res = await fetch(input.to, {
109
+ method: 'POST',
110
+ headers: {
111
+ authorization: `Bearer ${token}`,
112
+ 'content-type': 'application/json',
113
+ 'idempotency-key': deliveryId,
114
+ },
115
+ body: JSON.stringify({
116
+ event: 'message.received',
117
+ delivery_id: deliveryId,
118
+ conversation: { id: conversationId },
119
+ contact: input.contact ?? { id: `${UUID_NS}a1`, handle: 'v_test', name: null },
120
+ unanswered_messages: bodies.map((body, i) => ({ body, position: i + 1 })),
121
+ truncated_messages: 0,
122
+ ...(input.contract !== undefined ? { contract: input.contract } : {}),
123
+ ...(input.binding ? { binding: input.binding } : {}),
124
+ ...(input.language ? { language: input.language } : {}),
125
+ }),
126
+ });
127
+ return {
128
+ status: res.status,
129
+ action: (await answered),
130
+ };
131
+ },
132
+ async close() {
133
+ await new Promise((resolve) => server.close(() => resolve()));
134
+ },
135
+ };
136
+ }
@@ -0,0 +1,159 @@
1
+ /** The envelope core POSTs. Loose by rule — core adds fields without asking packs first. */
2
+ export interface ChannelDelivery {
3
+ event: string;
4
+ delivery_id: string;
5
+ conversation: {
6
+ id: string;
7
+ };
8
+ contact: {
9
+ id: string;
10
+ handle: string | null;
11
+ name: string | null;
12
+ };
13
+ unanswered_messages: Array<{
14
+ body: string;
15
+ position: number;
16
+ }>;
17
+ truncated_messages: number;
18
+ contract?: number;
19
+ binding?: {
20
+ id: string;
21
+ autonomy: 'supervised' | 'assisted' | 'autonomous';
22
+ settings: Record<string, unknown>;
23
+ settings_version: number;
24
+ };
25
+ language?: {
26
+ customer: string | null;
27
+ };
28
+ [key: string]: unknown;
29
+ }
30
+ /** What a pack proposes. Core decides what happens to it. */
31
+ export type ChannelAction = {
32
+ action: 'reply';
33
+ text: string;
34
+ confidence?: number;
35
+ grounded_on?: string[];
36
+ usage: Usage;
37
+ } | {
38
+ action: 'handoff';
39
+ reason: HandoffReason;
40
+ usage: Usage;
41
+ } | {
42
+ action: 'none';
43
+ usage: Usage;
44
+ };
45
+ export type HandoffReason = 'customer_requested' | 'no_answer' | 'agent_failed';
46
+ export interface Usage {
47
+ input_tokens: number;
48
+ output_tokens: number;
49
+ }
50
+ export interface ChannelContext {
51
+ tenantId: string;
52
+ conversationId: string;
53
+ deliveryId: string;
54
+ /** THE WHOLE UNANSWERED RUN. A customer who split a question across three bubbles asked
55
+ * one question, and a pack handed only the newest line answers the fragment. */
56
+ messages: Array<{
57
+ body: string;
58
+ position: number;
59
+ }>;
60
+ /** How many OLDER messages did not fit the tenant's budget. Said, not hidden. */
61
+ truncated: number;
62
+ contact: {
63
+ id: string;
64
+ handle: string | null;
65
+ name: string | null;
66
+ };
67
+ /** What this business wants its agent to be. Absent against a core that predates it. */
68
+ binding?: ChannelDelivery['binding'];
69
+ /** The customer's apparent language, or null when core looked and could not say. */
70
+ language?: {
71
+ customer: string | null;
72
+ };
73
+ /** Earlier turns in this conversation, under `channel:history`. */
74
+ history(limit?: number): Promise<Array<{
75
+ body: string;
76
+ authorKind: string;
77
+ position: number;
78
+ }>>;
79
+ /** This tenant's customer-safe knowledge, under `kb:retrieve`. */
80
+ kb: {
81
+ search(query: string, opts?: {
82
+ limit?: number;
83
+ }): Promise<Array<{
84
+ chunkId: string;
85
+ title: string | null;
86
+ text: string;
87
+ score: number;
88
+ }>>;
89
+ };
90
+ /** Pure builders. They construct an action; they do not send anything. */
91
+ reply(text: string, opts?: {
92
+ confidence?: number;
93
+ groundedOn?: string[];
94
+ usage?: Usage;
95
+ }): ChannelAction;
96
+ handoff(reason: HandoffReason, opts?: {
97
+ usage?: Usage;
98
+ }): ChannelAction;
99
+ none(opts?: {
100
+ usage?: Usage;
101
+ }): ChannelAction;
102
+ }
103
+ export interface ChannelAgentOptions {
104
+ /**
105
+ * The core this pack answers for.
106
+ *
107
+ * Everything trusted flows from here: the JWKS that proves a grant is real, and the API
108
+ * the answer is posted back to. A wrong value is not a broken pack — it is a pack that
109
+ * trusts somebody else's signatures, so it is validated at construction rather than at
110
+ * the first request.
111
+ */
112
+ coreUrl?: string;
113
+ /**
114
+ * The audience every grant must name, and it is REQUIRED here even though `verifyGrant`
115
+ * tolerates its absence.
116
+ *
117
+ * Without it a grant minted for any other deployment of any other pack verifies against
118
+ * this one. `verifyGrant` keeps the lenient behaviour because packs depend on it today;
119
+ * a helper being written for the first time has no such excuse.
120
+ */
121
+ audience: string;
122
+ /** The one function a pack author writes. */
123
+ answer: (ctx: ChannelContext) => Promise<ChannelAction> | ChannelAction;
124
+ /** The platform's keep-alive. On Vercel: `waitUntil` from `@vercel/functions`. */
125
+ waitUntil?: (promise: Promise<unknown>) => void;
126
+ /** Injected for tests. Defaults to global fetch. */
127
+ fetchImpl?: typeof fetch;
128
+ }
129
+ /**
130
+ * Refuse a core URL that cannot be trusted, at construction.
131
+ *
132
+ * The JWKS URL is derived from this with no scheme check anywhere below, so a pack pointed
133
+ * at `http://` fetches its trust anchor over a connection anybody on the path can rewrite —
134
+ * which means anybody on the path can mint grants it will accept. Loopback is exempted
135
+ * because local development is the reason the check is survivable at all, and loopback is
136
+ * then refused under NODE_ENV=production because "it worked on my machine" is precisely how
137
+ * that exemption reaches a deployment.
138
+ */
139
+ export declare function assertTrustworthyCore(raw: string): URL;
140
+ export interface ChannelAgent {
141
+ /** For a fetch-style platform (Vercel, Workers, Bun, Deno). */
142
+ fetchHandler(req: Request, ctx?: {
143
+ waitUntil?: (p: Promise<unknown>) => void;
144
+ }): Promise<Response>;
145
+ /**
146
+ * The same answer, computed INLINE and returned rather than posted back.
147
+ *
148
+ * WHY A SEPARATE HANDLER AND NOT A FLAG. The delivery path is asynchronous because a real
149
+ * customer's turn must survive the pack being slow; a preview is somebody staring at a
150
+ * settings drawer, where an answer that arrives on a later request is no answer at all.
151
+ * Same verification, same context, same `answer` — only the ending differs.
152
+ *
153
+ * It writes nothing. The grant core mints for this names a conversation that does not
154
+ * exist, so the post-back route would refuse it anyway; returning the proposal is the
155
+ * honest shape rather than a side effect somebody has to remember to suppress.
156
+ */
157
+ previewHandler(req: Request): Promise<Response>;
158
+ }
159
+ export declare function createChannelAgent(options: ChannelAgentOptions): ChannelAgent;
@@ -0,0 +1,359 @@
1
+ // Answering a customer on a channel, without writing the security-critical half yourself.
2
+ //
3
+ // WHAT THIS REPLACES. The reference pack hand-rolled seventy-eight lines of grant
4
+ // verification, because `verifyGrant` drops the one claim a channel turn depends on — the
5
+ // conversation this grant is bound to — and it parsed an envelope that existed only as an
6
+ // inline object literal inside core. Every pack after it would have copied that file, and a
7
+ // copy of security-critical code is a copy that stops being reviewed.
8
+ //
9
+ // THE SHAPE OF THE CONTRACT, which is not obvious and is the thing most first packs get
10
+ // wrong: core POSTs a delivery, the pack ACKNOWLEDGES within seconds, and the answer comes
11
+ // back later on a SEPARATE request. Holding the connection open for the whole think is what
12
+ // a first implementation does and what a serverless platform kills. So `answer` runs after
13
+ // the response, inside the platform's `waitUntil`, and the helper posts the result back.
14
+ //
15
+ // `answer` RETURNS its action rather than calling something. That makes "exactly one
16
+ // proposal per delivery" a property of the type rather than of everybody remembering, and
17
+ // it makes `answer` a pure function that can be unit-tested with no core at all — fake or
18
+ // otherwise.
19
+ import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
20
+ import { jwksUrlFor, resolveJwksUrl } from './jwks.js';
21
+ const GRANT_ISSUER = 'keemakr';
22
+ /** The only delivery-envelope version this SDK speaks. */
23
+ const SUPPORTED_CONTRACT = 1;
24
+ /** How long a delivery id is remembered, so a core retry does not pay for a second think. */
25
+ const SEEN_TTL_MS = 90_000;
26
+ const SEEN_MAX = 500;
27
+ /**
28
+ * Refuse a core URL that cannot be trusted, at construction.
29
+ *
30
+ * The JWKS URL is derived from this with no scheme check anywhere below, so a pack pointed
31
+ * at `http://` fetches its trust anchor over a connection anybody on the path can rewrite —
32
+ * which means anybody on the path can mint grants it will accept. Loopback is exempted
33
+ * because local development is the reason the check is survivable at all, and loopback is
34
+ * then refused under NODE_ENV=production because "it worked on my machine" is precisely how
35
+ * that exemption reaches a deployment.
36
+ */
37
+ export function assertTrustworthyCore(raw) {
38
+ let url;
39
+ try {
40
+ url = new URL(raw);
41
+ }
42
+ catch {
43
+ throw new Error(`[keemakr] KEE_CORE_URL is not a URL: ${raw}`);
44
+ }
45
+ const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]';
46
+ if (url.protocol !== 'https:' && !loopback) {
47
+ throw new Error(`[keemakr] refusing to trust ${url.origin} over ${url.protocol} — the JWKS fetched ` +
48
+ 'from it is what proves a grant is real, so anybody on the path could mint grants ' +
49
+ 'this pack accepts. Use https, or localhost for development.');
50
+ }
51
+ if (loopback && process.env.NODE_ENV === 'production') {
52
+ throw new Error(`[keemakr] refusing to trust ${url.origin} in production — a deployed pack pointed at ` +
53
+ 'loopback verifies nothing.');
54
+ }
55
+ return url;
56
+ }
57
+ /** Verify, keeping the claim `verifyGrant` drops. */
58
+ async function verifyChannelGrant(token, keys, audience) {
59
+ try {
60
+ const { payload } = await jwtVerify(token, keys, {
61
+ issuer: GRANT_ISSUER,
62
+ audience,
63
+ });
64
+ if (typeof payload.tenant_id !== 'string' || !Array.isArray(payload.scopes))
65
+ return null;
66
+ return {
67
+ tenantId: payload.tenant_id,
68
+ // THE CLAIM THE WHOLE LANE RESTS ON, and the one `verifyGrant` throws away. Without
69
+ // it a live grant can post an answer into ANY conversation in the tenant for its
70
+ // whole life — Idempotency-Key is deduplication, not authorization.
71
+ conversationId: typeof payload.channel_conversation_id === 'string'
72
+ ? payload.channel_conversation_id
73
+ : null,
74
+ scopes: payload.scopes.map(String),
75
+ traceId: typeof payload.trace_id === 'string' ? payload.trace_id : null,
76
+ };
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ /** Remembered deliveries, so core's retry does not buy a second model call. */
83
+ class SeenDeliveries {
84
+ seen = new Map();
85
+ /** True if this is new. Keyed on VERIFIED ids only — see the note at the call site. */
86
+ admit(key, now) {
87
+ for (const [k, at] of this.seen) {
88
+ if (now - at > SEEN_TTL_MS)
89
+ this.seen.delete(k);
90
+ }
91
+ if (this.seen.has(key))
92
+ return false;
93
+ if (this.seen.size >= SEEN_MAX) {
94
+ const oldest = this.seen.keys().next().value;
95
+ if (oldest !== undefined)
96
+ this.seen.delete(oldest);
97
+ }
98
+ this.seen.set(key, now);
99
+ return true;
100
+ }
101
+ forget(key) {
102
+ this.seen.delete(key);
103
+ }
104
+ }
105
+ export function createChannelAgent(options) {
106
+ if (!options.audience?.trim()) {
107
+ // AT CONSTRUCTION, not at the first delivery. A pack that boots and then refuses every
108
+ // customer looks like a broken product; a pack that refuses to boot looks like a
109
+ // missing environment variable, which is what it is.
110
+ throw new Error("[keemakr] createChannelAgent needs an `audience`: this deployment's exact origin, " +
111
+ "the same URL registered as the entry's runtime. Without it a grant minted for any " +
112
+ 'other pack verifies here.');
113
+ }
114
+ const coreRaw = options.coreUrl ?? process.env.KEE_CORE_URL;
115
+ if (!coreRaw) {
116
+ throw new Error('[keemakr] createChannelAgent needs `coreUrl` or KEE_CORE_URL.');
117
+ }
118
+ const core = assertTrustworthyCore(coreRaw);
119
+ const jwksUrl = resolveJwksUrl() ?? jwksUrlFor(core.origin);
120
+ const fetchImpl = options.fetchImpl ?? fetch;
121
+ // The keyset fetches through the SAME fetch as everything else. Its own cache is what
122
+ // makes this one round trip per key rotation rather than one per delivery, and routing it
123
+ // through the injected fetch is what lets a test — or a pack behind a proxy — serve it.
124
+ const keys = createRemoteJWKSet(new URL(jwksUrl), { [customFetch]: fetchImpl });
125
+ const seen = new SeenDeliveries();
126
+ // SAID AT BOOT, once. A pack pointed at the wrong core fails in a way that looks like a
127
+ // core outage from every angle except this line.
128
+ console.log(`[keemakr] channel agent trusting ${core.origin} (audience ${options.audience})`);
129
+ async function handle(req, waitUntil) {
130
+ const bearer = req.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ?? '';
131
+ if (!bearer)
132
+ return refuse(401, 'no_grant');
133
+ const claims = await verifyChannelGrant(bearer, keys, options.audience);
134
+ if (!claims)
135
+ return refuse(401, 'grant_invalid');
136
+ if (!claims.scopes.includes('channel:respond'))
137
+ return refuse(403, 'missing_channel_respond');
138
+ let body;
139
+ try {
140
+ body = (await req.json());
141
+ }
142
+ catch {
143
+ return refuse(400, 'invalid_json');
144
+ }
145
+ // THE GRANT NAMES ONE CONVERSATION, and this is where that is enforced. The reference
146
+ // pack parsed this claim and never compared it, which made the binding decorative.
147
+ if (!claims.conversationId || claims.conversationId !== body.conversation?.id) {
148
+ return refuse(403, 'conversation_mismatch');
149
+ }
150
+ // A CONTRACT THIS SDK DOES NOT SPEAK IS A REFUSAL, NOT A GUESS. Absent means a core
151
+ // that predates the field. Anything else means core has changed the envelope's SHAPE,
152
+ // and answering it with v1 assumptions is how a pack confidently misreads a customer.
153
+ if (body.contract !== undefined && body.contract !== SUPPORTED_CONTRACT) {
154
+ return refuse(400, 'contract_unsupported');
155
+ }
156
+ // DEDUPED AFTER VERIFICATION, on ids the signature vouches for. Keying on the raw
157
+ // Idempotency-Key header would let an unauthenticated caller suppress a real delivery
158
+ // by guessing one, which is a denial of service dressed as an optimisation.
159
+ const key = `${claims.tenantId}:${body.conversation.id}:${body.delivery_id}`;
160
+ if (!seen.admit(key, Date.now()))
161
+ return accept('duplicate');
162
+ const work = think(claims, body, bearer).catch((cause) => {
163
+ // The delivery is forgotten so core's retry can be answered. The alternative — a
164
+ // remembered id and a thrown think — is a conversation that is never answered again
165
+ // and no record anywhere of why.
166
+ seen.forget(key);
167
+ console.error('[keemakr] channel turn failed', cause?.message);
168
+ });
169
+ // THE WORK RUNS AFTER THE RESPONSE, which is the whole reason this helper exists. A
170
+ // bare `void think()` after returning is what a first pack writes and what a serverless
171
+ // platform freezes at response end — every delivery then times out at core's answer
172
+ // deadline, and the symptom is "the agent is slow" rather than "the agent was killed".
173
+ if (waitUntil)
174
+ waitUntil(work);
175
+ else
176
+ void work;
177
+ return accept('accepted');
178
+ }
179
+ async function think(claims, body, bearer) {
180
+ const ctx = buildContext(claims, body, bearer);
181
+ let action;
182
+ try {
183
+ action = await options.answer(ctx);
184
+ }
185
+ catch (cause) {
186
+ // A PACK THAT THREW STILL OWES CORE AN ANSWER. Silence costs the customer the full
187
+ // answer deadline — ninety seconds of a chat window doing nothing — and then raises
188
+ // `answer_timed_out`, which reads as "the pack is hanging" rather than "the pack
189
+ // crashed". A handoff puts a person on it now and says which.
190
+ console.error('[keemakr] answer() threw', cause?.message);
191
+ action = {
192
+ action: 'handoff',
193
+ reason: 'agent_failed',
194
+ usage: { input_tokens: 0, output_tokens: 0 },
195
+ };
196
+ }
197
+ await postBack(claims, body, bearer, action);
198
+ }
199
+ function buildContext(claims, body, bearer) {
200
+ const authed = (path, init = {}) => fetchImpl(new URL(path, core.origin).toString(), {
201
+ ...init,
202
+ headers: {
203
+ ...init.headers,
204
+ authorization: `Bearer ${bearer}`,
205
+ 'content-type': 'application/json',
206
+ },
207
+ });
208
+ return {
209
+ tenantId: claims.tenantId,
210
+ conversationId: body.conversation.id,
211
+ deliveryId: body.delivery_id,
212
+ messages: body.unanswered_messages ?? [],
213
+ truncated: body.truncated_messages ?? 0,
214
+ contact: body.contact,
215
+ binding: body.binding,
216
+ language: body.language,
217
+ async history(limit = 20) {
218
+ const res = await authed(`/api/channels/conversations/${encodeURIComponent(body.conversation.id)}?limit=${limit}`);
219
+ if (!res.ok)
220
+ return [];
221
+ const json = (await res.json());
222
+ return json.messages ?? [];
223
+ },
224
+ kb: {
225
+ async search(query, opts) {
226
+ const res = await authed('/api/capability/kb/retrieve', {
227
+ method: 'POST',
228
+ body: JSON.stringify({ query, limit: opts?.limit ?? 3 }),
229
+ });
230
+ if (!res.ok)
231
+ return [];
232
+ const json = (await res.json());
233
+ return (json.hits ?? []).map((h) => ({
234
+ chunkId: String(h.chunk_id ?? ''),
235
+ title: h.title ?? null,
236
+ text: String(h.text ?? ''),
237
+ score: Number(h.score ?? 0),
238
+ }));
239
+ },
240
+ },
241
+ // PURE BUILDERS. They return an action and touch nothing — so a pack's `answer` can
242
+ // be tested by calling it and inspecting what came back, with no core, no fake and no
243
+ // network.
244
+ reply(text, opts) {
245
+ return {
246
+ action: 'reply',
247
+ text,
248
+ ...(opts?.confidence !== undefined ? { confidence: opts.confidence } : {}),
249
+ ...(opts?.groundedOn ? { grounded_on: opts.groundedOn } : {}),
250
+ usage: opts?.usage ?? { input_tokens: 0, output_tokens: 0 },
251
+ };
252
+ },
253
+ handoff(reason, opts) {
254
+ return {
255
+ action: 'handoff',
256
+ reason,
257
+ usage: opts?.usage ?? { input_tokens: 0, output_tokens: 0 },
258
+ };
259
+ },
260
+ none(opts) {
261
+ return { action: 'none', usage: opts?.usage ?? { input_tokens: 0, output_tokens: 0 } };
262
+ },
263
+ };
264
+ }
265
+ async function postBack(claims, body, bearer, action) {
266
+ const res = await fetchImpl(new URL(`/api/channels/conversations/${encodeURIComponent(body.conversation.id)}/actions`, core.origin).toString(), {
267
+ method: 'POST',
268
+ headers: {
269
+ authorization: `Bearer ${bearer}`,
270
+ 'content-type': 'application/json',
271
+ 'idempotency-key': body.delivery_id,
272
+ ...(claims.traceId ? { 'x-keemakr-trace-id': claims.traceId } : {}),
273
+ },
274
+ body: JSON.stringify({
275
+ delivery_id: body.delivery_id,
276
+ ...action,
277
+ // ALWAYS, on every action including `none`. A pack that omits usage meters the
278
+ // whole lane at zero forever, and model tokens are most of what a conversation
279
+ // costs — so the one number that makes the bill visible is the one an author is
280
+ // most likely to forget.
281
+ usage: action.usage ?? { input_tokens: 0, output_tokens: 0 },
282
+ }),
283
+ });
284
+ if (!res.ok) {
285
+ console.error(`[keemakr] core refused the answer: ${res.status}`);
286
+ }
287
+ }
288
+ async function preview(req) {
289
+ const bearer = req.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ?? '';
290
+ if (!bearer)
291
+ return refuse(401, 'no_grant');
292
+ const claims = await verifyChannelGrant(bearer, keys, options.audience);
293
+ if (!claims)
294
+ return refuse(401, 'grant_invalid');
295
+ // THE SAME SCOPE AS ANSWERING, deliberately. A preview reads the tenant's knowledge and
296
+ // runs their model budget; a weaker gate here would be a way to do both without being
297
+ // allowed to answer anybody.
298
+ if (!claims.scopes.includes('channel:respond'))
299
+ return refuse(403, 'missing_channel_respond');
300
+ let body;
301
+ try {
302
+ body = (await req.json());
303
+ }
304
+ catch {
305
+ return refuse(400, 'invalid_json');
306
+ }
307
+ if (!claims.conversationId || claims.conversationId !== body.conversation?.id) {
308
+ return refuse(403, 'conversation_mismatch');
309
+ }
310
+ // AND THE SAME ENVELOPE CHECK AS A DELIVERY. This was in `handle` and not here, which
311
+ // is the one divergence that defeats the feature's purpose: a binding whose pack speaks
312
+ // a contract this SDK does not is correctly refused on the real path and silently
313
+ // ANSWERED on the preview path — so the owner is shown, under v1 assumptions, something
314
+ // no customer of theirs would ever receive. A preview that lies is worse than no
315
+ // preview, because it is trusted.
316
+ if (body.contract !== undefined && body.contract !== SUPPORTED_CONTRACT) {
317
+ return refuse(400, 'contract_unsupported');
318
+ }
319
+ const started = Date.now();
320
+ try {
321
+ const action = await options.answer(buildContext(claims, body, bearer));
322
+ return new Response(JSON.stringify({ action, latency_ms: Date.now() - started }), {
323
+ status: 200,
324
+ headers: { 'content-type': 'application/json' },
325
+ });
326
+ }
327
+ catch (cause) {
328
+ // SHOWN, not swallowed. The whole point of a preview is to find out why an agent is
329
+ // behaving oddly, and "it threw" is the most useful answer it can give.
330
+ return new Response(JSON.stringify({
331
+ action: {
332
+ action: 'handoff',
333
+ reason: 'agent_failed',
334
+ usage: { input_tokens: 0, output_tokens: 0 },
335
+ },
336
+ error: cause?.message ?? 'unknown',
337
+ latency_ms: Date.now() - started,
338
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
339
+ }
340
+ }
341
+ return {
342
+ fetchHandler(req, ctx) {
343
+ return handle(req, ctx?.waitUntil ?? options.waitUntil);
344
+ },
345
+ previewHandler: preview,
346
+ };
347
+ }
348
+ function refuse(status, code) {
349
+ return new Response(JSON.stringify({ error: code }), {
350
+ status,
351
+ headers: { 'content-type': 'application/json' },
352
+ });
353
+ }
354
+ function accept(state) {
355
+ return new Response(JSON.stringify({ accepted: true, state }), {
356
+ status: 202,
357
+ headers: { 'content-type': 'application/json' },
358
+ });
359
+ }
package/dist/index.d.ts CHANGED
@@ -3,3 +3,5 @@ export { verifyGrant, type VerifiedGrant } from './verify-grant.js';
3
3
  export { useKee, MemoryConflictError, RecordConflictError, RecordValidationError, type Kee, type KeeConnection, type KeeContext, type KeeError, type KeeMemory, type KeeRecords, type KeeKb, type KBHit, type KeeTools, type KeeConfig, type MemoryEntry, type MemorySearchHit, type RecordEntry, } from './client.js';
4
4
  export { keemakrToolDirectory } from './tool-directory.js';
5
5
  export { refreshGrant, REFRESH_THRESHOLD_SECONDS } from './refresh.js';
6
+ export { imageCanvas, type Canvas, type ImageCanvas, type ReportCanvas } from './canvases.js';
7
+ export { createChannelAgent, assertTrustworthyCore, type ChannelAction, type ChannelAgent, type ChannelAgentOptions, type ChannelContext, type ChannelDelivery, type HandoffReason, type Usage, } from './channel.js';
package/dist/index.js CHANGED
@@ -13,3 +13,5 @@ export { verifyGrant } from './verify-grant.js';
13
13
  export { useKee, MemoryConflictError, RecordConflictError, RecordValidationError, } from './client.js';
14
14
  export { keemakrToolDirectory } from './tool-directory.js';
15
15
  export { refreshGrant, REFRESH_THRESHOLD_SECONDS } from './refresh.js';
16
+ export { imageCanvas } from './canvases.js';
17
+ export { createChannelAgent, assertTrustworthyCore, } from './channel.js';
@@ -7,15 +7,6 @@ export interface VerifiedGrant {
7
7
  aud: string | null;
8
8
  exp: number | null;
9
9
  }
10
- /**
11
- * Verify a capability grant (session OR machine) against keemakr-core's JWKS.
12
- * Returns the trusted claims, or `null` on any failure (bad signature, wrong
13
- * issuer/audience, expired). `jwksUrl` falls back to KEE_CORE_JWKS_URL, then to
14
- * KEE_CORE_URL's origin — and to nothing after that, so a deployment that has not
15
- * named its platform refuses every grant rather than trusting a compiled-in
16
- * default it did not choose. `audience` falls back to KEE_AGENT_AUDIENCE and is
17
- * strongly recommended: a grant is only valid for the remote it was minted for.
18
- */
19
10
  export declare function verifyGrant(token: string, opts?: {
20
11
  jwksUrl?: string;
21
12
  audience?: string;
@@ -22,6 +22,16 @@ const GRANT_ISSUER = 'keemakr';
22
22
  * default it did not choose. `audience` falls back to KEE_AGENT_AUDIENCE and is
23
23
  * strongly recommended: a grant is only valid for the remote it was minted for.
24
24
  */
25
+ let audienceWarned = false;
26
+ /** Said once per process. A per-request warning is noise, and noise is not a warning. */
27
+ function warnAudienceUnset() {
28
+ if (audienceWarned)
29
+ return;
30
+ audienceWarned = true;
31
+ console.warn('[keemakr] KEE_AGENT_AUDIENCE is not set, so this grant is accepted whoever it was ' +
32
+ "minted for. Set it to this deployment's exact origin — the same URL registered as " +
33
+ "the entry's runtime — so a grant minted for another pack cannot be replayed here.");
34
+ }
25
35
  export async function verifyGrant(token, opts) {
26
36
  const jwksUrl = resolveJwksUrl(opts?.jwksUrl);
27
37
  if (!jwksUrl) {
@@ -29,6 +39,19 @@ export async function verifyGrant(token, opts) {
29
39
  return null;
30
40
  }
31
41
  const expectedAud = opts?.audience ?? process.env.KEE_AGENT_AUDIENCE;
42
+ // AN UNSET AUDIENCE IS A REAL RISK, SAID OUT LOUD ONCE.
43
+ //
44
+ // `jwtVerify` is given no `audience` option when this is empty, which means the grant is
45
+ // accepted whoever it was minted FOR — any core-signed token for any deployment of any
46
+ // pack passes. The behaviour is kept, because changing it would break every pack running
47
+ // without the variable today and a silent tightening is worse than a loud gap. But it was
48
+ // completely silent, which made the unsafe configuration the easy one: nothing at build
49
+ // time, nothing at boot, nothing in the log, and the symptom only appears if somebody
50
+ // points a different deployment at this one.
51
+ //
52
+ // Once per process, not per request: a warning on every turn is a warning nobody reads.
53
+ if (!expectedAud)
54
+ warnAudienceUnset();
32
55
  try {
33
56
  const { payload } = await jwtVerify(token, jwksFor(jwksUrl), {
34
57
  issuer: GRANT_ISSUER,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keemakr/agent-sdk",
3
- "version": "0.15.0",
3
+ "version": "0.16.0-dev-20260925092950",
4
4
  "description": "The floor for keemakr marketplace agents: verify the capability grant and reach tenant connections, memory, and shared platform tools through keemakr-core — without holding raw secrets or resolving the tenant yourself.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,14 @@
18
18
  "./connectors": {
19
19
  "types": "./dist/connectors.d.ts",
20
20
  "import": "./dist/connectors.js"
21
+ },
22
+ "./channel": {
23
+ "types": "./dist/channel.d.ts",
24
+ "import": "./dist/channel.js"
25
+ },
26
+ "./channel/testkit": {
27
+ "types": "./dist/channel-testkit.d.ts",
28
+ "import": "./dist/channel-testkit.js"
21
29
  }
22
30
  },
23
31
  "files": [
@@ -43,6 +51,11 @@
43
51
  "eve": ">=0.40.0 <0.60",
44
52
  "jose": "^6.2.3"
45
53
  },
54
+ "peerDependenciesMeta": {
55
+ "eve": {
56
+ "optional": true
57
+ }
58
+ },
46
59
  "devDependencies": {
47
60
  "@types/node": "^20.19.43",
48
61
  "eve": "0.40.0",