@keemakr/agent-sdk 0.18.0 → 1.0.0-v2-preview-20261007185032

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.
package/README.md CHANGED
@@ -13,19 +13,17 @@ The tenant identity and scopes come from a short-lived signed grant the operator
13
13
  npm install @keemakr/agent-sdk
14
14
  ```
15
15
 
16
- Peer dependencies: `eve` `>=0.40.0 <0.60` and `jose@^6.2.3`. Both ends of that eve range are
17
- compiled in CI — 0.40.0 and 0.59.1 — and nothing above 0.59.1 is claimed; see the CHANGELOG for
18
- why the ceiling stops there.
16
+ Peer dependencies (match your eve agent): `eve@0.13.0`, `jose@^6.2.3`.
19
17
 
20
18
  ## Configure
21
19
 
22
20
  Set these in your deployed agent's environment:
23
21
 
24
- | Variable | Purpose |
25
- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
26
- | `KEE_CORE_URL` | **Required.** The keemakr platform you installed on, e.g. `https://dash.dev.keemakr.ai`. Used both to verify grants and to call the capability API. |
27
- | `KEE_AGENT_AUDIENCE` | This deployment's audience — your runtime URL's origin, e.g. `https://my-agent.example.com`. Must match the audience the operator mints. |
28
- | `KEE_CORE_JWKS_URL` | Only when the JWKS is not at `<KEE_CORE_URL>/.well-known/jwks.json`. Takes precedence over `KEE_CORE_URL` for verification. |
22
+ | Variable | Purpose |
23
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
24
+ | `KEE_CORE_URL` | **Required.** The keemakr platform you installed on, e.g. `https://dash.dev.keemakr.ai`. Used both to verify grants and to call the capability API. |
25
+ | `KEE_AGENT_AUDIENCE` | This deployment's audience — your runtime URL's origin (`scheme://host[:port]`, no path, no trailing slash), e.g. `https://my-agent.example.com`. Must match the audience the operator mints exactly. Runtimes on one origin share one audience. |
26
+ | `KEE_CORE_JWKS_URL` | Only when the JWKS is not at `<KEE_CORE_URL>/.well-known/jwks.json`. Takes precedence over `KEE_CORE_URL` for verification. |
29
27
 
30
28
  **There is no default platform, deliberately.** `KEE_CORE_URL` names the keys your
31
29
  agent trusts and the host it sends the tenant's grant token to. A compiled-in
@@ -173,44 +171,6 @@ await kee.tools.run('current-time'); // run one in keemakr-core
173
171
 
174
172
  A call whose grant lacks the required scope returns a `KeeError` with `status: 403`; an expired/invalid grant returns `status: 401`.
175
173
 
176
- ## Read the user's own words in a tool
177
-
178
- A delegation from Kee carries Kee's brief as the `message` and, beside it, the user's own latest message in that conversation, verbatim. eve gives that second text (`clientContext.keemakr_user_message`) to your model only. Core also signs it into the grant, so `grantAuth()` puts it on the session auth and `getUserMessage(ctx)` returns it to tool code:
179
-
180
- ```ts title="agent/tools/request_discount.ts"
181
- import { defineTool } from 'eve/tools';
182
- import { z } from 'zod';
183
- import { getUserMessage } from '@keemakr/agent-sdk';
184
-
185
- export default defineTool({
186
- description: 'Request a discount on a deal.',
187
- inputSchema: z.object({ dealId: z.string(), percent: z.number() }),
188
- // An approval policy gets the same ctx, in the turn the user wrote in. Refuse here and no
189
- // approval card is raised for a request the user never made.
190
- approval: (ctx) => {
191
- const said = getUserMessage(ctx);
192
- if (said && !said.truncated && !mentions(said.text, ctx.toolInput?.dealId)) {
193
- return { type: 'denied', reason: `the user did not name deal ${ctx.toolInput?.dealId}` };
194
- }
195
- return 'user-approval';
196
- },
197
- async execute({ dealId, percent }, ctx) {
198
- /* ... */
199
- },
200
- });
201
-
202
- const mentions = (text: string, id: unknown) =>
203
- typeof id === 'string' &&
204
- text
205
- .toUpperCase()
206
- .replace(/[^A-Z0-9]/g, '')
207
- .includes(id.toUpperCase().replace(/[^A-Z0-9]/g, ''));
208
- ```
209
-
210
- `getUserMessage(ctx)` returns `{ text, inputMode, truncated }` or `null`. It is `null` when nobody wrote anything for this turn (a routine, a dispatch with no conversation turn), inside `execute()` after a human approved (core resumes the turn with a fresh grant that does not carry the words), and on any session that a grant did not authenticate. Decide what `null` means for each tool; most should go on as before rather than refuse. `truncated` is `true` when core cut the text (at 4 KiB in the grant), so do not refuse on the **absence** of something from a truncated message.
211
-
212
- Subagents see the same value: eve passes the parent's session auth to a declared subagent.
213
-
214
174
  ## Grant refresh (automatic since 0.8.0)
