appilot-mcp 0.2.1 → 0.4.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.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/LICENSE +15 -0
  4. package/README.md +133 -27
  5. package/dist/appilot-configurator.mcpb +0 -0
  6. package/dist/cli.d.ts +34 -0
  7. package/dist/cli.js +173 -0
  8. package/dist/client.d.ts +45 -1
  9. package/dist/client.js +74 -1
  10. package/dist/config.d.ts +21 -0
  11. package/dist/config.js +6 -0
  12. package/dist/contract/healthContract.js +31 -4
  13. package/dist/index.bundle.js +2111 -826
  14. package/dist/index.d.ts +7 -1
  15. package/dist/index.js +22 -4
  16. package/dist/manifest.d.ts +14 -2
  17. package/dist/manifest.js +31 -9
  18. package/dist/public-marketplace/.claude-plugin/marketplace.json +20 -0
  19. package/dist/public-marketplace/README.md +23 -0
  20. package/dist/public-marketplace/plugins/app-configurator/.claude-plugin/plugin.json +43 -0
  21. package/dist/public-marketplace/plugins/app-configurator/README.md +328 -0
  22. package/dist/public-marketplace/plugins/app-configurator/dist/index.bundle.js +57370 -0
  23. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/SKILL.md +267 -0
  24. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/agents/openai.yaml +13 -0
  25. package/dist/redaction.d.ts +51 -0
  26. package/dist/redaction.js +59 -0
  27. package/dist/remote/consent.d.ts +10 -2
  28. package/dist/remote/consent.js +16 -6
  29. package/dist/remote/consentMessages.d.ts +6 -1
  30. package/dist/remote/consentMessages.js +15 -6
  31. package/dist/remote/httpServer.d.ts +10 -0
  32. package/dist/remote/httpServer.js +126 -40
  33. package/dist/remote/oauth.d.ts +10 -1
  34. package/dist/remote/oauth.js +29 -11
  35. package/dist/scaffold.d.ts +68 -6
  36. package/dist/scaffold.js +424 -97
  37. package/dist/server.js +175 -18
  38. package/dist/userClient.d.ts +213 -0
  39. package/dist/userClient.js +400 -0
  40. package/dist/userServer.d.ts +47 -0
  41. package/dist/userServer.js +248 -0
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/examples/app.appilot.json +212 -0
  45. package/mcpb/manifest.json +117 -21
  46. package/package.json +5 -3
  47. package/skills/app-configurator/SKILL.md +61 -19
@@ -2,6 +2,13 @@
2
2
  * The remote Appilot MCP service: Streamable HTTP transport plus the OAuth
3
3
  * authorization server that fronts it.
4
4
  *
5
+ * One deployment answers for both servers, on two paths behind one authorization
6
+ * server. `/mcp` is Appilot Studio, which writes configuration for a developer.
7
+ * `/mcp/runtime` is Appilot, which operates a configured app for the person
8
+ * using it. They share the consent flow and nothing else: each path refuses a
9
+ * grant that was not approved for it, so a runtime connection cannot reach a
10
+ * Studio tool and a configuration connection cannot reach a runtime tool.
11
+ *
5
12
  * Stateless by construction. Each request builds its own MCP server bound to the
6
13
  * caller's own service token, which arrives sealed inside the bearer token and
7
14
  * never crosses between callers. Nothing is retained between requests, so a
@@ -11,16 +18,20 @@
11
18
  */
12
19
  import express from 'express';
13
20
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
14
- import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
21
+ import { mcpAuthRouter, mcpAuthMetadataRouter, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl, } from '@modelcontextprotocol/sdk/server/auth/router.js';
15
22
  import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
16
23
  import { createAppilotServer } from '../server.js';
24
+ import { createAppilotRuntimeServer, RUNTIME_SCOPE, RUNTIME_SCOPE_HELP } from '../userServer.js';
17
25
  import { consentLocale } from './consentMessages.js';
18
26
  import { renderErrorPage } from './consent.js';
