@ishlabs/cli 0.10.0 → 0.11.0

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.
@@ -17,6 +17,7 @@ import { loadConfig, saveConfig } from "../config.js";
17
17
  import { ApiError } from "../lib/api-client.js";
18
18
  import { output } from "../lib/output.js";
19
19
  import { formatChatEndpointList, formatChatEndpointDetail, envelopeFromRow, } from "../lib/chat-endpoint-formatters.js";
20
+ import { getChatEndpointTemplate, TEMPLATE_NAMES, } from "../lib/chat-endpoint-templates.js";
20
21
  // ---------------------------------------------------------------------------
21
22
  // Helpers
22
23
  // ---------------------------------------------------------------------------
@@ -57,7 +58,7 @@ function urlLooksLocal(url) {
57
58
  }
58
59
  }
59
60
  function inferredToConfig(inferred) {
60
- return {
61
+ const cfg = {
61
62
  transport: inferred.transport,
62
63
  outgoing: {
63
64
  url: inferred.outgoing.url ?? undefined,
@@ -70,6 +71,10 @@ function inferredToConfig(inferred) {
70
71
  incoming: inferred.incoming,
71
72
  asyncPoll: inferred.asyncPoll ?? null,
72
73
  };
74
+ if (inferred.streaming) {
75
+ cfg.streaming = inferred.streaming;
76
+ }
77
+ return cfg;
73
78
  }
74
79
  async function tunnelGuard(client) {
75
80
  try {
@@ -197,7 +202,7 @@ endpoint, apply the override, and PUT the merged result. Field flags win over
197
202
  $ ish chat endpoint update ep-abc --name "Production"
198
203
  $ ish chat endpoint update ep-abc --url https://api.example.com/v2/chat
199
204
  $ ish chat endpoint get ep-abc --verbose \\
200
- | jq '.config.incoming.slotsContainerPaths += ["response.options"]' \\
205
+ | jq '.config.incoming.slots += [{"containerPath": "response.options", "kind": "alternatives"}]' \\
201
206
  | ish chat endpoint update ep-abc --endpoint-config -`)
202
207
  .action(async (id, opts, cmd) => {
203
208
  await withClient(cmd, async (client, globals) => {
@@ -355,32 +360,93 @@ endpoint, apply the override, and PUT the merged result. Field flags win over
355
360
  function attachChatEndpointInit(parent) {
356
361
  parent
357
362
  .command("init")
358
- .description("Author an endpoint from a curl/JSON sample via auto-detect-shape")
363
+ .description("Author an endpoint from a curl/JSON sample via auto-detect-shape, or from a known-good template")
359
364
  .option("--from-curl <file>", 'Path to a curl example file (or "-" for stdin)')
360
365
  .option("--from-json <file>", 'Path to a JSON request/response sample (or "-" for stdin)')
366
+ .option("--template <name>", `Start from a known-good template (one of: ${TEMPLATE_NAMES.join(", ")})`)
361
367
  .option("--name <name>", "Save the inferred config under this display name")
362
368
  .option("--no-save", "Infer the shape without persisting it")
363
369
  .option("--workspace <id>", "Workspace ID")
364
370
  .option("--tunnel-backed", "Force isTunnelBacked=true (overrides localhost auto-detect)")
365
371
  .option("--no-tunnel-backed", "Force isTunnelBacked=false (overrides localhost auto-detect)")
366
372
  .addHelpText("after", `
367
- Pass exactly one of --from-curl or --from-json. Both accept "-" for stdin.
373
+ Pass exactly one of --from-curl, --from-json, or --template. --from-curl and
374
+ --from-json accept "-" for stdin. --template <name> emits a hand-curated
375
+ ChatbotEndpointConfig from public docs (no LLM round-trip), substituting
376
+ {{secret:NAME}} placeholders for auth tokens.
377
+
378
+ Available templates:
379
+ ${TEMPLATE_NAMES.map((n) => ` ${n}`).join("\n")}
368
380
 
369
381
  isTunnelBacked decision: explicit flag wins; else true when the inferred URL
370
- points at localhost / 127.0.0.1 / 0.0.0.0.
382
+ points at localhost / 127.0.0.1 / 0.0.0.0 (templates always default to false).
371
383
 
372
384
  Examples:
373
385
  $ ish chat endpoint init --from-curl ./bot.curl --name my-bot
374
- $ ish chat endpoint init --from-json ./shape.json --no-save | jq '.config'`)
386
+ $ ish chat endpoint init --from-json ./shape.json --no-save | jq '.config'
387
+ $ ish chat endpoint init --template openai --name "OpenAI"
388
+ $ ish chat endpoint init --template anthropic --no-save | jq '.config'`)
375
389
  .action(async (opts, cmd) => {
376
390
  await withClient(cmd, async (client, globals) => {
377
- if (!opts.fromCurl && !opts.fromJson) {
378
- throw new Error("Pass exactly one of --from-curl <file> or --from-json <file>.");
391
+ const sources = [opts.fromCurl, opts.fromJson, opts.template].filter((s) => s !== undefined).length;
392
+ if (sources === 0) {
393
+ throw new Error("Pass exactly one of --from-curl <file>, --from-json <file>, or --template <name>.");
379
394
  }
380
- if (opts.fromCurl && opts.fromJson) {
381
- throw new Error("Pass either --from-curl or --from-json, not both.");
395
+ if (sources > 1) {
396
+ throw new Error("Pass exactly one of --from-curl, --from-json, or --template — not multiple.");
382
397
  }
383
398
  const ws = resolveWorkspace(opts.workspace);
399
+ // Template path — fully local, no auto-detect call.
400
+ if (opts.template !== undefined) {
401
+ const tmpl = getChatEndpointTemplate(opts.template);
402
+ if (!tmpl) {
403
+ throw new Error(`Unknown template "${opts.template}". Available: ${TEMPLATE_NAMES.join(", ")}.`);
404
+ }
405
+ const config = JSON.parse(JSON.stringify(tmpl.config));
406
+ const inferredUrl = (typeof config.outgoing?.url === "string" ? config.outgoing.url : null) ?? null;
407
+ const detectedTunnel = urlLooksLocal(inferredUrl);
408
+ let tunnelBacked;
409
+ if (opts.tunnelBacked === true)
410
+ tunnelBacked = true;
411
+ else if (opts.tunnelBacked === false)
412
+ tunnelBacked = false;
413
+ else
414
+ tunnelBacked = detectedTunnel;
415
+ config.isTunnelBacked = tunnelBacked;
416
+ let endpointId = null;
417
+ let endpointAlias = null;
418
+ let saved = false;
419
+ const saveExplicitlyDisabled = opts.save === false;
420
+ const proposedName = opts.name ?? `template:${tmpl.name}`;
421
+ if (!saveExplicitlyDisabled) {
422
+ const created = await client.post(`/products/${ws}/chatbot-endpoints`, { name: proposedName, config, isTunnelBacked: tunnelBacked });
423
+ if (created.id) {
424
+ endpointId = created.id;
425
+ endpointAlias = tagAlias(ALIAS_PREFIX.chatEndpoint, created.id);
426
+ saved = true;
427
+ if (!globals.quiet) {
428
+ console.error(`Created endpoint ${endpointAlias}`);
429
+ }
430
+ }
431
+ }
432
+ const result = {
433
+ success: true,
434
+ saved,
435
+ endpoint_id: endpointId,
436
+ alias: endpointAlias,
437
+ config,
438
+ tunnel_backed: tunnelBacked,
439
+ tunnel_backed_detected: detectedTunnel,
440
+ template: tmpl.name,
441
+ description: tmpl.description,
442
+ warnings: [
443
+ "Templates use {{secret:NAME}} placeholders for auth — set the matching workspace secrets via `ish secret set` before testing.",
444
+ ],
445
+ };
446
+ output(result, globals.json, { writePath: true });
447
+ return;
448
+ }
449
+ // Auto-detect path (curl or JSON paste).
384
450
  const path = (opts.fromCurl ?? opts.fromJson);
385
451
  const paste = await readFileOrStdin(path);
386
452
  const inferRes = await client.post(`/products/${ws}/chat/auto-detect-shape`, { paste }, { timeout: 120_000 });
@@ -4,8 +4,17 @@
4
4
  * The backend returns a nested camelCase shape (id, name, productId, config,
5
5
  * isTunnelBacked, createdAt, updatedAt). The lean projection keeps only the
6
6
  * fields an agent typically branches on: id/alias/name, transport, the
7
- * outgoing url + method, the incoming messagePath, the slot-path count, and
7
+ * outgoing url + method, the incoming messagePath, slot/reference counts, and
8
8
  * isTunnelBacked. `--verbose` (or piped) passes the raw response.
9
+ *
10
+ * Slots-only model: `incoming.slots` and `incoming.references` are typed
11
+ * binding lists. Each slot carries `{containerPath, kind, labelPath, idPath}`;
12
+ * each reference carries `{containerPath, labelPath, urlPath}`. Legacy fields
13
+ * (`optionsPath`, `formRequestPath`, `cardsPath`, `artifactsPath`,
14
+ * `suggestedFollowupsPath`, plus the parallel `slotsContainerPaths` /
15
+ * `slotsKindHints` / `slotsLabelPaths` / `slotsIdPaths` /
16
+ * `referencesContainerPaths` arrays) are gone — anything interactive is a
17
+ * slot tagged with `kind`; anything passive is a reference.
9
18
  */
10
19
  export interface OutgoingHttp {
11
20
  url?: string;
@@ -13,15 +22,41 @@ export interface OutgoingHttp {
13
22
  mode?: string;
14
23
  [key: string]: unknown;
15
24
  }
25
+ export interface SlotBinding {
26
+ containerPath: string;
27
+ kind?: "alternatives" | "form" | "text" | null;
28
+ labelPath?: string | null;
29
+ idPath?: string | null;
30
+ }
31
+ export interface ReferenceBinding {
32
+ containerPath: string;
33
+ labelPath?: string | null;
34
+ urlPath?: string | null;
35
+ }
16
36
  export interface IncomingHttp {
17
37
  messagePath?: string;
18
- slotsContainerPaths?: string[];
38
+ conversationIdPath?: string | null;
39
+ endOfConversationPath?: string | null;
40
+ errorPath?: string | null;
41
+ toolCallsPath?: string | null;
42
+ tokenUsagePath?: string | null;
43
+ slots?: SlotBinding[];
44
+ references?: ReferenceBinding[];
45
+ responseStub?: unknown;
19
46
  [key: string]: unknown;
20
47
  }
48
+ export interface StreamingSettings {
49
+ eventFormat?: "openai" | "anthropic" | "raw";
50
+ deltaPath?: string | null;
51
+ terminalEvent?: string | null;
52
+ maxWaitSeconds?: number;
53
+ }
21
54
  export interface ChatbotEndpointConfig {
22
55
  transport?: string;
23
56
  outgoing?: OutgoingHttp;
24
57
  incoming?: IncomingHttp;
58
+ streaming?: StreamingSettings | null;
59
+ asyncPoll?: unknown;
25
60
  isTunnelBacked?: boolean;
26
61
  [key: string]: unknown;
27
62
  }
@@ -4,8 +4,17 @@
4
4
  * The backend returns a nested camelCase shape (id, name, productId, config,
5
5
  * isTunnelBacked, createdAt, updatedAt). The lean projection keeps only the
6
6
  * fields an agent typically branches on: id/alias/name, transport, the
7
- * outgoing url + method, the incoming messagePath, the slot-path count, and
7
+ * outgoing url + method, the incoming messagePath, slot/reference counts, and
8
8
  * isTunnelBacked. `--verbose` (or piped) passes the raw response.
9
+ *
10
+ * Slots-only model: `incoming.slots` and `incoming.references` are typed
11
+ * binding lists. Each slot carries `{containerPath, kind, labelPath, idPath}`;
12
+ * each reference carries `{containerPath, labelPath, urlPath}`. Legacy fields
13
+ * (`optionsPath`, `formRequestPath`, `cardsPath`, `artifactsPath`,
14
+ * `suggestedFollowupsPath`, plus the parallel `slotsContainerPaths` /
15
+ * `slotsKindHints` / `slotsLabelPaths` / `slotsIdPaths` /
16
+ * `referencesContainerPaths` arrays) are gone — anything interactive is a
17
+ * slot tagged with `kind`; anything passive is a reference.
9
18
  */
10
19
  import { tagAlias, ALIAS_PREFIX } from "./alias-store.js";
11
20
  import { output, printTable } from "./output.js";
@@ -29,10 +38,10 @@ function leanRow(row) {
29
38
  out.mode = cfg.outgoing.mode;
30
39
  if (cfg.incoming?.messagePath)
31
40
  out.message_path = cfg.incoming.messagePath;
32
- const slotsCount = Array.isArray(cfg.incoming?.slotsContainerPaths)
33
- ? cfg.incoming.slotsContainerPaths.length
41
+ out.slots = Array.isArray(cfg.incoming?.slots) ? cfg.incoming.slots.length : 0;
42
+ out.references = Array.isArray(cfg.incoming?.references)
43
+ ? cfg.incoming.references.length
34
44
  : 0;
35
- out.slots_paths = slotsCount;
36
45
  return out;
37
46
  }
38
47
  /** Return the round-trippable envelope used by `endpoint get --verbose`. */
@@ -68,6 +77,25 @@ export function formatChatEndpointList(rows, json, verbose) {
68
77
  r.is_tunnel_backed ? "yes" : "no",
69
78
  ]));
70
79
  }
80
+ function formatSlotLine(slot) {
81
+ const kindLabel = slot.kind ?? "auto";
82
+ const subParts = [];
83
+ if (slot.labelPath)
84
+ subParts.push(`label=${slot.labelPath}`);
85
+ if (slot.idPath)
86
+ subParts.push(`id=${slot.idPath}`);
87
+ const tail = subParts.length > 0 ? ` (${subParts.join(", ")})` : "";
88
+ return ` ${slot.containerPath} [${kindLabel}]${tail}`;
89
+ }
90
+ function formatReferenceLine(ref) {
91
+ const subParts = [];
92
+ if (ref.labelPath)
93
+ subParts.push(`label=${ref.labelPath}`);
94
+ if (ref.urlPath)
95
+ subParts.push(`url=${ref.urlPath}`);
96
+ const tail = subParts.length > 0 ? ` (${subParts.join(", ")})` : "";
97
+ return ` ${ref.containerPath}${tail}`;
98
+ }
71
99
  export function formatChatEndpointDetail(row, json, verbose) {
72
100
  if (json) {
73
101
  if (verbose) {
@@ -96,9 +124,31 @@ export function formatChatEndpointDetail(row, json, verbose) {
96
124
  if (cfg.incoming) {
97
125
  console.log("");
98
126
  console.log(` Message path ${cfg.incoming.messagePath ?? "-"}`);
99
- const slots = Array.isArray(cfg.incoming.slotsContainerPaths)
100
- ? cfg.incoming.slotsContainerPaths.length
101
- : 0;
102
- console.log(` Slot paths ${slots}`);
127
+ if (cfg.incoming.conversationIdPath) {
128
+ console.log(` Session path ${cfg.incoming.conversationIdPath}`);
129
+ }
130
+ if (cfg.incoming.errorPath) {
131
+ console.log(` Error path ${cfg.incoming.errorPath}`);
132
+ }
133
+ const slots = Array.isArray(cfg.incoming.slots) ? cfg.incoming.slots : [];
134
+ console.log(` Slots ${slots.length}`);
135
+ for (const slot of slots) {
136
+ console.log(formatSlotLine(slot));
137
+ }
138
+ const refs = Array.isArray(cfg.incoming.references) ? cfg.incoming.references : [];
139
+ console.log(` References ${refs.length}`);
140
+ for (const ref of refs) {
141
+ console.log(formatReferenceLine(ref));
142
+ }
143
+ }
144
+ if (cfg.transport === "streaming" && cfg.streaming) {
145
+ console.log("");
146
+ console.log(` Streaming ${cfg.streaming.eventFormat ?? "openai"}`);
147
+ if (cfg.streaming.deltaPath) {
148
+ console.log(` delta_path ${cfg.streaming.deltaPath}`);
149
+ }
150
+ if (cfg.streaming.terminalEvent) {
151
+ console.log(` terminal ${cfg.streaming.terminalEvent}`);
152
+ }
103
153
  }
104
154
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Hand-curated `ChatbotEndpointConfig` templates for `ish chat endpoint init
3
+ * --template <name>`.
4
+ *
5
+ * Each template is a known-good wire shape an agent can drop straight into
6
+ * `create_chatbot_endpoint` — derived from public docs for the named
7
+ * provider. Auth / API key values are placeholder secret refs
8
+ * (`{{secret:NAME}}`); the agent stores the real value via
9
+ * `ish secret set` before testing.
10
+ *
11
+ * Templates are intentionally minimal:
12
+ * - `transport`, `outgoing`, `incoming` (slots-only).
13
+ * - No `retry` block — defaults are fine.
14
+ * - No `asyncPoll` — none of the listed providers use it.
15
+ * - `streaming` only when the provider's default flow is SSE-shaped
16
+ * (we currently keep all templates in `sync` mode; agents who want
17
+ * streaming flip the transport themselves after init).
18
+ *
19
+ * Slots / references are populated when the provider documents a stable
20
+ * structured-output container (e.g. Bot Framework's `suggestedActions`,
21
+ * Watson Assistant's `output.generic[]`). Where the provider is pure text
22
+ * (vanilla OpenAI / Anthropic chat-completions), the lists stay empty —
23
+ * the runtime auto-classifier handles any structured content the bot
24
+ * decides to emit per turn.
25
+ */
26
+ import type { ChatbotEndpointConfig } from "./chat-endpoint-formatters.js";
27
+ export type ChatEndpointTemplateName = "openai" | "anthropic" | "voiceflow" | "dialogflow-cx" | "botframework";
28
+ export interface ChatEndpointTemplate {
29
+ name: ChatEndpointTemplateName;
30
+ description: string;
31
+ config: ChatbotEndpointConfig;
32
+ }
33
+ export declare const TEMPLATE_NAMES: ChatEndpointTemplateName[];
34
+ export declare function getChatEndpointTemplate(name: string): ChatEndpointTemplate | undefined;
35
+ export declare function listChatEndpointTemplates(): ChatEndpointTemplate[];
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Hand-curated `ChatbotEndpointConfig` templates for `ish chat endpoint init
3
+ * --template <name>`.
4
+ *
5
+ * Each template is a known-good wire shape an agent can drop straight into
6
+ * `create_chatbot_endpoint` — derived from public docs for the named
7
+ * provider. Auth / API key values are placeholder secret refs
8
+ * (`{{secret:NAME}}`); the agent stores the real value via
9
+ * `ish secret set` before testing.
10
+ *
11
+ * Templates are intentionally minimal:
12
+ * - `transport`, `outgoing`, `incoming` (slots-only).
13
+ * - No `retry` block — defaults are fine.
14
+ * - No `asyncPoll` — none of the listed providers use it.
15
+ * - `streaming` only when the provider's default flow is SSE-shaped
16
+ * (we currently keep all templates in `sync` mode; agents who want
17
+ * streaming flip the transport themselves after init).
18
+ *
19
+ * Slots / references are populated when the provider documents a stable
20
+ * structured-output container (e.g. Bot Framework's `suggestedActions`,
21
+ * Watson Assistant's `output.generic[]`). Where the provider is pure text
22
+ * (vanilla OpenAI / Anthropic chat-completions), the lists stay empty —
23
+ * the runtime auto-classifier handles any structured content the bot
24
+ * decides to emit per turn.
25
+ */
26
+ const OPENAI = {
27
+ name: "openai",
28
+ description: "OpenAI chat-completions wire shape. Stateless by default; ish ships the rolled-up history per turn. Drop in any OpenAI-compatible bot (Groq / Together / vLLM / OpenRouter / LiteLLM).",
29
+ config: {
30
+ transport: "sync",
31
+ outgoing: {
32
+ url: "https://api.openai.com/v1/chat/completions",
33
+ method: "POST",
34
+ headers: {
35
+ "content-type": "application/json",
36
+ Authorization: "Bearer {{secret:OPENAI_API_KEY}}",
37
+ },
38
+ bodyTemplate: {
39
+ model: "gpt-4o-mini",
40
+ messages: "{{history_with_current}}",
41
+ },
42
+ mode: "stateless",
43
+ roleAliases: {},
44
+ },
45
+ incoming: {
46
+ messagePath: "choices[0].message.content",
47
+ toolCallsPath: "choices[0].message.tool_calls",
48
+ tokenUsagePath: "usage",
49
+ slots: [],
50
+ references: [],
51
+ },
52
+ },
53
+ };
54
+ const ANTHROPIC = {
55
+ name: "anthropic",
56
+ description: "Anthropic Messages API. Stateless: each request carries the full history and a fresh system prompt. Default model is the latest Sonnet.",
57
+ config: {
58
+ transport: "sync",
59
+ outgoing: {
60
+ url: "https://api.anthropic.com/v1/messages",
61
+ method: "POST",
62
+ headers: {
63
+ "content-type": "application/json",
64
+ "anthropic-version": "2023-06-01",
65
+ "x-api-key": "{{secret:ANTHROPIC_API_KEY}}",
66
+ },
67
+ bodyTemplate: {
68
+ model: "claude-sonnet-4-20250514",
69
+ max_tokens: 1024,
70
+ messages: "{{history_with_current}}",
71
+ },
72
+ mode: "stateless",
73
+ roleAliases: {},
74
+ },
75
+ incoming: {
76
+ messagePath: "content[0].text",
77
+ tokenUsagePath: "usage",
78
+ slots: [],
79
+ references: [],
80
+ },
81
+ },
82
+ };
83
+ const VOICEFLOW = {
84
+ name: "voiceflow",
85
+ description: "Voiceflow Dialog Manager API. Stateful — the URL embeds the user/session id, and Voiceflow returns a list of trace objects. Body is `{action: {type: 'text', payload: <text>}}` per turn.",
86
+ config: {
87
+ transport: "sync",
88
+ outgoing: {
89
+ url: "https://general-runtime.voiceflow.com/state/user/{{conversation_id}}/interact",
90
+ method: "POST",
91
+ headers: {
92
+ "content-type": "application/json",
93
+ Authorization: "{{secret:VOICEFLOW_API_KEY}}",
94
+ versionID: "production",
95
+ },
96
+ bodyTemplate: {
97
+ action: {
98
+ type: "text",
99
+ payload: "{{action.text}}",
100
+ },
101
+ },
102
+ mode: "stateful",
103
+ roleAliases: {},
104
+ },
105
+ incoming: {
106
+ // Voiceflow's runtime returns an array of trace objects; the bot's
107
+ // textual reply lives in the first `speak` / `text` trace's payload.
108
+ // Agents typically tighten this path after a probe turn.
109
+ messagePath: "[0].payload.message",
110
+ slots: [
111
+ {
112
+ containerPath: "[0].payload.choices",
113
+ kind: "alternatives",
114
+ labelPath: "name",
115
+ idPath: "intent",
116
+ },
117
+ ],
118
+ references: [],
119
+ },
120
+ },
121
+ };
122
+ const DIALOGFLOW_CX = {
123
+ name: "dialogflow-cx",
124
+ description: "Google Dialogflow CX `detectIntent`. Stateful: the URL embeds the agent + session id and the response carries the next `currentPage` plus a list of fulfillment messages.",
125
+ config: {
126
+ transport: "sync",
127
+ outgoing: {
128
+ url: "https://dialogflow.googleapis.com/v3/projects/{{secret:GCP_PROJECT}}/locations/global/agents/{{secret:DIALOGFLOW_AGENT_ID}}/sessions/{{conversation_id}}:detectIntent",
129
+ method: "POST",
130
+ headers: {
131
+ "content-type": "application/json",
132
+ Authorization: "Bearer {{secret:GOOGLE_ACCESS_TOKEN}}",
133
+ },
134
+ bodyTemplate: {
135
+ queryInput: {
136
+ text: { text: "{{action.text}}" },
137
+ languageCode: "en",
138
+ },
139
+ },
140
+ mode: "stateful",
141
+ roleAliases: {},
142
+ },
143
+ incoming: {
144
+ messagePath: "queryResult.responseMessages[0].text.text[0]",
145
+ conversationIdPath: "responseId",
146
+ slots: [
147
+ {
148
+ containerPath: "queryResult.responseMessages",
149
+ kind: null,
150
+ },
151
+ ],
152
+ references: [],
153
+ },
154
+ },
155
+ };
156
+ const BOTFRAMEWORK = {
157
+ name: "botframework",
158
+ description: "Microsoft Bot Framework Direct Line `conversations/{id}/activities`. Stateful via the conversation id; activities[] carry the bot's reply plus suggestedActions for choice slots.",
159
+ config: {
160
+ transport: "sync",
161
+ outgoing: {
162
+ url: "https://directline.botframework.com/v3/directline/conversations/{{conversation_id}}/activities",
163
+ method: "POST",
164
+ headers: {
165
+ "content-type": "application/json",
166
+ Authorization: "Bearer {{secret:DIRECTLINE_SECRET}}",
167
+ },
168
+ bodyTemplate: {
169
+ type: "message",
170
+ from: { id: "{{tester.name}}" },
171
+ text: "{{action.text}}",
172
+ },
173
+ mode: "stateful",
174
+ roleAliases: {},
175
+ },
176
+ incoming: {
177
+ messagePath: "activities[0].text",
178
+ conversationIdPath: "activities[0].conversation.id",
179
+ slots: [
180
+ {
181
+ containerPath: "activities[0].suggestedActions.actions",
182
+ kind: "alternatives",
183
+ labelPath: "title",
184
+ idPath: "value",
185
+ },
186
+ ],
187
+ references: [
188
+ {
189
+ containerPath: "activities[0].attachments",
190
+ labelPath: "name",
191
+ urlPath: "contentUrl",
192
+ },
193
+ ],
194
+ },
195
+ },
196
+ };
197
+ const TEMPLATES = {
198
+ openai: OPENAI,
199
+ anthropic: ANTHROPIC,
200
+ voiceflow: VOICEFLOW,
201
+ "dialogflow-cx": DIALOGFLOW_CX,
202
+ botframework: BOTFRAMEWORK,
203
+ };
204
+ export const TEMPLATE_NAMES = Object.keys(TEMPLATES);
205
+ export function getChatEndpointTemplate(name) {
206
+ return TEMPLATES[name];
207
+ }
208
+ export function listChatEndpointTemplates() {
209
+ return TEMPLATE_NAMES.map((n) => TEMPLATES[n]);
210
+ }
package/dist/lib/docs.js CHANGED
@@ -1783,8 +1783,7 @@ ish chat endpoint get ep-abc --verbose \\
1783
1783
  | ish chat endpoint update ep-abc --endpoint-config -
1784
1784
 
1785
1785
  ish chat endpoint get ep-abc --verbose \\
1786
- | jq '.config.incoming.slotsContainerPaths += ["response.options"]
1787
- | .config.incoming.slotsKindHints["response.options"] = "alternatives"' \\
1786
+ | jq '.config.incoming.slots += [{"containerPath": "response.options", "kind": "alternatives"}]' \\
1788
1787
  | ish chat endpoint update ep-abc --endpoint-config -
1789
1788
  \`\`\`
1790
1789
 
@@ -1820,6 +1819,106 @@ The renderer expands these tokens at request time:
1820
1819
  }
1821
1820
  \`\`\`
1822
1821
 
1822
+ ### Slot bindings (interactive containers)
1823
+
1824
+ The bot's response shape is described by two typed lists on
1825
+ \`incoming\`:
1826
+
1827
+ - \`incoming.slots[]\` — INTERACTIVE containers the persona must
1828
+ respond to. Each entry is
1829
+ \`{containerPath, kind?, labelPath?, idPath?}\`.
1830
+ - \`incoming.references[]\` — PASSIVE containers (citations,
1831
+ followups, file artifacts, related links). Each entry is
1832
+ \`{containerPath, labelPath?, urlPath?}\`.
1833
+
1834
+ \`kind\` is one of \`alternatives\` (pick from a list), \`form\`
1835
+ (fill named fields), or \`text\` (free text). Leaving \`kind\` unset
1836
+ (the default) means "auto-classify per-turn from the live shape" —
1837
+ ish inspects the container value at parse time and dispatches on the
1838
+ shape it sees. Use that whenever the bot returns different slot
1839
+ kinds across turns.
1840
+
1841
+ \`labelPath\` / \`idPath\` (alternatives) and \`labelPath\` /
1842
+ \`urlPath\` (references) are optional sub-paths within each item.
1843
+ When omitted, ish falls back to \`label\` / \`text\` / \`title\` for
1844
+ labels, \`id\` / \`value\` / \`payload\` for ids, and
1845
+ \`url\` / \`uri\` / \`href\` for urls.
1846
+
1847
+ The legacy per-affordance fields (\`optionsPath\`,
1848
+ \`formRequestPath\`, \`cardsPath\`, \`artifactsPath\`,
1849
+ \`suggestedFollowupsPath\`) are gone. Anything interactive is a
1850
+ slot tagged with \`kind\`; anything passive is a reference. New
1851
+ affordance shapes ship as a new \`kind\` value, no schema migration.
1852
+
1853
+ \`auto-detect-shape\` (the engine behind \`init\`) populates these
1854
+ lists from the response stub via a shape-based classifier:
1855
+ \`list[{label, id?}]\` becomes \`alternatives\`,
1856
+ \`{fields: [...]}\` becomes \`form\`,
1857
+ \`list[{label, url}]\` becomes a reference, and
1858
+ \`list[str]\` becomes a string-list reference. The classifier never
1859
+ emits \`kind=text\` — that's a hand-set tag for free-text follow-ups.
1860
+
1861
+ ### Streaming endpoints
1862
+
1863
+ When a bot speaks Server-Sent Events (Accept: text/event-stream,
1864
+ \`-N\` / \`--no-buffer\` curl flags, or \`"stream": true\` in the
1865
+ body), \`init\` flips \`transport\` to \`"streaming"\` and seeds a
1866
+ \`streaming\` block:
1867
+
1868
+ \`\`\`json
1869
+ {
1870
+ "transport": "streaming",
1871
+ "streaming": {
1872
+ "eventFormat": "openai",
1873
+ "deltaPath": null,
1874
+ "terminalEvent": null,
1875
+ "maxWaitSeconds": 120
1876
+ }
1877
+ }
1878
+ \`\`\`
1879
+
1880
+ \`eventFormat\` is one of:
1881
+
1882
+ - \`"openai"\` — chunks shaped like
1883
+ \`{choices: [{delta: {content: "..."}}]}\`; ends on \`[DONE]\` or
1884
+ \`finish_reason != null\`. Matches OpenAI / Groq / vLLM / LiteLLM /
1885
+ OpenRouter / Chainlit.
1886
+ - \`"anthropic"\` — \`event: content_block_delta\` chunks carrying
1887
+ \`{delta: {type: "text_delta", text: "..."}}\`; ends on
1888
+ \`event: message_stop\`.
1889
+ - \`"raw"\` — body-text concatenation; no JSON decoding; closes on
1890
+ connection drop.
1891
+
1892
+ Override the format-specific defaults via \`deltaPath\` (e.g. an
1893
+ OpenAI-compatible proxy that nests delta under
1894
+ \`message.delta.content\`) and \`terminalEvent\`. \`maxWaitSeconds\`
1895
+ caps the streaming-loop deadline.
1896
+
1897
+ ### From a template
1898
+
1899
+ For well-known providers, skip auto-detect and start from a
1900
+ hand-curated config:
1901
+
1902
+ \`\`\`
1903
+ ish chat endpoint init --template openai --name "OpenAI"
1904
+ ish chat endpoint init --template anthropic --no-save | jq '.config'
1905
+ ish chat endpoint init --template voiceflow --name "Sales bot"
1906
+ ish chat endpoint init --template dialogflow-cx --name "Support"
1907
+ ish chat endpoint init --template botframework --name "Concierge"
1908
+ \`\`\`
1909
+
1910
+ Templates use \`{{secret:NAME}}\` placeholders for auth tokens; set
1911
+ the matching workspace secrets before testing:
1912
+
1913
+ \`\`\`
1914
+ printf %s "$OPENAI_API_KEY" | ish secret set OPENAI_API_KEY --value-stdin
1915
+ \`\`\`
1916
+
1917
+ Available templates: openai, anthropic, voiceflow, dialogflow-cx,
1918
+ botframework. Each is derived from the provider's public docs and
1919
+ is intentionally minimal — agents typically tighten the message
1920
+ path / model / slot bindings after one round-trip with the real bot.
1921
+
1823
1922
  ### Auth via workspace secrets
1824
1923
 
1825
1924
  For bots behind an API key, store the value as a workspace secret
@@ -2087,7 +2186,7 @@ const PAGES = [
2087
2186
  {
2088
2187
  slug: "guides/chat",
2089
2188
  title: "guide: chat-modality studies",
2090
- description: "Configure a chatbot endpoint, smoke test it, run a chat-modality study.",
2189
+ description: "Configure a chatbot endpoint (slots-only model), smoke test it, run a chat-modality study. Covers slot bindings, streaming endpoints, and built-in templates.",
2091
2190
  body: GUIDE_CHAT,
2092
2191
  },
2093
2192
  ];
@@ -29,3 +29,21 @@ export interface SkillTargetSpec {
29
29
  consumers: string[];
30
30
  }
31
31
  export declare const SKILL_TARGETS: SkillTargetSpec[];
32
+ /**
33
+ * Walks from `startDir` upward (inclusive of the home directory, capped at
34
+ * the filesystem root) looking for an installed ish skill at any of
35
+ * SKILL_TARGETS. Returns the first hit, identified by the presence of a
36
+ * SKILL.md file. Used by `ish status` to nudge agents toward `ish init`
37
+ * when the project doesn't have the skill installed yet.
38
+ */
39
+ export declare function findInstalledSkill(startDir: string, fs: {
40
+ existsSync: (p: string) => boolean;
41
+ }, path: {
42
+ join: (...p: string[]) => string;
43
+ dirname: (p: string) => string;
44
+ resolve: (p: string) => string;
45
+ }, homeDir: string): {
46
+ target: SkillTargetSpec;
47
+ root: string;
48
+ skillMdPath: string;
49
+ } | null;
@@ -520,8 +520,20 @@ ish iteration create --url "$URL"
520
520
 
521
521
  Goal: configure a customer chatbot endpoint, smoke test it, and run
522
522
  a chat-modality study end to end. The CLI talks to the endpoint
523
- through whatever transport it's configured for (sync / async-poll);
524
- local bots reach ish via \`ish connect\`.
523
+ through whatever transport it's configured for (sync / async-poll /
524
+ streaming); local bots reach ish via \`ish connect\`.
525
+
526
+ Endpoint shape is slots-only: \`incoming.slots[]\` lists interactive
527
+ containers (\`{containerPath, kind?, labelPath?, idPath?}\`,
528
+ \`kind\` ∈ \`alternatives\` / \`form\` / \`text\` or unset to
529
+ auto-classify per-turn) and \`incoming.references[]\` lists passive
530
+ containers (citations / followups / artifacts). Auto-detect derives
531
+ both from the response stub via shape rules — no markers to learn.
532
+
533
+ For well-known providers, skip auto-detect and start from a
534
+ hand-curated template:
535
+ \`ish chat endpoint init --template <openai|anthropic|voiceflow|dialogflow-cx|botframework>\`.
536
+ Templates use \`{{secret:NAME}}\` placeholders for auth.
525
537
 
526
538
  \`\`\`bash
527
539
  # 1. Author the endpoint from a curl example (or a ChatbotEndpointConfig file).
@@ -540,7 +552,7 @@ ish chat endpoint test "$ID" -m "Hello"
540
552
  # one-liner shorthand. Mirrors the editor dialog's PUT contract.
541
553
  ish chat endpoint update "$ID" --name "Production support bot"
542
554
  ish chat endpoint get "$ID" --verbose \\
543
- | jq '.config.incoming.slotsContainerPaths += ["response.options"]' \\
555
+ | jq '.config.incoming.slots += [{"containerPath": "response.options", "kind": "alternatives"}]' \\
544
556
  | ish chat endpoint update "$ID" --endpoint-config -
545
557
 
546
558
  # 4. Run a chat-modality study referencing the endpoint. Audience size
@@ -837,3 +849,32 @@ export const SKILL_TARGETS = [
837
849
  consumers: ["Codex", "Cursor", "Cline", "Roo Code"],
838
850
  },
839
851
  ];
852
+ /**
853
+ * Walks from `startDir` upward (inclusive of the home directory, capped at
854
+ * the filesystem root) looking for an installed ish skill at any of
855
+ * SKILL_TARGETS. Returns the first hit, identified by the presence of a
856
+ * SKILL.md file. Used by `ish status` to nudge agents toward `ish init`
857
+ * when the project doesn't have the skill installed yet.
858
+ */
859
+ export function findInstalledSkill(startDir, fs, path, homeDir) {
860
+ let dir = path.resolve(startDir);
861
+ const home = path.resolve(homeDir);
862
+ // Walk until the parent stops changing (filesystem root). Include `home`
863
+ // itself in the search so a global ~/.claude/skills/ish counts, but stop
864
+ // right after — don't claim a skill installed somewhere above $HOME.
865
+ while (true) {
866
+ for (const target of SKILL_TARGETS) {
867
+ const root = path.join(dir, target.path);
868
+ const skillMdPath = path.join(root, "SKILL.md");
869
+ if (fs.existsSync(skillMdPath)) {
870
+ return { target, root, skillMdPath };
871
+ }
872
+ }
873
+ if (dir === home)
874
+ return null;
875
+ const parent = path.dirname(dir);
876
+ if (parent === dir)
877
+ return null;
878
+ dir = parent;
879
+ }
880
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,4 +41,4 @@
41
41
  "@types/node": "^22.0.0",
42
42
  "typescript": "^5.7.0"
43
43
  }
44
- }
44
+ }