215
175
 
216
176
  Every `useKee` capability call keeps its grant alive by itself: when the active token has under two minutes left, the SDK exchanges it at core's `POST /api/capability/grant/refresh` (single-flight per delegation — concurrent tool calls share one refresh), and a `401 grant_expired` gets one refresh + one retry before surfacing. You write nothing; long runs simply stop dying at the TTL.
@@ -241,6 +201,60 @@ if (!claims) throw new Error('invalid or expired grant');
241
201
 
242
202
  Inside an eve channel, `grantAuth()` already accepts machine grants (same token shape) — no extra work.
243
203
 
204
+ ## Channel turns: answer the conversations Core routes to you
205
+
206
+ A workspace binds each widget, WhatsApp number, email inbox and SMS number to one named handler in one Pack. When something happens in a conversation the handler owns, Core posts a delivery to `{runtime}/channels/v1/deliver` with a turn grant. Mount one route and name your handlers. The example passes the message to Eve with the route's `from`, which Eve has from 0.35; the SDK itself needs no newer Eve than the rest of this package:
207
+
208
+ ```ts
209
+ // agent/channels/inbound.ts
210
+ import { defineChannel, POST, type RouteHandlerArgs } from 'eve/channels';
211
+ import {
212
+ CHANNEL_DELIVERY_PATH,
213
+ defineChannelHandler,
214
+ handleChannelDelivery,
215
+ } from '@keemakr/agent-sdk';
216
+
217
+ const salesInbound = defineChannelHandler<RouteHandlerArgs<undefined>>({
218
+ async message(turn, { from }) {
219
+ // The Core conversation is the Eve session address. A rollover opens a new
220
+ // conversation, and so a new session.
221
+ await from(turn.conversationId).send(turn.event.message.text, { auth: turn.sessionAuth() });
222
+ },
223
+ async verified(turn) {
224
+ // The person proved a number or address: move the lead's journey forward.
225
+ },
226
+ });
227
+
228
+ export default defineChannel({
229
+ routes: [
230
+ POST(CHANNEL_DELIVERY_PATH, (req, route) =>
231
+ handleChannelDelivery(req, { 'sales-inbound': salesInbound }, { route }),
232
+ ),
233
+ ],
234
+ });
235
+ ```
236
+
237
+ The methods are `message` (a customer message), `sent` (the agent's own reply went out; news only), `verified` (`contact.verified`) `ownerChanged` (a person gave the conversation back) and `staffAction` (a staff member clicked one of your buttons). A method you leave out is acknowledged and nothing happens. Declare the matching subscriptions on the inbound agent with `channelSubscriptions(['widget'])`. Declare staff buttons on the inbound agent as `staffActions: [{ key: 'send-booking-link', label: 'Send booking link', choices: [...] }]` and pass `{ staffActions: true }` to `channelSubscriptions`. When the staff member who clicked owns the conversation, `turn.actingUserId` names them and the turn's replies go out under their name.
238
+
239
+ A turn acts through Core, which checks on every call that the turn still owns the conversation:
240
+
241
+ | Action | Call |
242
+ | -------------------------------------- | --------------------------------------------------------------------------- |
243
+ | Reply | `turn.reply(text)` (idempotent on the text by default) |
244
+ | Stay silent | `turn.silent()` |
245
+ | Hand over to a named person | `turn.handoff(userId, { note })` |
246
+ | Ask the person to verify | `turn.requestVerification({ kind: 'phone', value }, 'sms_code')` |
247
+ | Check a code | `turn.verifyCode(code)` |
248
+ | Send an approved WhatsApp template | `turn.sendTemplate({ name, language, variables })` |
249
+ | Publish a Pack event, visible to staff | `turn.publish(contract, payload, { idempotencyKey, visible: { summary } })` |
250
+ | Create a task | `turn.createTask(input)` |
251
+
252
+ A workspace may hold agent replies for a person to approve: all of them, or those below its confidence threshold. Pass your own 0-1 rating as `turn.reply(text, { confidence })`; a reply without one counts as below the threshold. A held reply returns `heldForReview: true` (`outcome.status` is `held_for_review`): it is not lost and not refused, so do not send it again or apologise for a delay. Staff approve it (possibly edited) or discard it; an approved reply goes out as the agent's, and the handler hears `sent` as usual.
253
+
254
+ `turn.can(action)` says what the binding allows. Publishing and tasks also need the binding's identity rule met. On WhatsApp, `turn.window` says whether the 24-hour window is open (`{ open, closesAt }`); once it closes only a template may go, and an agent may send only a UTILITY template its binding lists (`turn.can('send_template')`). A refusal throws `ChannelActionError` with Core's `code` (for example `superseded` after a staff takeover). Inside the agent's tools, `channelTurnFromContext(ctx)` gives the same actions, and `useKee(ctx)` reaches the Pack's tools with the scopes the binding allows.
255
+
256
+ `advanceJourney(journey, turn)` and `journeyContext(journey)` keep the Pack's own journey record for each Contact (stage, last channel, open items, summary) and open each new session with it.
257
+
244
258
  ## Credential & model contract
245
259
 
246
260
  - **Tenant/service credentials live in keemakr-core**, reached only via the proxy — the credential never crosses the wire. Tenant is always the verified grant, resolved server-side; never pass a tenant id from tool input.
@@ -250,17 +264,6 @@ Inside an eve channel, `grantAuth()` already accepts machine grants (same token
250
264
 
251
265
  Full contract: keemakr-core `docs/CONNECTOR-CONTRACT.md`.
252
266
 
253
- ## Building the agent itself
254
-
255
- This SDK covers the grant and capability half. The delegation half — how the Operator talks to
256
- your root, what it expects back, and what it will not do for you — is in keemakr-core:
257
-
258
- - `docs/building-department-agents.md` — declared subagents and the isolation trap, parallel
259
- fan-out as a barrier, the `DelegationResult` contract, the ONE-combined-message-per-department
260
- obligation, the ~45s promotion budget, and voice.
261
- - `docs/authoring-manifest-descriptions.md` — the manifest description is the routing signal. An
262
- empty one on the department surface stops intent routing for your department entirely.
263
-
264
267
  ## License
265
268
 
266
269
  MIT
@@ -0,0 +1,33 @@
1
+ import { type VerifiedGrant } from './verify-grant.js';
2
+ export interface CapabilityInvokeEnvelope {
3
+ operation_id: string;
4
+ capability_id: string;
5
+ version: number;
6
+ input: unknown;
7
+ context: {
8
+ tenant_id: string;
9
+ on_behalf_of: string | null;
10
+ trace_id: string | null;
11
+ task_id: string | null;
12
+ run_id: string | null;
13
+ };
14
+ }
15
+ export type CapabilityHandlerContext = CapabilityInvokeEnvelope['context'] & {
16
+ operation_id: string;
17
+ capability_id: string;
18
+ version: number;
19
+ grant: VerifiedGrant;
20
+ /** The bearer Core sent — hand it to your Kee client, never log it. */
21
+ token: string;
22
+ };
23
+ export type CapabilityHandler<I = unknown, O = unknown> = (input: I, ctx: CapabilityHandlerContext) => Promise<O>;
24
+ /** A typed business failure the caller can act on (surfaces as failure.code). */
25
+ export declare class CapabilityFailure extends Error {
26
+ readonly code: string;
27
+ readonly details?: unknown | undefined;
28
+ constructor(code: string, message: string, details?: unknown | undefined);
29
+ }
30
+ export declare function handleCapabilityInvoke(req: Request, key: string, handlers: Record<string, CapabilityHandler<never, unknown>>, opts?: {
31
+ jwksUrl?: string;
32
+ audience?: string;
33
+ }): Promise<Response>;
@@ -0,0 +1,110 @@
1
+ // handleCapabilityInvoke — the agent side of a Capability invocation.
2
+ //
3
+ // Core POSTs `{runtime}/capabilities/{key}/invoke` with a short-lived grant as
4
+ // bearer. Mount this in a channel route and hand it your handlers:
5
+ //
6
+ // import { defineChannel, POST } from 'eve/channels';
7
+ // import { handleCapabilityInvoke, CapabilityFailure } from '@keemakr/agent-sdk';
8
+ //
9
+ // export default defineChannel({
10
+ // routes: [
11
+ // POST('/capabilities/:key/invoke', (req, { params }) =>
12
+ // handleCapabilityInvoke(req, params.key, {
13
+ // 'lead.qualify': async (input, ctx) => { … return { qualified: 3 }; },
14
+ // })),
15
+ // ],
16
+ // });
17
+ //
18
+ // It verifies the grant against Core's JWKS (audience = this runtime), parses the
19
+ // envelope, dispatches to the handler for `key`, and answers in the one shape
20
+ // Core validates: `{ ok:true, output }` or `{ ok:false, failure:{code,message} }`.
21
+ // Throw CapabilityFailure for a typed business failure; anything else becomes
22
+ // `failure.code = 'unhandled'` with the message, never a stack.
23
+ //
24
+ // The handler receives the verified grant and the raw token: build your Kee
25
+ // client from them (the grant-ctx pattern) to reach records/memory/connections.
26
+ // It receives NO way to invoke another Capability — that boundary is Core's.
27
+ import { bearer } from './http.js';
28
+ import { verifyGrant } from './verify-grant.js';
29
+ /** A typed business failure the caller can act on (surfaces as failure.code). */
30
+ export class CapabilityFailure extends Error {
31
+ code;
32
+ details;
33
+ constructor(code, message, details) {
34
+ super(message);
35
+ this.code = code;
36
+ this.details = details;
37
+ this.name = 'CapabilityFailure';
38
+ }
39
+ }
40
+ function json(status, body) {
41
+ return new Response(JSON.stringify(body), {
42
+ status,
43
+ headers: { 'content-type': 'application/json' },
44
+ });
45
+ }
46
+ export async function handleCapabilityInvoke(req, key, handlers, opts) {
47
+ const token = bearer(req);
48
+ if (!token)
49
+ return json(401, {
50
+ ok: false,
51
+ failure: { code: 'grant_missing', message: 'a capability grant is required' },
52
+ });
53
+ const grant = await verifyGrant(token, opts);
54
+ if (!grant) {
55
+ return json(401, {
56
+ ok: false,
57
+ failure: { code: 'grant_rejected', message: 'the capability grant did not verify' },
58
+ });
59
+ }
60
+ const handler = handlers[key];
61
+ if (!handler) {
62
+ return json(404, {
63
+ ok: false,
64
+ failure: { code: 'unknown_capability', message: `no handler for '${key}'` },
65
+ });
66
+ }
67
+ let envelope;
68
+ try {
69
+ envelope = (await req.json());
70
+ if (!envelope || typeof envelope.operation_id !== 'string')
71
+ throw new Error('bad envelope');
72
+ }
73
+ catch {
74
+ return json(400, {
75
+ ok: false,
76
+ failure: { code: 'bad_request', message: 'invalid invocation envelope' },
77
+ });
78
+ }
79
+ // The grant's tenant is the truth; an envelope naming another tenant is refused.
80
+ if (envelope.context?.tenant_id && envelope.context.tenant_id !== grant.tenantId) {
81
+ return json(403, {
82
+ ok: false,
83
+ failure: { code: 'tenant_mismatch', message: 'envelope tenant does not match the grant' },
84
+ });
85
+ }
86
+ try {
87
+ const output = await handler(envelope.input, {
88
+ ...envelope.context,
89
+ tenant_id: grant.tenantId,
90
+ operation_id: envelope.operation_id,
91
+ capability_id: envelope.capability_id,
92
+ version: envelope.version,
93
+ grant,
94
+ token,
95
+ });
96
+ return json(200, { ok: true, output: output ?? null });
97
+ }
98
+ catch (e) {
99
+ if (e instanceof CapabilityFailure) {
100
+ return json(200, {
101
+ ok: false,
102
+ failure: { code: e.code, message: e.message, details: e.details ?? null },
103
+ });
104
+ }
105
+ return json(200, {
106
+ ok: false,
107
+ failure: { code: 'unhandled', message: e instanceof Error ? e.message : String(e) },
108
+ });
109
+ }
110
+ }
@@ -0,0 +1,109 @@
1
+ export type AgentExposure = 'general' | 'inbound';
2
+ /** The customer-message Event each channel delivers. Owned by its channel family. */
3
+ export declare const CORE_CHANNEL_EVENTS: {
4
+ readonly email: {
5
+ readonly owner: "email";
6
+ readonly key: "email.message.received";
7
+ readonly version: 1;
8
+ };
9
+ readonly whatsapp: {
10
+ readonly owner: "whatsapp";
11
+ readonly key: "whatsapp.message.received";
12
+ readonly version: 1;
13
+ };
14
+ readonly widget: {
15
+ readonly owner: "widget";
16
+ readonly key: "widget.message.received";
17
+ readonly version: 1;
18
+ };
19
+ readonly sms: {
20
+ readonly owner: "sms";
21
+ readonly key: "sms.message.received";
22
+ readonly version: 1;
23
+ };
24
+ readonly instagram: {
25
+ readonly owner: "instagram";
26
+ readonly key: "instagram.message.received";
27
+ readonly version: 1;
28
+ };
29
+ readonly messenger: {
30
+ readonly owner: "messenger";
31
+ readonly key: "messenger.message.received";
32
+ readonly version: 1;
33
+ };
34
+ };
35
+ export type CoreChannelEventKey = (typeof CORE_CHANNEL_EVENTS)[keyof typeof CORE_CHANNEL_EVENTS]['key'];
36
+ /**
37
+ * A button the Pack offers staff in the inbox, for a conversation its inbound
38
+ * agent answers. A click reaches the handler as `conversation.action.requested`.
39
+ */
40
+ export interface StaffActionDeclaration {
41
+ /** Lowercase letters, digits and dashes, starting with a letter; at most 40. */
42
+ key: string;
43
+ /** The button text, for example "Send booking link". At most 60 characters. */
44
+ label: string;
45
+ description?: string;
46
+ /** One option to pick at the click, for example the channel to send on. */
47
+ choices?: {
48
+ key: string;
49
+ label: string;
50
+ }[];
51
+ }
52
+ export declare const STAFF_ACTION_KEY: RegExp;
53
+ export declare const STAFF_ACTION_EVENT = "conversation.action.requested";
54
+ export interface InboundEventSubscription {
55
+ owner: string;
56
+ key: string;
57
+ version: number;
58
+ fingerprint?: string;
59
+ }
60
+ /**
61
+ * The subscriptions an inbound agent declares in its manifest: the customer
62
+ * message on each channel it answers, and the news it wants. The agent's own
63
+ * sent replies are opt-in; a verified Contact and the conversation coming back
64
+ * are on unless turned off. `staffActions` is needed when the agent declares
65
+ * staff buttons.
66
+ */
67
+ export declare function channelSubscriptions(channels: readonly ('widget' | 'whatsapp' | 'email' | 'sms')[], news?: {
68
+ sent?: boolean;
69
+ verified?: boolean;
70
+ ownerChanged?: boolean;
71
+ staffActions?: boolean;
72
+ }): InboundEventSubscription[];
73
+ export interface AgentExposureManifest {
74
+ agents: Array<{
75
+ key: string;
76
+ exposure?: AgentExposure;
77
+ subscriptions?: InboundEventSubscription[];
78
+ permissions?: string[];
79
+ staffActions?: StaffActionDeclaration[];
80
+ }>;
81
+ teams?: Array<{
82
+ key: string;
83
+ exposure?: 'general';
84
+ lead?: string;
85
+ members?: string[];
86
+ }>;
87
+ capabilities?: Array<{
88
+ implementation?: {
89
+ kind?: string;
90
+ agent?: string;
91
+ team?: string;
92
+ };
93
+ }>;
94
+ schedules?: Array<{
95
+ agent?: string;
96
+ }>;
97
+ eventHandlers?: Array<{
98
+ authority?: {
99
+ agents?: string[];
100
+ teams?: string[];
101
+ };
102
+ }>;
103
+ }
104
+ /**
105
+ * Publication-time boundary: an inbound agent cannot also be named by a
106
+ * general dispatch declaration. Missing exposure remains general, preserving
107
+ * every existing manifest.
108
+ */
109
+ export declare function validateAgentExposureManifest(manifest: AgentExposureManifest): string[];
@@ -0,0 +1,130 @@
1
+ /** The customer-message Event each channel delivers. Owned by its channel family. */
2
+ export const CORE_CHANNEL_EVENTS = {
3
+ email: { owner: 'email', key: 'email.message.received', version: 1 },
4
+ whatsapp: { owner: 'whatsapp', key: 'whatsapp.message.received', version: 1 },
5
+ widget: { owner: 'widget', key: 'widget.message.received', version: 1 },
6
+ sms: { owner: 'sms', key: 'sms.message.received', version: 1 },
7
+ instagram: { owner: 'instagram', key: 'instagram.message.received', version: 1 },
8
+ messenger: { owner: 'messenger', key: 'messenger.message.received', version: 1 },
9
+ };
10
+ export const STAFF_ACTION_KEY = /^[a-z][a-z0-9-]{0,39}$/;
11
+ export const STAFF_ACTION_EVENT = 'conversation.action.requested';
12
+ /**
13
+ * Every conversation Event Core delivers to an inbound agent: a received message,
14
+ * the agent's own sent reply, a verified Contact, and the conversation coming back
15
+ * to the agent. Each is owned by its family (`widget`, `contact`, ...); the older
16
+ * `core` owner still validates.
17
+ */
18
+ const CORE_CHANNEL_EVENT_KEYS = new Set([
19
+ ...Object.values(CORE_CHANNEL_EVENTS).map((event) => event.key),
20
+ ...['widget', 'whatsapp', 'email', 'sms'].map((channel) => `${channel}.message.sent`),
21
+ 'contact.verified',
22
+ 'conversation.owner.changed',
23
+ 'conversation.action.requested',
24
+ ]);
25
+ function isCoreChannelSubscription(subscription) {
26
+ if (subscription.version !== 1 || !CORE_CHANNEL_EVENT_KEYS.has(subscription.key))
27
+ return false;
28
+ return subscription.owner === 'core' || subscription.key.startsWith(`${subscription.owner}.`);
29
+ }
30
+ /**
31
+ * The subscriptions an inbound agent declares in its manifest: the customer
32
+ * message on each channel it answers, and the news it wants. The agent's own
33
+ * sent replies are opt-in; a verified Contact and the conversation coming back
34
+ * are on unless turned off. `staffActions` is needed when the agent declares
35
+ * staff buttons.
36
+ */
37
+ export function channelSubscriptions(channels, news = {}) {
38
+ const out = [];
39
+ for (const channel of channels) {
40
+ out.push({ owner: channel, key: `${channel}.message.received`, version: 1 });
41
+ if (news.sent)
42
+ out.push({ owner: channel, key: `${channel}.message.sent`, version: 1 });
43
+ }
44
+ if (news.verified !== false)
45
+ out.push({ owner: 'contact', key: 'contact.verified', version: 1 });
46
+ if (news.ownerChanged !== false) {
47
+ out.push({ owner: 'conversation', key: 'conversation.owner.changed', version: 1 });
48
+ }
49
+ if (news.staffActions) {
50
+ out.push({ owner: 'conversation', key: STAFF_ACTION_EVENT, version: 1 });
51
+ }
52
+ return out;
53
+ }
54
+ /**
55
+ * Publication-time boundary: an inbound agent cannot also be named by a
56
+ * general dispatch declaration. Missing exposure remains general, preserving
57
+ * every existing manifest.
58
+ */
59
+ export function validateAgentExposureManifest(manifest) {
60
+ const inboundAgents = new Set(manifest.agents.filter((agent) => agent.exposure === 'inbound').map((agent) => agent.key));
61
+ const errors = [];
62
+ for (const agent of manifest.agents) {
63
+ if (agent.exposure === 'inbound' && !agent.subscriptions?.length) {
64
+ errors.push(`inbound agent '${agent.key}' must declare at least one Event subscription`);
65
+ }
66
+ if (agent.exposure !== 'inbound' &&
67
+ (agent.subscriptions?.length || agent.permissions?.length)) {
68
+ errors.push(`only an inbound agent may declare subscriptions or inbound permissions`);
69
+ }
70
+ const actions = agent.staffActions ?? [];
71
+ if (actions.length > 0) {
72
+ if (agent.exposure !== 'inbound') {
73
+ errors.push(`only an inbound agent may declare staff actions ('${agent.key}')`);
74
+ }
75
+ if (!agent.subscriptions?.some((s) => s.key === STAFF_ACTION_EVENT && s.version === 1)) {
76
+ errors.push(`inbound agent '${agent.key}' declares staff actions and must subscribe to ${STAFF_ACTION_EVENT}@1`);
77
+ }
78
+ if (actions.length > 10)
79
+ errors.push(`inbound agent '${agent.key}' may declare at most 10 staff actions`);
80
+ const seen = new Set();
81
+ for (const action of actions) {
82
+ const choices = action.choices ?? [];
83
+ if (!STAFF_ACTION_KEY.test(action.key) ||
84
+ seen.has(action.key) ||
85
+ typeof action.label !== 'string' ||
86
+ action.label.trim().length === 0 ||
87
+ action.label.length > 60 ||
88
+ (action.description !== undefined &&
89
+ (typeof action.description !== 'string' || action.description.length > 200)) ||
90
+ choices.length > 5 ||
91
+ new Set(choices.map((c) => c.key)).size !== choices.length ||
92
+ choices.some((c) => !STAFF_ACTION_KEY.test(c.key) ||
93
+ typeof c.label !== 'string' ||
94
+ c.label.trim().length === 0 ||
95
+ c.label.length > 60)) {
96
+ errors.push(`inbound agent '${agent.key}' has an invalid staff action '${String(action.key)}': a unique key of lowercase letters, digits and dashes, a label of 1-60 characters, and at most 5 unique choices`);
97
+ }
98
+ seen.add(action.key);
99
+ }
100
+ }
101
+ for (const subscription of agent.subscriptions ?? []) {
102
+ if (!isCoreChannelSubscription(subscription)) {
103
+ errors.push(`inbound agent '${agent.key}' may subscribe only to Core channel Event contracts`);
104
+ }
105
+ }
106
+ }
107
+ for (const team of manifest.teams ?? []) {
108
+ if ((team.lead && inboundAgents.has(team.lead)) ||
109
+ team.members?.some((member) => inboundAgents.has(member))) {
110
+ errors.push('an inbound agent cannot be a Team lead or member');
111
+ }
112
+ }
113
+ for (const capability of manifest.capabilities ?? []) {
114
+ const target = capability.implementation;
115
+ if (target?.agent && inboundAgents.has(target.agent)) {
116
+ errors.push('an inbound agent cannot be a Capability target');
117
+ }
118
+ }
119
+ for (const schedule of manifest.schedules ?? []) {
120
+ if (schedule.agent && inboundAgents.has(schedule.agent)) {
121
+ errors.push('an inbound agent cannot be a schedule target');
122
+ }
123
+ }
124
+ for (const handler of manifest.eventHandlers ?? []) {
125
+ if (handler.authority?.agents?.some((agent) => inboundAgents.has(agent))) {
126
+ errors.push('an inbound agent cannot be a generic Event handler target');
127
+ }
128
+ }
129
+ return errors;
130
+ }
@@ -0,0 +1,52 @@
1
+ /** What a turn may ask Core's action door for, as the binding allows. */
2
+ export type ChannelTurnAction = 'reply' | 'silent' | 'handoff' | 'request_verification' | 'verify_code' | 'publish_event'
3
+ /** WhatsApp: present when the binding lists templates the agent may send. */
4
+ | 'send_template';
5
+ export interface ChannelTurnGrant {
6
+ tenantId: string;
7
+ /** `{pack}/channels/{handler}` */
8
+ installedAgent: string;
9
+ /**
10
+ * Capability scopes for the Pack's own tools. Empty until the binding's
11
+ * identity rule is met, and on news the handler may not act on.
12
+ */
13
+ scopes: string[];
14
+ traceId: string | null;
15
+ audience: string;
16
+ exp: number | null;
17
+ turn: {
18
+ deliveryId: string;
19
+ conversationId: string;
20
+ ownerEpoch: number;
21
+ bindingId: string;
22
+ bindingRevisionId: string;
23
+ handlerKey: string;
24
+ agentKey: string;
25
+ consumerInstallId: string;
26
+ /** Empty on a delivery that is news only, such as the agent's own sent reply. */
27
+ permittedActions: ChannelTurnAction[];
28
+ /**
29
+ * The staff member this turn acts as, when their button click started it
30
+ * while they owned the conversation. Its replies go out under their name.
31
+ * Null for an agent turn.
32
+ */
33
+ actingUserId: string | null;
34
+ };
35
+ }
36
+ /** Pure: the verified claims as a turn grant, or null when they are not one. */
37
+ export declare function parseChannelTurnGrantClaims(claims: Record<string, unknown>): ChannelTurnGrant | null;
38
+ /**
39
+ * Verify a channel turn grant against Core's JWKS. The audience is required:
40
+ * a turn grant is valid only at the runtime Core woke. `audience` falls back to
41
+ * KEE_AGENT_AUDIENCE; with neither, every turn is refused.
42
+ */
43
+ export declare function verifyChannelTurnGrant(token: string, opts?: {
44
+ jwksUrl?: string;
45
+ audience?: string;
46
+ }): Promise<ChannelTurnGrant | null>;
47
+ /**
48
+ * True for a turn grant, in its current or its first shape. The general
49
+ * verifiers refuse these: a turn grant acts for one delivery only, and must not
50
+ * open the Pack's own agent routes as a delegation.
51
+ */
52
+ export declare function isChannelTurnClaims(claims: Record<string, unknown>): boolean;