19
- import { AppilotOAuthProvider, SUPPORTED_SCOPES } from './oauth.js';
27
+ import { AppilotOAuthProvider, SERVICE_TOKEN_SCOPES, SUPPORTED_SCOPES } from './oauth.js';
20
28
  /** Where the docs root lands a person who follows the OAuth metadata. */
21
29
  const CONFIGURE_WITH_AI_PATH = '/docs/developers/configure-with-ai/overview';
22
30
  /** Body cap for a JSON-RPC request. A ConfigBundle import is the large one. */
23
31
  const MAX_BODY = '32mb';
32
+ /** Where each server answers. One deployment, two MCP resources, one authorization server. */
33
+ export const STUDIO_MCP_PATH = '/mcp';
34
+ export const RUNTIME_MCP_PATH = '/mcp/runtime';
24
35
  export function createRemoteApp(config, options = {}) {
25
36
  const provider = options.provider ??
26
37
  new AppilotOAuthProvider({
@@ -30,7 +41,8 @@ export function createRemoteApp(config, options = {}) {
30
41
  handoffSecret: config.handoffSecret,
31
42
  secret: config.secret,
32
43
  });
33
- const mcpUrl = new URL('/mcp', config.publicUrl);
44
+ const mcpUrl = new URL(STUDIO_MCP_PATH, config.publicUrl);
45
+ const runtimeMcpUrl = new URL(RUNTIME_MCP_PATH, config.publicUrl);
34
46
  const app = express();
35
47
  app.disable('x-powered-by');
36
48
  app.use(['/authorize', '/consent', '/connect/callback'], (_req, res, next) => {
@@ -49,6 +61,26 @@ export function createRemoteApp(config, options = {}) {
49
61
  app.get(['/health', '/healthz'], (_req, res) => {
50
62
  res.json({ status: 'ok', transport: 'http', instance: config.baseUrl });
51
63
  });
64
+ // The runtime resource's own metadata, mounted first.
65
+ //
66
+ // RFC 9728 puts a resource's metadata at a path-specific well-known URL, and
67
+ // the SDK mounts each one with a prefix match. `/…/mcp` therefore also matches
68
+ // `/…/mcp/runtime`, so the more specific resource has to be registered ahead
69
+ // of the more general one or a runtime client discovers the Studio resource
70
+ // and asks for the wrong scope.
71
+ app.use(mcpAuthMetadataRouter({
72
+ oauthMetadata: createOAuthMetadata({
73
+ provider,
74
+ issuerUrl: config.publicUrl,
75
+ baseUrl: config.publicUrl,
76
+ scopesSupported: [...SUPPORTED_SCOPES],
77
+ serviceDocumentationUrl: new URL(CONFIGURE_WITH_AI_PATH, config.docsUrl),
78
+ }),
79
+ resourceServerUrl: runtimeMcpUrl,
80
+ resourceName: 'Appilot',
81
+ scopesSupported: [RUNTIME_SCOPE],
82
+ serviceDocumentationUrl: new URL(CONFIGURE_WITH_AI_PATH, config.docsUrl),
83
+ }));
52
84
  // /.well-known/oauth-authorization-server, /authorize, /token, /register.
53
85
  // Must be mounted at the application root.
54
86
  app.use(mcpAuthRouter({
@@ -67,7 +99,7 @@ export function createRemoteApp(config, options = {}) {
67
99
  res.redirect(303, await provider.finishConnection(id, code, req.get('Cookie') ?? '', res));
68
100
  }
69
101
  catch {
70
- res.status(400).type('html').send(renderErrorPage('Connection could not be completed', 'The approval expired, was already used, or belongs to another browser. Start again from your assistant.', consentLocale(req.get('Accept-Language'))));
102
+ res.status(400).type('html').send(renderErrorPage('failed', 'failedHelp', consentLocale(req.get('Accept-Language'))));
71
103
  }
72
104
  });
73
105
  // The consent screen posts here. Form-encoded, same-origin, no JSON.
@@ -81,50 +113,102 @@ export function createRemoteApp(config, options = {}) {
81
113
  res.status(outcome.status).set('Content-Type', 'text/html; charset=utf-8').send(outcome.html);
82
114
  }
83
115
  catch {
84
- res.status(500).set('Content-Type', 'text/html; charset=utf-8').send(renderErrorPage('Connection unavailable', 'Appilot could not complete the connection. Please try again.', consentLocale(req.get('Accept-Language'))));
116
+ res.status(500).set('Content-Type', 'text/html; charset=utf-8').send(renderErrorPage('unavailableTitle', 'unavailableDetail', consentLocale(req.get('Accept-Language'))));
85
117
  }
86
118
  });
87
119
  const requireAuth = requireBearerAuth({
88
120
  verifier: provider,
89
121
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
90
122
  });
91
- app.post('/mcp', requireAuth, express.json({ limit: MAX_BODY }), async (req, res) => {
92
- const auth = req.auth;
93
- const extra = (auth?.extra ?? {});
94
- // One server and one transport per request. The connection profile is the
95
- // caller's own: the deployment's fixed instance URL, their service token,
96
- // and the scopes the person actually approved, which the tool surface
97
- // enforces so the consent screen describes a real limit.
98
- const server = createAppilotServer({
99
- baseUrl: config.baseUrl,
100
- token: extra.pat,
101
- defaultAppId: extra.appId,
102
- transport: 'http',
103
- grantedScopes: auth?.scopes ?? [],
104
- });
105
- const transport = new StreamableHTTPServerTransport({
106
- sessionIdGenerator: undefined,
107
- enableDnsRebindingProtection: true,
108
- allowedHosts: config.allowedHosts,
109
- });
110
- res.on('close', () => {
111
- void transport.close();
112
- void server.close();
113
- });
114
- try {
115
- await server.connect(transport);
116
- await transport.handleRequest(req, res, req.body);
117
- }
118
- catch (err) {
119
- if (!res.headersSent) {
120
- res.status(500).json({
123
+ const requireRuntimeAuth = requireBearerAuth({
124
+ verifier: provider,
125
+ resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(runtimeMcpUrl),
126
+ });
127
+ /**
128
+ * Keep the two servers apart at the door.
129
+ *
130
+ * The tools enforce their own scopes one layer in, and that is not enough on
131
+ * its own: several Studio tools are pure and need no scope, so a runtime-only
132
+ * grant could still reach them if the endpoint let it through. The endpoint
133
+ * therefore decides first. Studio needs at least one configuration scope,
134
+ * Appilot needs `runtime:use`, and a grant approved for both reaches both,
135
+ * which is what approving both means.
136
+ */
137
+ function wrongServer(scopes, wanted) {
138
+ const granted = scopes ?? [];
139
+ const reaches = wanted === 'runtime'
140
+ ? granted.includes(RUNTIME_SCOPE)
141
+ : granted.some(scope => SERVICE_TOKEN_SCOPES.includes(scope));
142
+ if (reaches)
143
+ return null;
144
+ const held = granted.length ? granted.join(', ') : 'no scopes';
145
+ return wanted === 'runtime'
146
+ ? `This connection was approved for ${held}, which does not include ${RUNTIME_SCOPE}, so it reaches Appilot Studio at ${STUDIO_MCP_PATH} and not the Appilot connector. ${RUNTIME_SCOPE_HELP}`
147
+ : `This connection was approved for ${held}, which carries no configuration scope, so it reaches the Appilot connector at ${RUNTIME_MCP_PATH} and not Appilot Studio. Studio is a separate connection: add it in your assistant and approve it with a service token that carries config:read or config:write, which an organization administrator mints in the Backoffice under Service tokens.`;
148
+ }
149
+ function mcpEndpoint(surface) {
150
+ return async (req, res) => {
151
+ const auth = req.auth;
152
+ const extra = (auth?.extra ?? {});
153
+ const refusal = wrongServer(auth?.scopes, surface);
154
+ if (refusal) {
155
+ res.status(403).json({
121
156
  jsonrpc: '2.0',
122
- error: { code: -32603, message: err instanceof Error ? err.message : 'Internal error' },
157
+ error: { code: -32001, message: refusal },
123
158
  id: null,
124
159
  });
160
+ return;
125
161
  }
126
- }
127
- });
162
+ // One server and one transport per request. The connection profile is the
163
+ // caller's own: the deployment's fixed instance URL, the credential the
164
+ // approval sealed, and the scopes the person actually approved, which the
165
+ // tool surface enforces so the consent screen describes a real limit.
166
+ //
167
+ // The sealed credential is a service token for Studio and the person's
168
+ // own Appilot session for the connector. It is read here, never echoed,
169
+ // and the two never reach the same server.
170
+ const server = surface === 'runtime'
171
+ ? createAppilotRuntimeServer({
172
+ baseUrl: config.baseUrl,
173
+ sessionToken: extra.pat,
174
+ transport: 'http',
175
+ grantedScopes: auth?.scopes ?? [],
176
+ })
177
+ : createAppilotServer({
178
+ baseUrl: config.baseUrl,
179
+ token: extra.pat,
180
+ defaultAppId: extra.appId,
181
+ transport: 'http',
182
+ grantedScopes: auth?.scopes ?? [],
183
+ });
184
+ const transport = new StreamableHTTPServerTransport({
185
+ sessionIdGenerator: undefined,
186
+ enableDnsRebindingProtection: true,
187
+ allowedHosts: config.allowedHosts,
188
+ });
189
+ res.on('close', () => {
190
+ void transport.close();
191
+ void server.close();
192
+ });
193
+ try {
194
+ await server.connect(transport);
195
+ await transport.handleRequest(req, res, req.body);
196
+ }
197
+ catch (err) {
198
+ if (!res.headersSent) {
199
+ res.status(500).json({
200
+ jsonrpc: '2.0',
201
+ error: { code: -32603, message: err instanceof Error ? err.message : 'Internal error' },
202
+ id: null,
203
+ });
204
+ }
205
+ }
206
+ };
207
+ }
208
+ // The runtime path is registered first: express matches in order, and `/mcp`
209
+ // is a prefix of it in every other place this file touches.
210
+ app.post(RUNTIME_MCP_PATH, requireRuntimeAuth, express.json({ limit: MAX_BODY }), mcpEndpoint('runtime'));
211
+ app.post(STUDIO_MCP_PATH, requireAuth, express.json({ limit: MAX_BODY }), mcpEndpoint('studio'));
128
212
  // Stateless: there is no stream to resume and no session to delete.
129
213
  const methodNotAllowed = (_req, res) => {
130
214
  res.status(405).set('Allow', 'POST').json({
@@ -133,8 +217,10 @@ export function createRemoteApp(config, options = {}) {
133
217
  id: null,
134
218
  });
135
219
  };
136
- app.get('/mcp', methodNotAllowed);
137
- app.delete('/mcp', methodNotAllowed);
220
+ for (const path of [STUDIO_MCP_PATH, RUNTIME_MCP_PATH]) {
221
+ app.get(path, methodNotAllowed);
222
+ app.delete(path, methodNotAllowed);
223
+ }
138
224
  return app;
139
225
  }
140
226
  export function startRemote(config) {
@@ -34,8 +34,17 @@ import { type ConsentLocale } from './consentMessages.js';
34
34
  * `feedback:write` is the one scope that governs sending data OUT of the
35
35
  * tenant, which is why it is separate from the config scopes rather than folded
36
36
  * into them.
37
+ *
38
+ * `runtime:use` is the odd one and it is the reason this list is not simply the
39
+ * service-token scopes. It authorizes the Appilot connector, which holds a
40
+ * person's own session and operates their app through the page they are looking
41
+ * at. It grants nothing over configuration, and the four config scopes grant
42
+ * nothing over the runtime surface: the two servers sit on separate paths, each
43
+ * refusing a grant that was not approved for it (see `remote/httpServer.ts`).
37
44
  */
38
- export declare const SUPPORTED_SCOPES: readonly ["config:read", "config:write", "provision:write", "feedback:write"];
45
+ export declare const SUPPORTED_SCOPES: readonly ["config:read", "config:write", "provision:write", "feedback:write", "runtime:use"];
46
+ /** The scopes a service token can carry. `runtime:use` is not one of them. */
47
+ export declare const SERVICE_TOKEN_SCOPES: readonly string[];
39
48
  /** What the instance says a credential reaches. Absent on instances without /config/whoami. */
40
49
  export interface TokenIdentity {
41
50
  organizationName?: string | null;
@@ -35,12 +35,27 @@ import { renderConsentPage, renderConfirmPage, renderErrorPage } from './consent
35
35
  * `feedback:write` is the one scope that governs sending data OUT of the
36
36
  * tenant, which is why it is separate from the config scopes rather than folded
37
37
  * into them.
38
+ *
39
+ * `runtime:use` is the odd one and it is the reason this list is not simply the
40
+ * service-token scopes. It authorizes the Appilot connector, which holds a
41
+ * person's own session and operates their app through the page they are looking
42
+ * at. It grants nothing over configuration, and the four config scopes grant
43
+ * nothing over the runtime surface: the two servers sit on separate paths, each
44
+ * refusing a grant that was not approved for it (see `remote/httpServer.ts`).
38
45
  */
39
46
  export const SUPPORTED_SCOPES = [
40
47
  'config:read',
41
48
  'config:write',
42
49
  'provision:write',
43
50
  'feedback:write',
51
+ 'runtime:use',
52
+ ];
53
+ /** The scopes a service token can carry. `runtime:use` is not one of them. */
54
+ export const SERVICE_TOKEN_SCOPES = [
55
+ 'config:read',
56
+ 'config:write',
57
+ 'provision:write',
58
+ 'feedback:write',
44
59
  ];
45
60
  /**
46
61
  * What to ask for when the client asks for nothing.
@@ -112,8 +127,11 @@ async function defaultVerifyServiceToken(baseUrl, pat) {
112
127
  if (res.status === 401 || res.status === 403)
113
128
  return null;
114
129
  if (res.status === 404) {
115
- // Instance predates the self-check. Accept the token on shape alone.
116
- return { organizationId: null, appId: null, scopes: [...SUPPORTED_SCOPES] };
130
+ // Instance predates the self-check. Accept the token on shape alone, and
131
+ // only for what a service token can ever carry: `runtime:use` names a
132
+ // person's session, so an instance that cannot answer must not be read as
133
+ // having said yes to it.
134
+ return { organizationId: null, appId: null, scopes: [...SERVICE_TOKEN_SCOPES] };
117
135
  }
118
136
  if (!res.ok) {
119
137
  throw new ServerError(`The Appilot instance answered ${res.status} while verifying the service token.`);
@@ -188,7 +206,7 @@ export class AppilotOAuthProvider {
188
206
  const sealedRequest = await this.sealAuthRequest({
189
207
  language,
190
208
  client_id: client.client_id,
191
- client_name: client.client_name || 'your assistant',
209
+ client_name: client.client_name || message('clientFallback', language),
192
210
  redirect_uri: params.redirectUri,
193
211
  code_challenge: params.codeChallenge,
194
212
  state: params.state,
@@ -204,7 +222,7 @@ export class AppilotOAuthProvider {
204
222
  secret: this.options.handoffSecret,
205
223
  backofficeUrl: this.options.backofficeUrl,
206
224
  secure: this.options.publicUrl.protocol === 'https:',
207
- }, sealedRequest, client.client_id, client.client_name || 'AI assistant', requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES], language, res);
225
+ }, sealedRequest, client.client_id, client.client_name || message('clientFallback', language), requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES], language, res);
208
226
  return;
209
227
  }
210
228
  catch {
@@ -215,7 +233,7 @@ export class AppilotOAuthProvider {
215
233
  locale: language,
216
234
  request: sealedRequest,
217
235
  action: this.consentPath,
218
- clientName: client.client_name || 'AI assistant',
236
+ clientName: client.client_name || message('clientFallback', language),
219
237
  baseUrl: this.options.baseUrl,
220
238
  backofficeUrl: this.options.backofficeUrl,
221
239
  scopes: requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES],
@@ -228,7 +246,7 @@ export class AppilotOAuthProvider {
228
246
  locale: language,
229
247
  request: sealedRequest,
230
248
  action: this.consentPath,
231
- clientName: client.client_name || 'an MCP client',
249
+ clientName: client.client_name || message('clientFallback', language),
232
250
  baseUrl: this.options.baseUrl,
233
251
  backofficeUrl: this.options.backofficeUrl,
234
252
  scopes: requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES],
@@ -304,14 +322,14 @@ export class AppilotOAuthProvider {
304
322
  catch {
305
323
  return {
306
324
  status: 400,
307
- html: renderErrorPage('This sign-in link expired', 'Start the connection again from the client that sent you here. A consent link is valid for ten minutes.', fallbackLocale),
325
+ html: renderErrorPage('expired', 'expiredHelp', fallbackLocale),
308
326
  };
309
327
  }
310
328
  const locale = request.language ?? fallbackLocale;
311
329
  if (request.stage !== 'request' && request.stage !== 'confirm') {
312
330
  return {
313
331
  status: 400,
314
- html: renderErrorPage('Invalid request', 'That confirmation is incomplete. Start again.', locale),
332
+ html: renderErrorPage('invalid', 'invalidHelp', locale),
315
333
  };
316
334
  }
317
335
  const redirect = new URL(request.redirect_uri);
@@ -335,7 +353,7 @@ export class AppilotOAuthProvider {
335
353
  locale,
336
354
  request: await this.sealAuthRequest({ ...original, scopes }),
337
355
  action: this.consentPath,
338
- clientName: request.client_name || 'your assistant',
356
+ clientName: request.client_name || message('clientFallback', locale),
339
357
  baseUrl: this.options.baseUrl,
340
358
  backofficeUrl: this.options.backofficeUrl,
341
359
  scopes,
@@ -345,7 +363,7 @@ export class AppilotOAuthProvider {
345
363
  if (form.action !== 'confirm' || !request.pat || !request.identity) {
346
364
  return {
347
365
  status: 400,
348
- html: renderErrorPage('Invalid request', 'That confirmation is incomplete. Start again.', locale),
366
+ html: renderErrorPage('invalid', 'invalidHelp', locale),
349
367
  };
350
368
  }
351
369
  return {
@@ -364,7 +382,7 @@ export class AppilotOAuthProvider {
364
382
  locale,
365
383
  request: rawRequest,
366
384
  action: this.consentPath,
367
- clientName: request.client_name || 'your assistant',
385
+ clientName: request.client_name || message('clientFallback', locale),
368
386
  baseUrl: this.options.baseUrl,
369
387
  backofficeUrl: this.options.backofficeUrl,
370
388
  scopes: request.scopes,
@@ -10,8 +10,23 @@
10
10
  * integration a developer must write in their own backend, it is
11
11
  * security-critical, and getting it wrong is invisible until an auth edge case
12
12
  * shows up in production.
13
+ *
14
+ * Everything returned here has to COMPILE. Three of the six framework outputs
15
+ * did not typecheck under `strict` when the 2026-09-07 audit ran them through
16
+ * `tsc`, and the relay, the piece a developer is least able to review, was the
17
+ * file that failed. `test/scaffoldTypecheck.test.ts` now compiles every output
18
+ * against the workspace sources of `appilot` and `appilot-server`.
19
+ */
20
+ /**
21
+ * The frameworks with a first-class relay, plus `other`.
22
+ *
23
+ * `appilot-server` is a Node package, so a Django, Rails or PHP host cannot use
24
+ * it. That is not a reason to answer a raw Zod enum dump: the exchange is one
25
+ * authenticated HTTPS call, and `other` returns it as curl plus a Python and a
26
+ * Ruby handler.
13
27
  */
14
- export type Framework = 'next' | 'express' | 'fastify' | 'hono' | 'remix' | 'sveltekit';
28
+ export declare const SCAFFOLD_FRAMEWORKS: readonly ["next", "express", "fastify", "hono", "remix", "sveltekit", "other"];
29
+ export type Framework = (typeof SCAFFOLD_FRAMEWORKS)[number];
15
30
  export interface ScaffoldFile {
16
31
  /** Suggested path, relative to the repository root. The agent may move it. */
17
32
  path: string;
@@ -49,19 +64,38 @@ export declare function scaffoldIntegration(options: ScaffoldOptions): ScaffoldR
49
64
  * A step-by-step knowledge article is a procedure in the wrong place, and it
50
65
  * teaches the agent to author steps instead of adopting the plan that already
51
66
  * exists. Emitting both halves correctly is how a scaffold teaches that once.
67
+ *
68
+ * Everything emitted here is in the shape the API accepts, and
69
+ * `test/scaffoldAgentFirst.test.ts` proves it by running the output through the
70
+ * shared action-plan schemas and through the health contract. The previous
71
+ * version emitted `{ title, steps: [{ text }] }` sections and a `form_values`
72
+ * entry with no `fields`, so the tool meant to make an app agent-first produced
73
+ * a plan `create_entity` rejected and `runHealthContract` crashed on.
52
74
  */
53
75
  export interface AgentFirstScaffold {
54
76
  capability: string;
55
- /** Ready for `create_tool`, minus the credential. */
56
- tool: Record<string, unknown>;
57
- /** Ready for `create_action_plan` once the control ids exist. */
58
- actionPlan: Record<string, unknown>;
59
- /** Ready for `create_knowledge`. */
77
+ shape: CapabilityShape;
78
+ /** Ready for `create_entity({ kind: 'tool' })`, minus the credential. Null for a page-only capability. */
79
+ tool: Record<string, unknown> | null;
80
+ /** Ready for `create_entity({ kind: 'action_plan' })` once the control ids exist. Null when the capability has no in-page procedure. */
81
+ actionPlan: Record<string, unknown> | null;
82
+ /** Ready for `create_entity({ kind: 'knowledge' })`. */
60
83
  knowledge: Record<string, unknown>;
61
84
  files: ScaffoldFile[];
62
85
  order: string[];
63
86
  notes: string[];
64
87
  }
88
+ /**
89
+ * What the capability does to the app, which decides whether a plan makes sense
90
+ * and what its steps are.
91
+ *
92
+ * `create` is a form the user fills and submits. `navigate` takes them to a
93
+ * place. `read` answers a question, and it gets NO action plan: a plan whose
94
+ * only step opens a screen does nothing, and the health contract says so. The
95
+ * scaffold used to emit the open/fill/submit template for all three, so asking
96
+ * it for "Show a booking" produced a plan that filled a form nobody had.
97
+ */
98
+ export type CapabilityShape = 'create' | 'navigate' | 'read';
65
99
  interface AgentFirstOptions {
66
100
  /** What the user is trying to do, in their words. */
67
101
  capability: string;
@@ -79,6 +113,34 @@ interface AgentFirstOptions {
79
113
  } | null;
80
114
  /** True when the operation is UI-coupled and belongs in the page instead. */
81
115
  clientSide?: boolean;
116
+ /** What the capability does. Defaults to `create`. */
117
+ shape?: CapabilityShape;
118
+ /** Where the plan's steps run. Defaults to `/`. */
119
+ viewPath?: string;
82
120
  }
83
121
  export declare function scaffoldAgentFirst(options: AgentFirstOptions): AgentFirstScaffold;
122
+ export interface IntegrationSnippet {
123
+ apiUrl: string | null;
124
+ scriptTag: string;
125
+ bootSnippet: string;
126
+ tokenEndpointHint: string;
127
+ publicEnvName: string;
128
+ notes: string[];
129
+ }
130
+ /**
131
+ * The script tag and the boot call for an app that is already provisioned.
132
+ *
133
+ * `create_app` returns these, and returning them was the only way to get them,
134
+ * so a developer who had lost the snippet re-ran a provisioning write to read
135
+ * one line of HTML. They are composed from the connection and the key, so this
136
+ * is pure: it calls nothing and needs no scope. The shape matches what the
137
+ * backend's own `buildIntegration` returns, deliberately, so a curl caller and
138
+ * an agent read the same thing.
139
+ */
140
+ export declare function integrationSnippet(options: {
141
+ widgetScriptUrl: string;
142
+ apiUrl?: string | null;
143
+ widgetKey?: string | null;
144
+ framework?: Framework;
145
+ }): IntegrationSnippet;
84
146
  export {};