@ductape/mcp 0.1.61 → 0.2.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.
package/src/index.ts DELETED
@@ -1,4318 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Ductape MCP Server
4
- *
5
- * Exposes Ductape SDK operations as MCP tools by calling the backend proxy.
6
- *
7
- * Authentication:
8
- * Set DUCTAPE_PUBLISHABLE_KEY in the MCP server's env config to avoid passing
9
- * publishable_key on every tool call. Per-call publishable_key still overrides
10
- * the env var when provided.
11
- */
12
-
13
- import { createRequire } from 'module';
14
- import { execSync } from 'child_process';
15
- import { z } from 'zod';
16
- import {
17
- executeViaProxy,
18
- generateExecutablePayload,
19
- getAssetSchemas,
20
- type SDKModule,
21
- } from './proxy-client.js';
22
-
23
- const MODULES: SDKModule[] = [
24
- 'product', 'app', 'databases', 'graph', 'webhooks', 'notifications',
25
- 'events', 'messageBrokers', 'storage', 'vector', 'caches', 'sessions', 'quotas',
26
- 'actions', 'features', 'jobs', 'logs', 'resilience', 'health', 'fallback', 'secrets',
27
- ];
28
-
29
-
30
-
31
- // ─── Exhaustive SDK Method & Params Reference ────────────────────────────────
32
- // Built from a complete read of sdk/ts/src/index.ts (Ductape class public API).
33
- // Each entry follows: [module].[method] → params array signature.
34
- // ─────────────────────────────────────────────────────────────────────────────
35
-
36
- const METHOD_DOCS = `
37
- ━━━ TOOL SELECTION GUIDE ━━━
38
-
39
- There are THREE categories of operations. Use the right tool for each:
40
-
41
- 0. ADMINISTRATIVE OPERATIONS (create/update products, apps, environments, cloud connections…)
42
- These require an access key and CANNOT be done via ductape_execute (publishable key only).
43
- → Use ductape_cli instead. Examples:
44
- ductape_cli("products list")
45
- ductape_cli("products create --name \\"My Product\\" --tag my-product")
46
- ductape_cli("cloud connections list")
47
- ductape_cli("link --product my-product --env dev")
48
- If the CLI is not installed, ductape_cli will return install instructions automatically.
49
- NOTE: Environments and app actions are configured in the Workbench UI — there are no CLI commands for them.
50
-
51
- DECLARATIVE SYNC (apply sessions, notifications, events from code; run DB migrations)
52
- → Also use ductape_cli. The project must be linked first (ductape init --link).
53
- ductape_cli("apply") # sync all: sessions + notifications + events
54
- ductape_cli("apply sessions") # sessions only
55
- ductape_cli("apply notifications") # notifications only
56
- ductape_cli("apply events") # event brokers only
57
- ductape_cli("apply --dry-run") # preview without changes
58
- ductape_cli("db schema generate") # diff schema.json → write migration files
59
- ductape_cli("db schema generate --destructive") # also generate drop operations
60
- ductape_cli("db migrate") # apply pending migration files
61
- ductape_cli("db migrate --dry-run") # preview without applying
62
- ductape_cli("db migrate --env prd") # apply to a specific environment
63
- ductape_cli("db migrate status") # show applied vs pending migrations
64
- ductape_cli("db migrate status --json") # machine-readable status
65
- ductape_cli("db migrate rollback") # roll back last migration
66
- ductape_cli("db migrate rollback -n 3") # roll back last 3 migrations
67
-
68
- DATABASE SCHEMA FILE — ductape/database/schema.json
69
- ─────────────────────────────────────────────────────────────────────────
70
- IMPORTANT: "db schema generate" READS schema.json and emits migration files.
71
- It does NOT create schema.json from scratch. You must write this file first.
72
-
73
- If schema.json does not exist, "db schema generate" now auto-creates it and
74
- attempts to hydrate it from the cloud (pass --db <tag> to enable cloud hydration):
75
- ductape_cli("db schema generate --db statecraft-core")
76
- If the cloud database is empty, schema.json will be created as [] and you must
77
- populate it before running the command again.
78
-
79
- FILE FORMAT — ductape/database/schema.json is a JSON array:
80
- [
81
- {
82
- "db": "<database-component-tag>",
83
- "tables": {
84
- "<collection_name>": {
85
- "<field_name>": {
86
- "type": "string|number|boolean|date|datetime|timestamp|uuid|object|array|json|text|decimal|bigint|smallint|double|binary|blob|time",
87
- "required": true, // false by default (nullable)
88
- "unique": true, // unique constraint on this field
89
- "index": true, // create an index on this field (use with unique for unique index)
90
- "default": "value", // default value ("now"/"NOW" → CURRENT_TIMESTAMP)
91
- "maxlength": 255, // max length for string fields
92
- "enum": ["a", "b", "c"], // restrict to enum values (sets type to string automatically)
93
- "primaryKey": true, // mark as primary key
94
- "autoGenerate": true, // auto-generate value on insert
95
- "float": true // treat number as float (use with type: "number")
96
- }
97
- }
98
- }
99
- }
100
- ]
101
-
102
- SINGLE-FIELD INDEX: set index: true (+ unique: true for unique constraint)
103
- "matchId": { "type": "string", "required": true, "unique": true, "index": true }
104
-
105
- COMPOSITE INDEX: schema.json does not support composite indexes.
106
- After running db schema generate, add a manual migration file in
107
- ductape/migrations/<db-tag>/ with type "createIndex" and a fields array:
108
- {
109
- "tag": "create_map_events_match_seq_idx",
110
- "name": "Composite unique index on matchId+sequence",
111
- "up": [{
112
- "type": "createIndex",
113
- "collection": "map_events",
114
- "name": "map_events_matchId_sequence_idx",
115
- "fields": [{ "name": "matchId" }, { "name": "sequence" }],
116
- "unique": true,
117
- "ifNotExists": true
118
- }],
119
- "down": [{ "type": "dropIndex", "collection": "map_events", "name": "map_events_matchId_sequence_idx", "ifExists": true }],
120
- "createdAt": "<ISO timestamp>"
121
- }
122
-
123
- WORKFLOW:
124
- 1. Create/edit ductape/database/schema.json with your collection definitions
125
- 2. ductape_cli("db schema generate --db <tag>") → writes migration files
126
- 3. ductape_cli("db migrate --db <tag>") → applies pending migrations
127
- 4. Add manual migration files for composite/partial indexes not expressible in schema.json
128
-
129
- 1. ASSET CREATION / UPDATE (create, update, add, register… for ANY asset type)
130
- ALL creation and update operations require an access key and CANNOT go through ductape_execute.
131
- → Use ductape_cli for every create/update operation. Examples:
132
- ductape_cli("apps create --name \\"Email Service\\" --description \\"Transactional email\\"")
133
- ductape_cli("apps list")
134
- ductape_cli("resources storage list")
135
- ductape_cli("resources database create -f db-config.json")
136
- This applies to: products, apps, and resources (databases, storage, caches, etc.),
137
- cloud connections, and secrets. Environments, app actions, auths, quotas, fallbacks,
138
- jobs, and healthchecks are configured in the Workbench UI. Features have no CLI create
139
- command because their definitions are code-first through features.define.
140
-
141
- ⚠ MULTI-ENV REQUIREMENT — applies to ALL product assets (storage, database, cache,
142
- messageBroker, graph, vector, and any other resource with an envs array):
143
- The envs array MUST contain one entry for EVERY environment defined on the product.
144
- import-persist and provision-persist only generate a draft for ONE env at a time.
145
- Calling import-persist once will fail with MISSING_ENV_COVERAGE because the single-env
146
- draft does not cover all product environments. Use import-persist-all or
147
- provision-persist-all instead — they accept an array of per-env inputs, import/provision
148
- each env in parallel, merge the drafts, and persist once with full env coverage.
149
-
150
- BEFORE constructing any resource file or initiating import/provision:
151
- 1. Run: ductape_cli("products environments list <product_tag> --json")
152
- 2. Note every slug returned (e.g. dev, snd, prd).
153
- 3. Collect connection details or confirmation for EACH slug from the user.
154
- Do not proceed until you have details for every env.
155
-
156
- Use import-persist-all / provision-persist-all (not the single-env forms) whenever
157
- you have details for all envs. The file must be a JSON array — one object per env,
158
- same format as a single import-persist/provision-persist input, all sharing the same
159
- product and component tag. Example:
160
- [{"cloud":"gcp-snd","service":"gcs","type":"storage","product":"my-prod",
161
- "component":"assets","env":"snd","resource":"snd-bucket"},
162
- {"cloud":"gcp-prd","service":"gcs","type":"storage","product":"my-prod",
163
- "component":"assets","env":"prd","resource":"prd-bucket"}]
164
- → ductape_cli("cloud resources import-persist-all -f all-envs.json --json")
165
-
166
- Sharing one resource across multiple envs is allowed but must be confirmed per env:
167
- - Ask the user explicitly: "Should <env> use the same <resource> as <other_env>?"
168
- - Never assume shared configuration without confirmation.
169
- - For cloud-linked envs, set config.cloud to the connection tag for that env and
170
- omit raw credentials; the cloud connection must exist for that env too.
171
-
172
- 2. RUNTIME OPERATIONS (run, dispatch, execute, start, send, produce, query, insert, update, delete…)
173
- The "input" field shape is product- and operation-specific — it is NOT derivable from Joi validators.
174
- It is defined by how the product's action/feature/session/quota/etc. was configured in Ductape.
175
- → ALWAYS call ductape_generate_payload first to get the canonical payload template, EXCEPT for
176
- messaging (produce/consume/dispatch) — see the Events section for why.
177
- → The template shows you exactly which input keys are expected and their types/defaults.
178
- → Then fill in the values and pass the completed payload to ductape_execute.
179
- → Applies to: actions, features, sessions, notifications, databases, storage, graphs, vectors,
180
- quotas, fallbacks, jobs, and any other operation that executes against a pre-configured schema.
181
-
182
- Skipping ductape_generate_payload for applicable runtime operations will produce incorrect or empty input payloads.
183
- Exception: messaging produce/consume/dispatch — the producer defines the schema, so infer from context instead.
184
-
185
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
186
-
187
- ━━━ NESTJS INTEGRATION — use @ductape/nestjs ━━━
188
-
189
- When the target application is a NestJS service or controller, always use @ductape/nestjs
190
- instead of instantiating @ductape/sdk directly. It provides NestJS DI integration,
191
- global interceptors, decorators, and type-safe resource handles.
192
-
193
- SETUP — register once in AppModule:
194
-
195
- ╔══════════════════════════════════════════════════════════════════════════╗
196
- ║ redisUrl IS REQUIRED TO USE ANY *.dispatch() ║
197
- ║ ║
198
- ║ Every dispatch() call (actions, features, events, databases, storage, ║
199
- ║ graph, notifications, quotas, fallback) enqueues jobs ║
200
- ║ via BullMQ over Redis. Without redisUrl the call throws at runtime: ║
201
- ║ "Queues not configured. dispatch() requires a queue connection." ║
202
- ║ ║
203
- ║ *.run(), events.produce(), and @Events.Consumer do NOT need Redis. ║
204
- ║ Only dispatch() does — and it is non-negotiable. ║
205
- ╚══════════════════════════════════════════════════════════════════════════╝
206
-
207
- import { DuctapeModule } from '@ductape/nestjs';
208
-
209
- @Module({
210
- imports: [
211
- DuctapeModule.forIntegration({
212
- accessKey: process.env.DUCTAPE_ACCESS_KEY,
213
- product: 'my-product',
214
- env: process.env.NODE_ENV === 'production' ? 'prd' : 'snd',
215
- redisUrl: process.env.DUCTAPE_REDIS_URL, // required — no dispatch() works without this
216
- }),
217
- ],
218
- })
219
- export class AppModule {}
220
-
221
- // Async (e.g. pulling from ConfigService):
222
- DuctapeModule.forRootAsync({
223
- imports: [ConfigModule],
224
- inject: [ConfigService],
225
- useFactory: (cfg: ConfigService) => ({
226
- accessKey: cfg.get('DUCTAPE_ACCESS_KEY'),
227
- product: cfg.get('DUCTAPE_PRODUCT'),
228
- env: cfg.get('DUCTAPE_ENV'),
229
- redisUrl: cfg.get('DUCTAPE_REDIS_URL'), // required — no dispatch() works without this
230
- }),
231
- })
232
-
233
- Environment variable (add to .env and deployment secrets):
234
- DUCTAPE_REDIS_URL=redis://localhost:6379 # local dev
235
- DUCTAPE_REDIS_URL=rediss://:<password>@host:6380 # managed Redis (TLS)
236
-
237
- INJECTING IN SERVICES AND CONTROLLERS — use @InjectContext():
238
-
239
- import { InjectContext, DuctapeContext } from '@ductape/nestjs';
240
-
241
- @Injectable()
242
- export class MatchService {
243
- constructor(@InjectContext() private readonly ductape: DuctapeContext) {}
244
-
245
- async publishMatchState(matchId: string, state: object) {
246
- await this.ductape.sdk.events.produce({
247
- product: 'my-product',
248
- env: 'prd',
249
- event: 'statecraft-events:match-state',
250
- message: { matchId, ...state },
251
- });
252
- }
253
-
254
- async queryDatabase(env: string) {
255
- const db = await this.ductape.database('core-db', { env });
256
- return db.query({ table: 'matches', where: { active: true } });
257
- }
258
- }
259
-
260
- AVAILABLE ON DuctapeContext:
261
-
262
- ctx.sdk — full @ductape/sdk instance (events, sessions, etc.)
263
- ctx.database(tag, overrides?) — connected DatabaseHandle (query, insert, update, delete)
264
- ctx.storage(tag, overrides?) — StorageHandle (upload, download, remove)
265
- ctx.cache(tag, overrides?) — CacheHandle (get, set, delete)
266
- ctx.graph(tag, overrides?) — GraphHandle
267
- ctx.vector(tag, overrides?) — VectorHandle
268
- ctx.agent(tag, overrides?) — AgentHandle
269
- ctx.warehouse(overrides?) — WarehouseHandle
270
- ctx.cloudTiers(query?) — query available cloud resource tiers and pricing
271
- ctx.runJob({ product, env, event, input? }) — trigger a product job
272
-
273
- CONTROLLER DECORATORS:
274
-
275
- @Product('my-product') — override default product at class or method level
276
- @Env('prd') — override default env
277
- @ApiRun({ app, action }) — bind a controller method to an app action (interceptor runs it automatically)
278
- @ApiConfig({ app, ... }) — shared app credentials at class level
279
- @Webhook.Register(...) — register a webhook consumer URL
280
- @Webhook.Consumer(...) — mark inbound handler for forwarded webhook payloads
281
-
282
- FOR MESSAGING (events.produce / events.consume / events.dispatch):
283
-
284
- In @ductape/nestjs, inject DuctapeContextService (not raw Ductape):
285
- constructor(private readonly ductape: DuctapeContextService) {}
286
-
287
- Produce immediately (method decorator — returns payload as message):
288
- @Events.Produce({ event: 'broker-tag:topic-tag' })
289
- emitOrderCreated(payload: { orderId: string }) { return payload; }
290
-
291
- Dispatch (scheduled or immediate) — dispatch() CANNOT be used without redisUrl in forIntegration:
292
- @Events.Dispatch({ broker: 'order-events', event: 'order-events:order-created', schedule: { every: 60000 } })
293
- dispatchHeartbeat(payload: { message: { ping: boolean } }) { return payload; }
294
-
295
- Dispatch with dynamic schedule — method returns { message, schedule?, retries? }:
296
- @Events.Dispatch({ broker: 'statecraft-events', event: 'statecraft-events:boundary-due' })
297
- scheduleBoundary(match: MatchLifecycle) {
298
- return {
299
- message: buildBoundaryCommand(match),
300
- schedule: { start_at: match.nextBoundaryAt },
301
- retries: 5,
302
- };
303
- }
304
- // method return takes precedence over decorator schedule; use sdk.events.dispatch() directly
305
- // when even the broker/event must vary at call time.
306
- // ALL *.dispatch() calls require redisUrl — BullMQ over Redis. Without it dispatch throws.
307
-
308
- Consume (method decorator — method is called for each incoming message):
309
- @Events.Consumer({ event: 'order-events:order-created' })
310
- async onOrderCreated(message: { orderId: string; total: number }) {
311
- await this.processOrder(message);
312
- // return to ack; throw to nack
313
- }
314
- // DuctapeEventsConsumerService wires this up automatically at module init.
315
- // No manual onModuleInit needed when using the decorator.
316
-
317
- Low-level SDK access (for produce only — not needed for consume with the decorator):
318
- await this.ductape.sdk.events.produce({
319
- product: 'my-product', env: 'prd',
320
- event: 'broker-tag:topic-tag',
321
- message: { ... },
322
- });
323
-
324
- SPECIALIZED MODULES (for injecting handles directly without @InjectContext):
325
-
326
- DuctapeDatabaseModule.forTags(['core-db']) → inject with @Database('core-db')
327
- DuctapeStorageModule.forTags(['assets']) → inject with @Storage('assets')
328
- DuctapeCacheModule, DuctapeGraphModule, DuctapeVectorModule — same pattern
329
-
330
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
331
-
332
- ALL params are passed as a JSON array in positional order matching the SDK signature.
333
-
334
- ━━━ MODULE: product ━━━
335
- IMPORTANT: ALL product.* methods require the access key and will return 403 with a publishable key.
336
- Use ductape_cli for ALL product operations — never ductape_execute:
337
- ductape_cli("products get --tag <tag> --json") ← fetch product + full inventory
338
- ductape_cli("products components list --tag <tag> --json") ← compact non-secret inventory
339
- ductape_cli("products components get --tag <tag> --type notifications --json")
340
- ductape_cli("products components get --tag <tag> --type events --json")
341
- ductape_cli("products create --name <name> --tag <tag>")
342
- ductape_cli("products environments list <tag> --json")
343
- ductape_cli("products environments get <tag> <slug> --json")
344
- ductape_cli("products apps list --product <id> --json")
345
-
346
- SDK method signatures (for reference, admin key only):
347
- product.create [data: { name, description, tag?, envs?: [{slug, name}] }]
348
- product.fetch [product_tag: string]
349
- product.update [product_tag: string, data: { name?: string, description?: string }]
350
- product.environments.create [product_tag, data: { slug, env_name, description, active? }]
351
- product.environments.list [product_tag]
352
- product.environments.fetch [product_tag, slug]
353
- product.apps.add [product_tag, app: { access_tag, envs: [{ app_env_slug, product_env_slug, variables?, auth? }] }]
354
- product.apps.list [product_tag]
355
- product.apps.fetch [product_tag, access_tag]
356
-
357
- ━━━ MODULE: app ━━━
358
- app.create [data: { app_name: string, description: string, unique?: boolean }]
359
- app.fetch [app_tag]
360
- app.update [app_tag, data: { app_name?: string, description?: string, logo?: string, colors?: { primary?: string, secondary?: string, accent?: string, background?: string }, require_whitelist?: boolean, aboutText?: string, aboutHTML?: string, request_type?: string }]
361
- app.init [app_tag]
362
-
363
- ━━━ MODULE: actions (app actions) ━━━
364
- actions.create [app_tag, data: { tag: string, name: string, resource: string, method: "GET"|"POST"|"PUT"|"PATCH"|"DELETE", description?: string, body?: object, params?: object, query?: object, headers?: object, response?: { name?: string, status_code: number, success: boolean, body: object, response_format: "json"|"xml"|"form" } }]
365
- actions.update [app_tag, action_tag, data: { resource?: string, method?: "GET"|"POST"|"PUT"|"PATCH"|"DELETE", description?: string, request_type?: "json"|"xml"|"form", body?: object, query?: object, params?: object, headers?: object, response?: { name?: string, success: boolean, body: object, response_format: "json"|"xml"|"form", status_code: number } }]
366
- actions.fetch [app_tag, action_tag]
367
- actions.list [app_tag]
368
- actions.run [{ product, env, app, action, input: { "body:fieldName": value, ... } }] ← CALL ductape_generate_payload FIRST (operation_family="action", method="run", targets={app, action})
369
- actions.dispatch [{ product, env, app, action, input, retries?, session?, cache?, schedule?: { start_at?, cron?, every?, limit?, tz? } }] ← CALL ductape_generate_payload FIRST (operation_family="action", method="dispatch") — requires redisUrl in ductape initialization
370
-
371
- ━━━ MODULE: auths ━━━
372
- auths.create [app_tag, data: { tag: string, name: string, setup_type: "header"|"bearer"|"basic"|"oauth2"|"apikey", expiry: number, period: "seconds"|"minutes"|"hours"|"days", description: string, action_tag?: string }]
373
- auths.update [app_tag, auth_tag, data: { name?: string, setup_type?: "header"|"bearer"|"basic"|"oauth2"|"apikey", expiry?: number, period?: "seconds"|"minutes"|"hours"|"days", description?: string, action_tag?: string }]
374
- auths.fetch [app_tag, auth_tag]
375
- auths.list [app_tag]
376
-
377
- ━━━ MODULE: webhooks ━━━
378
- webhooks.create [app_tag, data: { tag: string, name: string, description: string, envs?: [{ slug: string, registration_url?: string, method?: "GET"|"POST"|"PUT"|"PATCH", sample?: object }] }]
379
- webhooks.update [app_tag, webhook_tag, data: { name?: string, description?: string, envs?: [{ slug: string, registration_url?: string, method?: "GET"|"POST"|"PUT"|"PATCH", sample?: object }] }]
380
- webhooks.fetch [app_tag, webhook_tag]
381
- webhooks.list [app_tag]
382
- webhooks.events.create [app_tag, data: { tag: string, name: string, selector: string, description: string, sample: object }]
383
- webhooks.events.update [app_tag, event_tag, data: { name?: string, selector?: string, selectorValue?: string, description?: string, sample?: object }]
384
- webhooks.events.fetch [app_tag, event_tag]
385
- webhooks.events.list [app_tag, webhook_tag]
386
-
387
- ━━━ MODULE: sessions ━━━
388
- sessions.create [product_tag, data: { tag: string, name: string, description?: string, expiry: number, period: "seconds"|"minutes"|"hours"|"days", selector: string, schema: Record<string, unknown> }]
389
- ← selector MUST be in the format "$Session{fieldName}" (e.g. "$Session{playerId}"). Plain dot-paths are rejected.
390
- ← schema is SAMPLE DATA — actual example values, not type declarations. The value at the selector path must be a primitive (string|number|boolean), not an object.
391
- sessions.update [product_tag, session_tag, data: { name?: string, description?: string, expiry?: number, period?: "seconds"|"minutes"|"hours"|"days", selector?: string, schema?: object }]
392
- sessions.fetch [product_tag, session_tag]
393
- sessions.list [product_tag]
394
- sessions.delete [product_tag, session_tag]
395
- sessions.start [{ product, env, tag, data: { key: value } }] ← CALL ductape_generate_payload FIRST (operation_family="session", method="start", targets={tag}) to discover the data field shape
396
- sessions.verify [{ product, env, tag, token }]
397
- sessions.refresh [{ product, env, tag, refreshToken }]
398
- sessions.revoke [{ product, env, tag, sessionId?, identifier? }]
399
- sessions.listActive [{ product, env, tag, identifier?, page?, limit? }]
400
- sessions.fetchUsers [{ product, session, env?, page?, limit? }]
401
- sessions.fetchUserDetails [{ product, session, identifier, env? }]
402
- sessions.fetchDashboard [{ product, session, env? }]
403
-
404
- ━━━ MODULE: quotas ━━━
405
- quotas.create [product_tag, data: {
406
- tag: string, // unique identifier
407
- name?: string, // display name
408
- description?: string,
409
-
410
- // INPUT SCHEMA (top-level): declares what fields the quota accepts when called at runtime.
411
- // These are the fields callers will pass to quotas.run / quotas.dispatch.
412
- // Construct this yourself — it is a schema declaration, not a runtime value.
413
- input: {
414
- fieldName: {
415
- type: "string"|"number"|"boolean"|"object"|"array",
416
- required?: boolean,
417
- description?: string
418
- }
419
- },
420
-
421
- options: [ // list of providers tried in order
422
- {
423
- provider?: string, // friendly name for this provider slot
424
- app: string, // app tag whose action to call
425
- type: "action",
426
- event: string, // action tag on that app
427
- quota: number, // max allowed uses for this provider
428
- uses?: number, // current use count (usually 0 at creation)
429
-
430
- // OPTIONS INPUT (per provider): maps the quota's declared input fields → the underlying action's fields.
431
- // ← CALL ductape_generate_payload (operation_family="action", method="run", targets={app, event})
432
- // to discover what fields the action accepts, then wire them with "$Input{fieldName}" references.
433
- input: { "body:field": "$Input{fieldName}" },
434
-
435
- output: {}, // expected output shape (can be {})
436
- retries: number,
437
- healthcheck?: string, // optional healthcheck tag to gate this provider
438
- check_interval?: number // ms between health polls
439
- }
440
- ]
441
- }]
442
- quotas.update [product_tag, quota_tag, data: { name?: string, description?: string, options?: array }]
443
- quotas.fetch [product_tag, quota_tag]
444
- quotas.list [product_tag]
445
- quotas.delete [product_tag, quota_tag]
446
- quotas.run [{ product: string, env: string, tag: string, input: { fieldName: value }, session?: string, cache?: string }] ← CALL ductape_generate_payload FIRST (operation_family="quota", method="run", targets={tag})
447
- quotas.dispatch [{ product: string, env: string, tag: string, input: object, session?: string, cache?: string, schedule?: { cron?: string, delay?: number, at?: string } }] ← CALL ductape_generate_payload FIRST (operation_family="quota", method="dispatch") — requires redisUrl in ductape initialization
448
-
449
- ━━━ MODULE: fallback ━━━
450
- fallback.create [product_tag, data: {
451
- tag: string,
452
- name?: string,
453
- description?: string,
454
-
455
- // INPUT SCHEMA (top-level): declares what fields the fallback accepts when called at runtime.
456
- // These are the fields callers will pass to fallback.run / fallback.dispatch.
457
- // Construct this yourself — it is a schema declaration, not a runtime value.
458
- input: {
459
- fieldName: {
460
- type: "string"|"number"|"boolean"|"object"|"array",
461
- required?: boolean,
462
- description?: string
463
- }
464
- },
465
-
466
- options: [ // ordered list: primary first, then fallback(s)
467
- {
468
- provider?: string, // friendly name e.g. "primary", "backup"
469
- app: string, // app tag
470
- type: "action",
471
- event: string, // action tag
472
-
473
- // OPTIONS INPUT (per provider): maps the fallback's declared input fields → the underlying action's fields.
474
- // ← CALL ductape_generate_payload (operation_family="action", method="run", targets={app, event})
475
- // to discover what fields the action accepts, then wire them with "$Input{fieldName}" references.
476
- input: { "body:field": "$Input{fieldName}" },
477
-
478
- output: {},
479
- retries: number,
480
- healthcheck?: string,
481
- check_interval?: number
482
- }
483
- ]
484
- }]
485
- fallback.update [product_tag, fallback_tag, data: { name?: string, description?: string, options?: array }]
486
- fallback.fetch [product_tag, fallback_tag]
487
- fallback.list [product_tag]
488
- fallback.delete [product_tag, fallback_tag]
489
- fallback.run [{ product: string, env: string, tag: string, input: { fieldName: value }, session?: string, cache?: string }] ← CALL ductape_generate_payload FIRST (operation_family="fallback", method="run", targets={tag})
490
- fallback.dispatch [{ product: string, env: string, tag: string, input: object, session?: string, cache?: string, schedule?: { cron?: string, delay?: number, at?: string } }] ← CALL ductape_generate_payload FIRST (operation_family="fallback", method="dispatch") — requires redisUrl in ductape initialization
491
-
492
- ━━━ MODULE: health ━━━
493
- health.create [product_tag, data: { tag: string, name: string, description?: string, app?: string, event?: string, probe?: { type: "app"|"database"|"feature", app?: string, event?: string, input?: object }, interval: number, retries: number, envs: [{ slug: string, input?: object }], onFailure?: { notifications?: [{ notification: string, message: string, channels: { email?: { recipients: string[] }, push?: { recipients?: string[] }, sms?: { recipients: string[] } } }], webhooks?: [{ url: string, method?: "GET"|"POST", headers?: object, body?: object }] } }]
494
- health.update [product_tag, health_tag, data: { name?: string, description?: string, app?: string, event?: string, probe?: { type: "app"|"database"|"feature", app?: string, event?: string, input?: object }, interval?: number, retries?: number, envs?: [{ slug: string, input?: object }], onFailure?: object }]
495
- health.fetch [product_tag, health_tag]
496
- health.list [product_tag]
497
- health.delete [product_tag, health_tag]
498
- health.status [{ product, env, tag }]
499
- health.run [{ product, env, tag }]
500
- health.check [{ product, env, tag }]
501
-
502
- ━━━ MODULE: notifications ━━━
503
- notifications.create [product_tag, data: { tag: string, name: string, type?: "email"|"push"|"sms"|"callback" }]
504
- notifications.update [product_tag, notif_tag, data: { name?: string, type?: "email"|"push"|"sms"|"callback" }]
505
- notifications.fetch [product_tag, notif_tag]
506
- notifications.list [product_tag]
507
- notifications.delete [product_tag, notif_tag]
508
- notifications.messages.create [product_tag, data: { tag: string, name: string, description?: string, push_notification?: { title: string, body: string, data?: object }, email?: { subject: string, template: string }, callback?: object, sms?: string }]
509
- notifications.messages.update [product_tag, msg_tag, data: { name?: string, description?: string, push_notification?: { title: string, body: string, data?: object }, email?: { subject: string, template: string }, callback?: object, sms?: string }]
510
- notifications.messages.fetch [product_tag, msg_tag]
511
- notifications.messages.list [product_tag, notification_tag]
512
- notifications.send [{ product, env, event, input: { ... } }] ← CALL ductape_generate_payload FIRST (operation_family="notification", method="send", targets={notification})
513
- notifications.email.send [{ product, env, notification, input: { ... }, session?, cache? }] ← CALL ductape_generate_payload FIRST (operation_family="notification", method="email.send")
514
- notifications.push.send [{ product, env, notification, input: { ... }, session?, cache? }] ← CALL ductape_generate_payload FIRST (operation_family="notification", method="push.send")
515
- notifications.sms.send [{ product, env, notification, input: { ... }, session?, cache? }] ← CALL ductape_generate_payload FIRST (operation_family="notification", method="sms.send")
516
- notifications.callback.send [{ product, env, notification, input: { ... }, session?, cache? }] ← CALL ductape_generate_payload FIRST (operation_family="notification", method="callback.send")
517
- notifications.slack.send [{ product, env, notification, input: { text?, blocks?, channel? }, session?, cache? }]
518
- notifications.discord.send [{ product, env, notification, input: { content?, embeds? }, session?, cache? }]
519
- notifications.dispatch [{ product, env, notification, event, input, retries?, session?, cache?, schedule?: { cron?, every?, start_at? } }] ← CALL ductape_generate_payload FIRST (operation_family="notification", method="dispatch") — requires redisUrl in ductape initialization
520
- notifications.getMessages [{ product_tag?, env?, notification_tag?, status?, type?, start_date?, end_date?, page?, limit? }]
521
-
522
- ━━━ MODULE: events (alias: messageBrokers — both accepted; events matches the TS SDK naming) ━━━
523
- events.create [{ product: string, tag: string, name: string, description?: string, type: "kafka"|"rabbitmq"|"redis"|"sqs", envs: [{ slug: string, connection_url: string }] }]
524
- events.update [product_tag, broker_tag, data: { name?: string, description?: string, type?: "kafka"|"rabbitmq"|"redis"|"sqs", envs?: [{ slug: string, connection_url: string }] }]
525
- events.fetch [product_tag, broker_tag]
526
- events.list [product_tag]
527
- events.delete [product_tag, broker_tag]
528
- events.topics.create ← FORBIDDEN with publishable key. Use ductape_cli instead:
529
- ductape_cli("events topics create -f topic.json")
530
- topic.json: { tag: "broker-tag:topic-tag", name, description?, sample?, idempotent?, queueUrls?: [{ env_slug, url }] }
531
- ← Always required before consuming. For SQS: must include queueUrls per env.
532
- ← For Pub/Sub, Kafka, RabbitMQ, Redis, NATS: the first produce call auto-registers the topic,
533
- but you should still create it explicitly so consumers can subscribe before any produce occurs.
534
- events.topics.update ← FORBIDDEN with publishable key. Use ductape_cli:
535
- ductape_cli("events topics update --tag broker:topic -f patch.json")
536
- events.topics.delete ← FORBIDDEN with publishable key. Use ductape_cli:
537
- ductape_cli("events topics delete --tag broker:topic")
538
- events.topics.fetch [product_tag, topic_tag] ← safe via ductape_execute
539
- events.topics.list [product_tag, broker_tag] ← safe via ductape_execute
540
- events.produce [{ product, env, event: "broker_tag:topic_tag", message: { key: value }, session?, cache? }]
541
- events.consume [{ product, env, event: "broker_tag:topic_tag", callback: "function_ref" }]
542
- events.dispatch [{ product, env, event: "broker_tag:topic_tag", input: { message }, retries?, session?, cache?, schedule?: { cron?, every?, start_at? } }] — event is always the fully-qualified "broker:topic" string; do NOT pass broker separately — requires redisUrl in ductape initialization
543
- events.messages.query [{ product, env, brokerTag, topicTag?, producerTag?, consumerTag?, status?, startDate?, endDate?, page?, limit? }]
544
- events.messages.getProducers [{ product, env, brokerTag, topicTag?, page?, limit? }]
545
- events.messages.getConsumers [{ product, env, brokerTag, topicTag?, page?, limit? }]
546
- events.messages.getDeadLetters [{ product, env, brokerTag, topicTag?, consumerTag?, startDate?, endDate?, page?, limit? }]
547
- events.messages.getStats [{ product, env, brokerTag }]
548
- events.messages.getDashboard [{ product, env, brokerTag }]
549
-
550
- ━━━ MODULE: storage ━━━
551
- storage.create [{ product: string, tag: string, name: string, description?: string, envs: [{ slug: string, type: "aws"|"azure"|"gcp", config: { bucket?: string, region?: string, accessKeyId?: string, secretAccessKey?: string, containerName?: string, connectionString?: string, projectId?: string, keyFilename?: string } }] }]
552
- storage.update [product_tag, storage_tag, data: { name?: string, description?: string, envs?: [{ slug: string, type: "aws"|"azure"|"gcp", config: { bucket?: string, region?: string, accessKeyId?: string, secretAccessKey?: string } }] }]
553
- storage.fetch [product_tag, storage_tag]
554
- storage.list [product_tag]
555
- storage.delete [product_tag, storage_tag]
556
- storage.upload [{ product, env, storage, fileName, buffer: string|Buffer, mimeType? }]
557
- storage.download [{ product, env, storage, fileName }]
558
- storage.remove [{ product, env, storage, fileName }]
559
- storage.listFiles [{ product, env, storage, prefix?, limit?, continuationToken? }]
560
- storage.getSignedUrl [{ product, env, storage, fileName, expiresIn?: number, action?: "read"|"write" }]
561
- storage.stats [{ product, env, storage, prefix?, session?, cache? }]
562
- storage.testConnection [{ product, env, storage }]
563
- storage.files.upload [{ product, env, storage, fileName, buffer, mimeType? }]
564
- storage.files.download [{ product, env, storage, fileName }]
565
- storage.files.delete [{ product, env, storage, fileName }]
566
- storage.files.list [{ product, env, storage, prefix?, limit?, continuationToken? }]
567
- storage.files.getSignedUrl [{ product, env, storage, fileName, expiresIn?, action? }]
568
- storage.dispatch [{ product, env, storage, operation, input, retries?, session?, cache?, schedule? }] ← CALL ductape_generate_payload FIRST (operation_family="storage", method="dispatch", targets={storage}) — requires redisUrl in ductape initialization
569
-
570
- ━━━ MODULE: databases ━━━
571
- databases.create [{ product, tag, name, description?, type: "mongodb"|"postgresql"|"mysql"|"sqlite",
572
- envs: [{ slug, connection_url?,
573
- cloud?, ← workspace cloud-connection tag (omit connection_url when using this)
574
- authMode?, ← "cloud_connection" when using cloud field
575
- dbName?, ← REQUIRED for MongoDB cloud connections — the database name inside the cluster (e.g. "myapp_snd")
576
- instance?, ← RDS/Cloud SQL instance id (provision flow only)
577
- region? }] }]
578
- MONGODB CLOUD CONNECTION RULE: when type="mongodb" and cloud is set, dbName is MANDATORY.
579
- Without dbName the Atlas API returns a URL with no database name and the adapter will throw
580
- at migrate/connect time. Provide one dbName per env — they typically differ per environment:
581
- e.g. { slug:"snd", cloud:"atlas-conn", authMode:"cloud_connection", dbName:"myapp_snd" }
582
- { slug:"prd", cloud:"atlas-conn", authMode:"cloud_connection", dbName:"myapp_prd" }
583
- databases.register [product_tag, data: IProductDatabase]
584
- databases.list [product_tag]
585
- databases.fetch [product_tag, database_tag]
586
- databases.updateDatabase [product_tag, database_tag, data: { name?, description?, type?,
587
- envs?: [{ slug, connection_url?, cloud?, authMode?, dbName?, instance?, region? }] }]
588
- databases.connect [{ product, env, database }]
589
- databases.testConnection [{ product, env, database }]
590
- databases.disconnect []
591
- databases.query [{ product, env, database, table, where?: {field: value}, select?: string[], orderBy?: [{field, order:"ASC"|"DESC"}], limit?, offset?, session?, cache? }] ← CALL ductape_generate_payload FIRST (operation_family="database", method="query", targets={database, table})
592
- databases.insert [{ product, env, database, table, data: {key: value}|{key:value}[], returning?, session?, cache? }] ← CALL ductape_generate_payload FIRST (operation_family="database", method="insert", targets={database, table})
593
- databases.update [{ product, env, database, table, data: {key:value}, where: {field: value}, returning?, session?, cache? }] ← CALL ductape_generate_payload FIRST (operation_family="database", method="update", targets={database, table})
594
- databases.delete [{ product, env, database, table, where: {field: value}, returning?, session?, cache? }] ← CALL ductape_generate_payload FIRST (operation_family="database", method="delete", targets={database, table})
595
- databases.upsert [{ product, env, database, table, data: {key:value}, conflictKeys: string[], returning?, session?, cache? }] ← CALL ductape_generate_payload FIRST (operation_family="database", method="upsert", targets={database, table})
596
- NOTE on write operations (insert/update/delete/upsert): if the proxy returns "Authentication failed",
597
- the publishable key does not have write access for that database. Write permissions are configured
598
- in Workbench → Tokens → Publishable Key. An access key is required for write operations that are
599
- not explicitly enabled for the publishable key. In that case the operation must be performed
600
- server-side using a full Ductape SDK instance initialized with an access key.
601
- NOTE: ductape_generate_payload for databases returns:
602
- payload.input — ready-to-use input with real field names in where/data, plus session and cache inside input
603
- meta.schema_context.database.fields — { fieldName: { type, required, description?, sample? } } for the table
604
- meta.schema_context.database.available_tables — list of all tables that have configured actions
605
- meta.schema_warnings — array of warning strings; if it contains "No table schema discovered..." it means no
606
- migrations or schema have been synced to the server yet. To fix this, tell the user:
607
- "Run: ductape db schema push --db <database_tag> --env <env_slug>
608
- This reads the live table schema from the database and syncs it to the Ductape server,
609
- enabling accurate field guidance for AI operations. Once complete, retry your request."
610
- databases.count [{ product, env, database, entity, where? }]
611
- databases.sum [{ product, env, database, entity, field, where? }]
612
- databases.avg [{ product, env, database, entity, field, where? }]
613
- databases.min [{ product, env, database, entity, field, where? }]
614
- databases.max [{ product, env, database, entity, field, where? }]
615
- databases.aggregate [{ product, env, database, entity, aggregations: [{type:"count"|"sum"|"avg"|"min"|"max", field?, alias}], where?, groupBy? }]
616
- databases.schema.create [collection_name, definition: { fieldName: "string"|"number"|"boolean"|"date"|{type, required?, unique?, default?} }, options?]
617
- databases.schema.drop [collection_name, options?]
618
- databases.schema.addField [collection, fieldName, definition]
619
- databases.schema.dropField [collection, fieldName]
620
- databases.schema.renameField [collection, oldName, newName]
621
- databases.schema.modifyField [collection, fieldName, changes: Partial<{type, required, unique, default}>]
622
- databases.schema.createIndex [collection, fields: string[]|[{field, order}], options?: {unique?, name?}]
623
- databases.schema.dropIndex [collection, indexName]
624
- databases.schema.list [schemaName?]
625
- databases.schema.describe [collection_name]
626
- databases.schema.indexes [collection_name]
627
- databases.migration.create [{ product, database, data: { name, tag, description?, value: { up: string[], down: string[] } } }]
628
- databases.migration.update [{ product, tag, data: { name?, description?, value? } }]
629
- databases.migration.fetch [{ product, tag }]
630
- databases.migration.list [{ product, database }]
631
- databases.migration.delete [{ product, tag }]
632
- databases.migration.run [migrations, options?]
633
- databases.migration.rollback [migrations, count?]
634
- databases.migration.history []
635
- databases.migration.status [migrations]
636
- databases.action.create [{ product, database, data: { tag, name, description?, type:"sql"|"nosql", query } }]
637
- databases.action.update [{ product: string, tag: string, data: { name?: string, description?: string, query?: string } }]
638
- databases.action.fetch [action_tag]
639
- databases.action.list [database_tag]
640
- databases.action.delete [action_tag]
641
- databases.action.dispatch [{ product, env, database, action, input, schedule? }] ← CALL ductape_generate_payload FIRST (operation_family="database", method="dispatch", targets={database: "db_tag", table: "table_name"}) — requires redisUrl in ductape initialization
642
- databases.dispatch [{ product, env, database, action, input, schedule? }] ← CALL ductape_generate_payload FIRST (operation_family="database", method="dispatch", targets={database: "db_tag", table: "table_name"}) — requires redisUrl in ductape initialization
643
- databases.beginTransaction [{ product, env, database, isolationLevel?: "READ_COMMITTED"|"REPEATABLE_READ"|"SERIALIZABLE" }]
644
- → returns a transaction object; pass it to insert/update/delete/upsert/query calls as the last argument.
645
- Commit with: transaction.commit() Rollback with: transaction.rollback()
646
- BEFORE using transactions: ask the user which database type and tier they are running.
647
- Transaction support varies by database — call ductape_docs({ topic: "transactions" }) for the full matrix.
648
- databases.transaction [{ product, env, database, isolationLevel? }, async (tx) => { … }]
649
- → managed transaction: commits automatically on success, rolls back on error.
650
- Prefer this over beginTransaction when the callback is self-contained.
651
- See ductape_docs({ topic: "transactions" }) for database/tier compatibility before proceeding.
652
-
653
- ━━━ MODULE: graph ━━━
654
- graph.create [{ product, tag, name, description?, type: "neo4j"|"nebula"|"arangodb", envs: [{slug, connection_url, username?, password?}] }]
655
- graph.fetch [product_tag, graph_tag]
656
- graph.list [product_tag?]
657
- graph.update [product_tag, graph_tag, data: { name?: string, description?: string, type?: "neo4j"|"neptune"|"arangodb"|"memgraph", envs?: [{ slug: string, connection_url: string, username?: string, password?: string, database?: string, graphName?: string, region?: string }] }]
658
- graph.delete [graph_tag, product_tag?]
659
- graph.connect [{ product, env, graph }]
660
- graph.testConnection [config]
661
- graph.createNode [{ labels: string[], properties: { key: value } }, transaction?]
662
- graph.findNodes [{ labels?: string[], where?: { key: value }, limit?, skip? }, transaction?]
663
- graph.findNodeById [id: string|number, transaction?]
664
- graph.updateNode [{ id, properties: { key: value } }, transaction?]
665
- graph.deleteNode [{ id, detach?: boolean }, transaction?]
666
- graph.mergeNode [{ labels: string[], matchProps: { key: value }, setProps?: { key: value } }, transaction?]
667
- graph.addLabels [{ id, labels: string[] }, transaction?]
668
- graph.removeLabels [{ id, labels: string[] }, transaction?]
669
- graph.setLabels [{ id, labels: string[] }, transaction?]
670
- graph.createRelationship [{ fromId, toId, type: string, properties?: { key: value } }, transaction?]
671
- graph.findRelationships [{ type?: string, where?, limit? }, transaction?]
672
- graph.findRelationshipById [id, transaction?]
673
- graph.updateRelationship [{ id, properties: { key: value } }, transaction?]
674
- graph.deleteRelationship [{ id }, transaction?]
675
- graph.mergeRelationship [{ fromId, toId, type, matchProps?, setProps? }, transaction?]
676
- graph.traverse [{ startId, direction?: "in"|"out"|"both", relationshipTypes?: string[], maxDepth?, where? }, transaction?]
677
- graph.shortestPath [{ fromId, toId, relationshipType?, maxDepth? }, transaction?]
678
- graph.allPaths [{ fromId, toId, relationshipType?, maxDepth? }, transaction?]
679
- graph.getNeighborhood [{ id, depth?, relationshipTypes? }, transaction?]
680
- graph.findConnectedComponents [{ labels? }, transaction?]
681
- graph.query [cypher_query: string, params?: { key: value }, transaction?]
682
- graph.getStatistics [transaction?]
683
- graph.countNodes [labels?: string[], where?, transaction?]
684
- graph.countRelationships [types?: string[], where?, transaction?]
685
- graph.fullTextSearch [{ index, query, limit? }, transaction?]
686
- graph.vectorSearch [{ index, vector: number[], topK? }, transaction?]
687
- graph.createNodeIndex [{ label, field, type?: "btree"|"fulltext"|"vector" }]
688
- graph.createNodeConstraint [{ label, field, type: "unique"|"exists" }]
689
- graph.createRelationshipIndex [{ type, field }]
690
- graph.listIndexes []
691
- graph.listConstraints []
692
- graph.dropIndex [name: string]
693
- graph.dropConstraint [name: string]
694
- graph.listLabels []
695
- graph.listRelationshipTypes []
696
- graph.createAction [{ graphTag, tag, name, query, params? }, productTag?]
697
- graph.listActions [graphTag?, productTag?]
698
- graph.getAction [actionTag, graphTag?, productTag?]
699
- graph.updateAction [actionTag, updates, graphTag?, productTag?]
700
- graph.deleteAction [actionTag, graphTag?, productTag?]
701
- graph.beginTransaction [options?]
702
- graph.commitTransaction [transaction]
703
- graph.rollbackTransaction [transaction]
704
- graph.dispatch [data] ← CALL ductape_generate_payload FIRST (operation_family="graph", method="dispatch", targets={graph}) — requires redisUrl in ductape initialization
705
-
706
- ━━━ MODULE: vector ━━━
707
- vector.create [{ product, tag, name, description?, provider: "pinecone"|"qdrant"|"weaviate", dimensions: number, metric?: "cosine"|"euclidean"|"dotproduct", envs: [{slug, api_key, environment?}] }]
708
- vector.update [{ product: string, tag: string, data: { name?: string, description?: string, type?: "pinecone"|"qdrant"|"weaviate"|"chroma"|"milvus"|"pgvector"|"memory", dimensions?: number, metric?: "cosine"|"euclidean"|"dotproduct", envs?: [{ slug: string, endpoint?: string, apiKey?: string, region?: string, index?: string, namespace?: string }] } }]
709
- vector.fetch [{ product, tag }]
710
- vector.list [{ product }]
711
- vector.delete [{ product, tag }]
712
- vector.connect [{ product, env, vector }]
713
- vector.disconnect [{ product, env, vector }]
714
- vector.query [{ product, env, tag, vector: number[], topK?: number, filter?, namespace?, includeValues?, includeMetadata? }]
715
- vector.upsert [{ product, env, tag, vectors: [{id, values: number[], metadata?}], namespace? }]
716
- vector.fetchVectors [{ product, env, vector, ids: string[], namespace? }]
717
- vector.deleteVectors [{ product, env, tag, ids: string[], namespace?, deleteAll? }]
718
- vector.findSimilar [{ product, env, vector, values: number[], topK?, filter?, namespace?, includeValues?, includeMetadata? }]
719
- vector.upsertOne [{ product, env, tag, id, values: number[], metadata?, namespace?, session? }]
720
- vector.fetchOne [{ product, env, vector, id, namespace? }]
721
- vector.updateVector [{ product, env, vector, id, values?, setMetadata?, mergeMetadata?, namespace? }]
722
- vector.updateMetadata [{ product, env, vector, id, metadata: { key: value }, merge?, namespace? }]
723
- vector.deleteByIds [{ product, env, vector, ids: string[], namespace? }]
724
- vector.deleteAll [{ product, env, vector, namespace? }]
725
- vector.listVectors [{ product, env, vector, namespace?, prefix?, limit?, cursor? }]
726
- vector.listAllVectors [{ product, env, vector, namespace?, prefix? }]
727
- vector.listNamespaces [{ product, env, vector }]
728
- vector.deleteNamespace [{ product, env, vector, namespace }]
729
- vector.describeIndex [{ product, env, vector }]
730
- vector.getStats [{ product, env, vector }]
731
- vector.createIndex [{ product, env, vector, name, dimensions, metric?, replicas?, shards? }]
732
- vector.deleteIndex [{ product, env, vector, name }]
733
- vector.listIndexes [{ product, env, vector }]
734
- vector.count [{ product, env, vector, namespace? }]
735
-
736
- ━━━ MODULE: features ━━━
737
- Feature definitions are code-first. Use features.define in application source; do not call
738
- administrative create/update/delete methods through ductape_execute.
739
- features.fetch [product_tag, feature_tag]
740
- features.fetchAll [product_tag]
741
-
742
- features.define [{
743
- product?: string,
744
- tag: string,
745
- name: string,
746
- description?: string,
747
- input?: { fieldName: { type: string, required?: boolean } },
748
- output?: object,
749
- signals?: { signalName: { input?: object } },
750
- queries?: { queryName: { handler?: function } },
751
- options?: { timeout?: number, retries?: number },
752
- envs?: [{ slug: string, active?: boolean }],
753
- recordInput?: object, // sample input for step-recording (run once during define)
754
- recordScenarios?: object[], // multiple recording scenarios for branching
755
- branchOverrides?: object, // force step results during recording to reach later branches
756
- handler: async (ctx) => {
757
- // ctx.input – typed feature input
758
- // ctx.step(tag, fn, rollback?, opts?) – define a durable step
759
- // ctx.api.run({ app, event, input }) – call an app action
760
- // ctx.database.query/insert/update/delete({ database, event, ... })
761
- // ctx.graph.execute({ graph, action, input })
762
- // ctx.notification.send/email/push/sms({ notification, event, ... })
763
- // ctx.storage.upload/download({ storage, event, input })
764
- // ctx.events.produce({ event: "broker:topic", message: {} })
765
- // ctx.quota.execute({ quota, input })
766
- // ctx.fallback.execute({ fallback, input })
767
- // ctx.healthcheck.getStatus(tag)
768
- // ctx.sleep(ms|"1h30m")
769
- // ctx.waitForSignal("signal-name", { timeout? })
770
- // ctx.setState(key, value) / ctx.getState(key)
771
- // ctx.feature(childId, childTag, childInput) – child feature
772
- }
773
- }]
774
-
775
- features.execute [{ ← CALL ductape_generate_payload FIRST (operation_family="features", method="execute", targets={tag}) to discover the input field shape
776
- product: string,
777
- env: string,
778
- tag: string,
779
- input: { fieldName: value },
780
- session?: string, // "session_tag:jwt_token"
781
- idempotency_key?: string,
782
- cache?: string,
783
- retries?: number,
784
- timeout?: number
785
- }]
786
-
787
- features.dispatch [{ ← CALL ductape_generate_payload FIRST (operation_family="features", method="dispatch", targets={feature}) — requires redisUrl in ductape initialization
788
- product: string,
789
- env: string,
790
- feature: string,
791
- input: { fieldName: value },
792
- schedule?: { start_at?: number|string, cron?: string, every?: number, limit?: number, endDate?: number|string, tz?: string },
793
- session?: string,
794
- cache?: string,
795
- retries?: number
796
- }]
797
-
798
- features.signal [{ product, env, feature_id, signal: string, payload?: object }]
799
- features.query [{ product, env, feature_id, query: string, params?: object }]
800
- features.status [executionId: string]
801
- features.cancel [executionId: string, reason?: string]
802
- features.replay [executionId: string, options?: object]
803
- features.restart [executionId: string]
804
- features.resume [executionId: string]
805
- features.replayFromStep [executionId: string, stepTag: string]
806
- features.history [executionId: string]
807
- features.stepDetail [executionId: string, stepTag: string]
808
- features.relatedExecutions [executionId: string]
809
- features.compare [executionId1: string, executionId2: string]
810
-
811
- ━━━ MODULE: caches ━━━
812
- caches.create [product_tag, data: { name: string, tag: string, description?: string, expiry: number }]
813
- ← expiry is in MILLISECONDS (e.g. 3600000 = 1 hour, 86400000 = 1 day). No type or envs — Ductape manages the store.
814
- caches.update [product_tag, cache_tag, data: { name?: string, description?: string, expiry?: number }]
815
- caches.fetch [product_tag, cache_tag]
816
- caches.list [product_tag]
817
- caches.delete [product_tag, cache_tag]
818
- caches.get [{ key: string }]
819
- caches.set [{ product, cache, key, value: string, componentTag?, componentType?, expiry?: string (ISO 8601 absolute timestamp — NOT a duration; e.g. new Date(Date.now()+3600000).toISOString() for 1h TTL), env }]
820
- caches.clear [{ key: string }]
821
- caches.clearAll [{ product, cache, env? }]
822
- caches.fetchValues [{ product, cache, env?, page?, limit?, expiryFilter?: "all"|"expiring"|"permanent"|"expired" }]
823
- caches.fetchDashboard [{ product, cache, env? }]
824
-
825
- ━━━ MODULE: jobs ━━━
826
- jobs.create [product_tag, data: { tag, name, description?, type: "action"|"notification"|"storage"|"database"|"publish", app?, event?, executions?, intervals?, start_at? }]
827
- jobs.update [product_tag, job_tag, data: { name?: string, description?: string, type?: "action"|"notification"|"storage"|"database"|"publish", app?: string, event?: string, executions?: number, intervals?: number, start_at?: number }]
828
- jobs.fetch [product_tag, job_tag]
829
- jobs.list [product_tag]
830
- jobs.delete [product_tag, job_tag]
831
- jobs.get [jobId: string]
832
- jobs.listJobs [{ product?, status?, limit?, page? }]
833
- jobs.cancel [jobId, { reason? }?]
834
- jobs.cancelMany [{ product?, status? }]
835
- jobs.pause [jobId]
836
- jobs.pauseMany [{ product?, recurring? }]
837
- jobs.resume [jobId]
838
- jobs.resumeMany [{ product?, status? }]
839
- jobs.retry [jobId, { delay? }?]
840
- jobs.retryMany [{ status?, from? }]
841
- jobs.reschedule [jobId, { start_at?, cron?, every? }]
842
- jobs.getHistory [jobId, { limit? }?]
843
- jobs.getStats [{ product?, env?, from?, to? }?]
844
-
845
- ━━━ MODULE: secrets ━━━
846
- secrets.create [{ key, value, description?, token_type?: "api"|"password"|"certificate", scope?: string[], envs?: string[], expires_at?: number }]
847
- secrets.update [key, { value?, description?, scope?, envs?, expires_at? }]
848
- secrets.fetch [key]
849
- secrets.list []
850
- secrets.delete [key]
851
- secrets.revoke [key]
852
- secrets.exists [key]
853
- secrets.resolve [value, { env?, app? }?]
854
- secrets.validate [value]
855
-
856
- ━━━ MODULE: logs ━━━
857
- logs.init [product_tag?, app_tag?] (at least one required)
858
- logs.fetch [{ product_id?, app_id?, env?, level?: "info"|"warn"|"error"|"debug", start_date?, end_date?, page?, limit? }]
859
-
860
- ━━━ TOOL: ductape_schema ━━━
861
- Use this tool to dynamically discover the full field manifest for Ductape asset creation and update operations,
862
- derived live from the SDK's Joi validators. No arguments are required to get everything.
863
-
864
- Arguments:
865
- module?: "app" | "product" (optional — omit to get both modules + all enums)
866
-
867
- Returns:
868
- When module is omitted:
869
- {
870
- modules: {
871
- app: { [method: string]: { fields: { [field]: FieldDef } } },
872
- product: { [method: string]: { fields: { [field]: FieldDef } } }
873
- },
874
- enums: {
875
- HttpMethods, AuthTypes, DataFormats, DataTypes, StatusCodes, TokenPeriods,
876
- DatabaseTypes, AppComponents, ProductComponents, Categories, DefaultTypes,
877
- EventTypes, InputsTypes, PublicStates
878
- }
879
- }
880
-
881
- When module="app" or module="product":
882
- { module: string, methods: { [method]: { fields: { [field]: FieldDef } } }, enums: { ... } }
883
-
884
- FieldDef shape:
885
- {
886
- type: string, // "string" | "number" | "boolean" | "object" | "array" | "alternatives" | "any"
887
- required: boolean,
888
- enum?: (string | number | boolean)[], // present when field is restricted to specific values
889
- fields?: { [key]: FieldDef }, // present for nested objects
890
- items?: FieldDef, // present for arrays (describes each element)
891
- oneOf?: FieldDef[], // present for alternatives (.try(...))
892
- minLength?: number, maxLength?: number, // string length constraints
893
- min?: number, max?: number, // number range constraints
894
- pattern?: string // string pattern regex
895
- }
896
-
897
- Example method keys for app module:
898
- "create", "update", "environments.create", "environments.update",
899
- "actions.create", "actions.update", "auths.create", "auths.update",
900
- "variables.create", "variables.update", "constants.create", "constants.update",
901
- "webhooks.create", "webhooks.update", "webhooks.events.create", "webhooks.events.update"
902
-
903
- Example method keys for product module:
904
- "create", "environments.create", "environments.update", "apps.add", "apps.update",
905
- "databases.create", "databases.update", "notifications.create", "notifications.update",
906
- "caches.create", "caches.update", "jobs.create", "jobs.update",
907
- "messageBrokers.create", "messageBrokers.update", "fallbacks.create", "fallbacks.update",
908
- "graphs.create", "graphs.update", "vectors.create", "vectors.update",
909
- "sessions.create", "sessions.update", "healthchecks.create",
910
- "quotas.create", "quotas.update", "functions.create", "functions.update",
911
- "agents.create", "agents.update", "models.create", "models.update",
912
- "storage.create", "storage.update"
913
-
914
- When to use this tool:
915
- - Before constructing an administrative asset file for ductape_cli or Workbench — use this to discover required fields
916
- - To enumerate valid enum values (e.g. which DatabaseTypes or AuthTypes are accepted)
917
- - To understand nested object shapes without reading SDK docs
918
- `;
919
-
920
- const executeInputSchema = z.object({
921
- publishable_key: z.string().optional().describe('Your workspace publishable key. Omit if DUCTAPE_PUBLISHABLE_KEY is set in the MCP server env config.'),
922
- module: z.enum(MODULES as [string, ...string[]]).describe(
923
- 'SDK module to target. Options: ' + MODULES.join(', ') + '.'
924
- ),
925
- method: z.string().describe(
926
- 'The SDK method to call. Use dot-notation for nested methods (e.g. "environments.create", "schema.addField", "messages.query").\n' +
927
- 'Full reference: see the METHOD_DOCS embedded in the params description.'
928
- ),
929
- params: z.array(z.any()).default([]).describe(METHOD_DOCS),
930
- });
931
-
932
- const payloadGenerateInputSchema = z.object({
933
- publishable_key: z.string().optional().describe('Your workspace publishable key. Omit if DUCTAPE_PUBLISHABLE_KEY is set in the MCP server env config.'),
934
- product_tag: z.string().describe('The product tag (e.g. "my-product"). Identifies which product configuration to read.'),
935
- env_slug: z.string().describe('The environment slug (e.g. "dev", "prod"). Determines which env-specific values are used.'),
936
- operation_family: z.string().describe(
937
- 'The operation category. One of: "action", "features", "database", "graph", "vector", "storage", ' +
938
- '"notification", "messaging", "quota", "fallback", "healthcheck", "session", "cache".'
939
- ),
940
- method: z.string().describe(
941
- 'The method within the operation family. E.g. "run", "dispatch", "execute", "start", "send", "produce", ' +
942
- '"query", "insert", "update", "delete". Must match an allowed method for the given operation_family.'
943
- ),
944
- targets: z.record(z.any()).optional().describe(
945
- 'Identifies the specific operation to generate a payload for. ' +
946
- 'For actions: { app: "app_tag", action: "action_tag" }. ' +
947
- 'For features: { feature: "feature_tag" }. ' +
948
- 'For databases: { database: "db_tag", table: "table_or_collection_name" }. ' +
949
- ' Providing table is strongly recommended — the generator scans all actions configured for that table, ' +
950
- ' aggregates field definitions (name, type, required, sample value), and returns them in ' +
951
- ' meta.schema_context.database.fields. The where/data placeholders in the payload are also ' +
952
- ' pre-filled with the real field names. meta.schema_context.database.available_tables lists ' +
953
- ' every table the database has actions configured for, so you can discover table names first. ' +
954
- 'For graphs: { graph: "graph_tag", node_label?: "NodeLabel", edge_type?: "REL_TYPE" }. ' +
955
- ' node_label and edge_type pre-fill the Cypher template; omit to get generic placeholders. ' +
956
- ' meta.schema_context.graph.type tells you the engine (neo4j, neptune, etc.). ' +
957
- 'For vectors: { vector: "vector_tag", namespace?: "ns" }. ' +
958
- ' meta.schema_context.vector surfaces dimensions, metric, and index so you know what size ' +
959
- ' embedding to pass and which distance function is used. ' +
960
- 'For sessions: { session: "session_tag" }. ' +
961
- 'For notifications: { notification: "notif_tag" }. ' +
962
- 'For quotas/fallbacks: { tag: "resource_tag" }. ' +
963
- 'For storage: { storage: "storage_tag" }. ' +
964
- 'For messaging: { broker: "broker_tag", topic?: "topic_tag" }.'
965
- ),
966
- include_session: z.boolean().optional().describe(
967
- 'Include a session placeholder inside the generated input object. ' +
968
- 'Defaults to false for execution_context="system" and true otherwise. ' +
969
- 'The placeholder is named "<session_tag_token>" to indicate it expects the runtime JWT, not the tag name.'
970
- ),
971
- execution_context: z.enum(['user', 'delegated', 'system']).optional().default('user').describe(
972
- 'Actor intent for this runtime operation. "user" means an active request initiated by the authenticated user; ' +
973
- '"delegated" means work acting on behalf of a user with a short-lived delegated identity or application-owned ' +
974
- 'immutable actor context; "system" means intentionally unattributed background work. This drives session warnings.'
975
- ),
976
- include_cache: z.boolean().optional().describe(
977
- 'Include a cache tag inside the generated payload. Defaults to false; set true only when the runtime call should use cache explicitly.'
978
- ),
979
- schema_mode: z.enum(['strict', 'best_effort']).optional().default('best_effort').describe(
980
- '"strict" — fail if any required field cannot be resolved. ' +
981
- '"best_effort" — fill what is known, leave unknowns as null/placeholder. Use best_effort when exploring.'
982
- ),
983
- input_hint: z.record(z.any()).optional().describe(
984
- 'Optional. Partial input values you already know. These are merged into the generated payload template ' +
985
- 'so the result is pre-filled. Use this to get a payload with known values already substituted.'
986
- ),
987
- });
988
-
989
- const snippetGenerateInputSchema = payloadGenerateInputSchema.extend({
990
- language: z.enum(['typescript', 'python']).default('typescript'),
991
- });
992
-
993
- const schemaInputSchema = z.object({
994
- module: z.enum(['app', 'product']).optional().describe(
995
- 'Optional. Scope the result to a single module ("app" or "product"). Omit to get the full manifest including enums.'
996
- ),
997
- });
998
-
999
- function toPrettyJson(value: unknown): string {
1000
- return JSON.stringify(value ?? {}, null, 2);
1001
- }
1002
-
1003
- const ALLOWED_SNIPPET_METHODS: Record<string, string[]> = {
1004
- action: ['run', 'dispatch', 'execute'],
1005
- features: ['run', 'dispatch', 'execute'],
1006
- database: ['dispatch', 'find', 'insert', 'update', 'delete', 'query', 'upsert', 'count', 'aggregate'],
1007
- graph: ['dispatch', 'find', 'insert', 'update', 'delete', 'query', 'execute'],
1008
- vector: ['query', 'find', 'findSimilar', 'upsert', 'insert', 'delete', 'dispatch'],
1009
- storage: ['dispatch', 'upload', 'download', 'remove', 'listFiles', 'getSignedUrl', 'stats'],
1010
- notification: ['dispatch', 'send', 'email.send', 'push.send', 'sms.send', 'callback.send', 'slack.send', 'discord.send'],
1011
- messaging: ['dispatch', 'send', 'publish', 'produce', 'consume'],
1012
- broker: ['dispatch', 'send', 'publish', 'produce', 'consume'],
1013
- quota: ['run', 'dispatch', 'check', 'consume'],
1014
- fallback: ['run', 'dispatch'],
1015
- healthcheck: ['run', 'check', 'status'],
1016
- health: ['run', 'check', 'status'],
1017
- session: ['start', 'verify', 'refresh', 'revoke', 'listActive'],
1018
- cache: ['get', 'set', 'clear', 'clearAll', 'dispatch', 'fetchValues'],
1019
- };
1020
-
1021
- function ensureSupportedSnippetOperation(operationFamily: string, method: string): void {
1022
- const family = String(operationFamily || '').toLowerCase();
1023
- const m = String(method || '');
1024
- const allowed = ALLOWED_SNIPPET_METHODS[family] || [];
1025
- if (!allowed.includes(m)) {
1026
- const supportedFamilies = Object.keys(ALLOWED_SNIPPET_METHODS).sort().join(', ');
1027
- throw new Error(
1028
- `Unsupported snippet operation "${family}.${m}". Allowed methods for "${family}": ${allowed.join(', ') || '(none)'}.\n` +
1029
- `Supported families: ${supportedFamilies}`,
1030
- );
1031
- }
1032
- }
1033
-
1034
- function resolveSdkCallPath(operationFamily: string, method: string): string {
1035
- const family = String(operationFamily || '').toLowerCase();
1036
- const m = String(method || '').toLowerCase();
1037
-
1038
- if (family === 'action') return `actions.${m}`;
1039
- if (family === 'features' || family === 'feature') return `features.${m}`;
1040
- if (family === 'database') return m === 'dispatch' ? 'databases.dispatch' : `databases.${m}`;
1041
- if (family === 'graph') return m === 'dispatch' ? 'graph.dispatch' : `graph.${m}`;
1042
- if (family === 'vector') return `vector.${m}`;
1043
- if (family === 'storage') return m === 'dispatch' ? 'storage.dispatch' : `storage.${m}`;
1044
- if (family === 'notification') return m === 'dispatch' ? 'notifications.dispatch' : `notifications.${m}`;
1045
- if (family === 'messaging' || family === 'broker' || family === 'events' || family === 'event') {
1046
- if (m === 'dispatch') return 'events.dispatch';
1047
- if (m === 'send' || m === 'publish' || m === 'produce') return 'events.produce';
1048
- return `events.${m}`;
1049
- }
1050
- if (family === 'quota') return `quotas.${m}`;
1051
- if (family === 'fallback') return `fallback.${m}`;
1052
- if (family === 'healthcheck' || family === 'health') return `health.${m}`;
1053
- if (family === 'session') return `sessions.${m}`;
1054
- if (family === 'cache') return m === 'dispatch' ? 'caches.dispatch' : `caches.${m}`;
1055
- return `${family}.${m}`;
1056
- }
1057
-
1058
- function buildSdkInvocationArgs(payload: Record<string, unknown>): Record<string, unknown> {
1059
- const input = (payload?.input as Record<string, unknown>) || {};
1060
- return {
1061
- product: payload?.product_tag,
1062
- env: payload?.env,
1063
- ...input,
1064
- ...(payload?.session ? { session: payload.session } : {}),
1065
- ...(payload?.cache ? { cache: payload.cache } : {}),
1066
- };
1067
- }
1068
-
1069
- function operationAcceptsSession(operationFamily: string, method: string): boolean {
1070
- const family = operationFamily.toLowerCase();
1071
- const sessionFamilies = new Set([
1072
- 'action', 'features', 'feature', 'database', 'graph', 'vector', 'storage',
1073
- 'notification', 'messaging', 'broker', 'events', 'event', 'quota', 'fallback',
1074
- ]);
1075
- if (!sessionFamilies.has(family)) return false;
1076
- return !['consume', 'status', 'check', 'fetch', 'list'].includes(method.toLowerCase());
1077
- }
1078
-
1079
- function addSessionAwarenessMetadata(
1080
- generated: any,
1081
- args: z.infer<typeof payloadGenerateInputSchema>,
1082
- ): any {
1083
- const acceptsSession = operationAcceptsSession(args.operation_family, args.method);
1084
- const executionContext = args.execution_context ?? 'user';
1085
- const payloadHasSession = Boolean(generated?.payload?.session || generated?.payload?.input?.session);
1086
- const sessionRequested = args.include_session ?? executionContext !== 'system';
1087
- const warnings: string[] = [];
1088
-
1089
- if (acceptsSession && executionContext !== 'system' && (!sessionRequested || !payloadHasSession)) {
1090
- warnings.push(
1091
- `Session attribution is missing for a ${executionContext}-context operation. ` +
1092
- 'Pass the original full session token for immediate user work, or an approved delegated identity/immutable actor context for delayed work.',
1093
- );
1094
- }
1095
- if (executionContext === 'system' && payloadHasSession) {
1096
- warnings.push(
1097
- 'This operation is marked system-context but contains a session placeholder. Remove it unless the work is actually user/delegated.',
1098
- );
1099
- }
1100
-
1101
- generated.meta = {
1102
- ...(generated?.meta ?? {}),
1103
- session_awareness: {
1104
- accepts_session: acceptsSession,
1105
- execution_context: executionContext,
1106
- appears_user_context: executionContext === 'user',
1107
- session_requested: sessionRequested,
1108
- session_present_in_payload: payloadHasSession,
1109
- intentionally_system_context: executionContext === 'system',
1110
- warnings,
1111
- },
1112
- };
1113
- return generated;
1114
- }
1115
-
1116
- function buildTypeScriptSnippet(
1117
- payload: Record<string, unknown>,
1118
- operationFamily: string,
1119
- method: string,
1120
- ): string {
1121
- const callPath = resolveSdkCallPath(operationFamily, method);
1122
- const invocationArgs = buildSdkInvocationArgs(payload);
1123
- return `import Ductape from "@ductape/sdk";
1124
-
1125
- const ductape = new Ductape({
1126
- workspace_id: process.env.DUCTAPE_WORKSPACE_ID!,
1127
- user_id: process.env.DUCTAPE_USER_ID!,
1128
- public_key: process.env.DUCTAPE_PUBLIC_KEY!,
1129
- });
1130
-
1131
- async function run() {
1132
- const payload = ${toPrettyJson(payload)};
1133
- const args = ${toPrettyJson(invocationArgs)};
1134
- const result = await ductape.${callPath}(args);
1135
- return { payload, result };
1136
- }
1137
-
1138
- run().catch(console.error);
1139
- `;
1140
- }
1141
-
1142
- function buildPythonSnippet(
1143
- payload: Record<string, unknown>,
1144
- operationFamily: string,
1145
- method: string,
1146
- ): string {
1147
- const callPath = resolveSdkCallPath(operationFamily, method);
1148
- const invocationArgs = buildSdkInvocationArgs(payload);
1149
- return `from ductape import Ductape
1150
- import os
1151
- import json
1152
-
1153
- ductape = Ductape(
1154
- workspace_id=os.environ.get("DUCTAPE_WORKSPACE_ID"),
1155
- user_id=os.environ.get("DUCTAPE_USER_ID"),
1156
- public_key=os.environ.get("DUCTAPE_PUBLIC_KEY"),
1157
- )
1158
-
1159
- def run():
1160
- payload = ${toPrettyJson(payload)}
1161
- args = ${toPrettyJson(invocationArgs)}
1162
- result = ductape.${callPath}(args)
1163
- return {"payload": payload, "result": result}
1164
-
1165
- if __name__ == "__main__":
1166
- print(json.dumps(run(), indent=2))
1167
- `;
1168
- }
1169
-
1170
- function buildSnippet(
1171
- language: 'typescript' | 'python',
1172
- payload: Record<string, unknown>,
1173
- operationFamily: string,
1174
- method: string,
1175
- ): string {
1176
- return language === 'python'
1177
- ? buildPythonSnippet(payload, operationFamily, method)
1178
- : buildTypeScriptSnippet(payload, operationFamily, method);
1179
- }
1180
-
1181
-
1182
- // ─── CLI helpers ─────────────────────────────────────────────────────────────
1183
-
1184
- // Per-process cache: avoids re-running whoami / workspaces use on every call.
1185
- let authState: 'unknown' | 'ok' | 'none' = 'unknown';
1186
- let workspaceSynced = false;
1187
-
1188
- const ADMIN_SUBCOMMANDS = [
1189
- 'login', 'logout', 'whoami',
1190
- 'profiles',
1191
- 'workspaces',
1192
- 'link', 'unlink', 'init',
1193
- 'products', 'apps',
1194
- 'resources',
1195
- 'notifications',
1196
- 'events',
1197
- 'cloud',
1198
- 'secrets',
1199
- 'generate',
1200
- 'apply',
1201
- 'db',
1202
- 'graph',
1203
- ];
1204
-
1205
- function checkCli(): { available: boolean; version?: string } {
1206
- try {
1207
- const out = execSync('ductape --version', {
1208
- encoding: 'utf8',
1209
- timeout: 5000,
1210
- stdio: ['pipe', 'pipe', 'pipe'],
1211
- env: cliEnvironment(),
1212
- }).trim();
1213
- return { available: true, version: out || 'unknown' };
1214
- } catch {
1215
- return { available: false };
1216
- }
1217
- }
1218
-
1219
- function checkLoginState(): 'ok' | 'none' {
1220
- try {
1221
- // `ductape whoami` only reports whether a local credentials file exists. It does not validate
1222
- // the stored token, so an expired token would be cached as authenticated and fail later with 401.
1223
- // Use a harmless authenticated read to validate the credential against the API.
1224
- execSync('ductape workspaces list --json', {
1225
- encoding: 'utf8',
1226
- timeout: 10000,
1227
- stdio: ['pipe', 'pipe', 'pipe'],
1228
- env: cliEnvironment(),
1229
- });
1230
- authState = 'ok';
1231
- return 'ok';
1232
- } catch {
1233
- authState = 'none';
1234
- return 'none';
1235
- }
1236
- }
1237
-
1238
- function syncWorkspace(): void {
1239
- const target = process.env.DUCTAPE_WORKSPACE;
1240
- workspaceSynced = true; // mark done regardless so we don't retry on every call
1241
- if (!target) return;
1242
- try {
1243
- execSync(`ductape workspaces use "${target}"`, {
1244
- encoding: 'utf8',
1245
- timeout: 10000,
1246
- stdio: ['pipe', 'pipe', 'pipe'],
1247
- env: cliEnvironment(),
1248
- });
1249
- } catch {
1250
- // best-effort; if it fails the user will see workspace-mismatch errors on subsequent commands
1251
- }
1252
- }
1253
-
1254
- function runCli(command: string): { success: boolean; output: string } {
1255
- const first = command.trim().split(/\s+/)[0];
1256
- if (!ADMIN_SUBCOMMANDS.includes(first)) {
1257
- return {
1258
- success: false,
1259
- output: `Subcommand "${first}" is not in the allowed admin subcommand list. Allowed: ${ADMIN_SUBCOMMANDS.join(', ')}.`,
1260
- };
1261
- }
1262
- // Auto-inject --workspace on login if DUCTAPE_WORKSPACE is set and caller hasn't specified one
1263
- let finalCommand = command;
1264
- const ws = process.env.DUCTAPE_WORKSPACE;
1265
- if (first === 'login' && ws && !command.includes('--workspace') && !command.includes('--skip-workspace-select')) {
1266
- finalCommand = `${command} --workspace "${ws}"`;
1267
- }
1268
- try {
1269
- const output = execSync(`ductape ${finalCommand}`, {
1270
- encoding: 'utf8',
1271
- // Must exceed the proxy's operation timeout so stderr can preserve the structured timeout
1272
- // instead of this wrapper killing the CLI first and reducing it to "(no data)".
1273
- timeout: 90000,
1274
- stdio: ['pipe', 'pipe', 'pipe'],
1275
- env: cliEnvironment(),
1276
- });
1277
- return { success: true, output: output.trim() };
1278
- } catch (err: any) {
1279
- const msg = (err.stderr || err.stdout || err.message || String(err)).trim();
1280
- if (/\bHTTP 401\b|unauthori[sz]ed|invalid token|token expired/i.test(msg)) {
1281
- // Distinguish a genuinely expired login from a command-specific endpoint/auth bug.
1282
- // A valid workspace read proves the CLI session and selected workspace are authenticated.
1283
- if (checkLoginState() === 'ok') {
1284
- return {
1285
- success: false,
1286
- output: [
1287
- 'The Ductape CLI session and active workspace are authenticated, but this command endpoint returned HTTP 401.',
1288
- 'Do not ask the user to log in again and do not retry through ductape_execute.',
1289
- 'This indicates a CLI command routing or endpoint authorization problem.',
1290
- '',
1291
- 'Update the Ductape CLI to the latest version and retry the same ductape_cli command.',
1292
- `Original command: ductape ${command}`,
1293
- ].join('\n'),
1294
- };
1295
- }
1296
- authState = 'none';
1297
- workspaceSynced = false;
1298
- return {
1299
- success: false,
1300
- output: [
1301
- 'Ductape CLI authentication expired or was revoked (HTTP 401).',
1302
- 'This administrative operation correctly used ductape_cli; do not retry it through ductape_execute.',
1303
- '',
1304
- 'Re-authenticate locally in a trusted terminal:',
1305
- ' ductape login',
1306
- 'or use an OAuth flow:',
1307
- ' ductape login --browser google',
1308
- ' ductape login --browser github',
1309
- '',
1310
- 'Do not paste a password, OAuth callback token, or stored CLI credential into an agent prompt.',
1311
- 'After login succeeds, retry the same ductape_cli command.',
1312
- ].join('\n'),
1313
- };
1314
- }
1315
- return { success: false, output: msg };
1316
- }
1317
- }
1318
-
1319
- /**
1320
- * MCP is publishable-key-only. The standalone CLI may manage its own access-key
1321
- * credentials, but an access key present in the MCP host environment is never
1322
- * forwarded into CLI subprocesses.
1323
- */
1324
- function cliEnvironment(): NodeJS.ProcessEnv {
1325
- const environment = { ...process.env };
1326
- delete environment.DUCTAPE_ACCESS_KEY;
1327
- return environment;
1328
- }
1329
-
1330
- const docsInputSchema = z.object({
1331
- topic: z.string().describe(
1332
- 'Feature topic to look up. Supported: ' +
1333
- 'transactions, presave, triggers, aggregations, migrations, indexes, performance, actions, ' +
1334
- 'graphs, storage, cloud, vector, warehouse, secrets, apps, products, sessions, caches, ' +
1335
- 'notifications, resilience, features, events, logs, frontend, frontend-analytics, client, react, vue',
1336
- ),
1337
- });
1338
-
1339
- const DOCS: Record<string, string> = {
1340
- frontend: `
1341
- DUCTAPE FRONTEND SDK GUIDE
1342
-
1343
- Choose one integration package:
1344
- React 17+ — npm install @ductape/react
1345
- Provider and hooks built on @ductape/client.
1346
- Continue with ductape_docs({ topic: "react" }).
1347
- Vue 3+ — npm install @ductape/vue
1348
- Plugin and composables built on @ductape/client.
1349
- Continue with ductape_docs({ topic: "vue" }).
1350
- Other UI — npm install @ductape/client
1351
- frameworks Use directly with Svelte, Angular, vanilla JavaScript, or a custom adapter.
1352
- Continue with ductape_docs({ topic: "client" }).
1353
-
1354
- Do not install @ductape/client separately when using @ductape/react or @ductape/vue unless the
1355
- application also needs direct access to a client API not exposed by the framework package. Both
1356
- framework packages wrap @ductape/client and expose the underlying client through useDuctape().
1357
-
1358
- SHARED APPLICATION LIFECYCLE
1359
- 1. Create one client at the application root using a browser-safe publishableKey, proxy baseUrl,
1360
- default product tag, and environment slug.
1361
- 2. Connect the real-time client only in the browser. In React use DuctapeProvider autoConnect or
1362
- connect() after mount; in Vue use createDuctape({ autoConnect: true }) or connect() in onMounted.
1363
- 3. Authenticate the player with the sessions API and retain the returned session token according
1364
- to the application's security policy. Refresh it before expiry (the React/Vue packages provide
1365
- useSessionAutoRefresh) and revoke it on logout.
1366
- 4. Pass the session value when connecting a broker that requires player-scoped authorization.
1367
- 5. Subscribe through the framework hook/composable or the client service. Always unsubscribe on
1368
- component teardown; the framework integrations do this automatically for declarative hooks.
1369
- 6. The underlying client reconnects its WebSocket and restores active subscriptions. Observe
1370
- connectionState to render disconnected/reconnecting UI; do not create duplicate subscriptions.
1371
- 7. Disconnect resource sessions and the root client when the owning application scope is torn down.
1372
-
1373
- PRODUCT ANALYTICS
1374
- Frontend product analytics complements backend session propagation; neither replaces the other.
1375
- Continue with ductape_docs({ topic: "frontend-analytics" }) for identity lifecycle, pageviews,
1376
- custom events, privacy masking, hidden-state safety, trace correlation, and package-version checks.
1377
-
1378
- AUTHENTICATION AND SECURITY
1379
- - Use publishableKey in browser applications. Never ship workspace private keys or privileged
1380
- access keys in frontend bundles.
1381
- - A Ductape player session and the real-time transport connection are separate: authenticate or
1382
- refresh the session, then use that session when opening player-scoped broker resources.
1383
- - For SSR, create/connect the real-time client only on the browser side.
1384
-
1385
- MIGRATING A CUSTOM WRAPPER
1386
- - Keep @ductape/client when the wrapper implements application-specific projection or state logic.
1387
- - For React, move root connection ownership to DuctapeProvider, replace imperative subscriptions
1388
- with useBroker/useBrokerSubscription, and keep projection reduction in an application hook.
1389
- - For Vue, move root ownership to createDuctape(), replace imperative subscriptions with
1390
- useBroker/useBrokerSubscription, and keep projection reduction in an application composable.
1391
- - Verify session handoff, initial loading state, error state, reconnect UI, subscription cleanup,
1392
- and duplicate-event behavior with a live end-to-end run before removing the old wrapper.
1393
- `.trim(),
1394
-
1395
- transactions: `
1396
- DUCTAPE DATABASE TRANSACTIONS
1397
-
1398
- Ask the user which database type and tier they are using before advising on transaction support.
1399
-
1400
- Support matrix:
1401
- PostgreSQL — full ACID; all isolation levels (READ_UNCOMMITTED, READ_COMMITTED,
1402
- REPEATABLE_READ, SERIALIZABLE). Always supported.
1403
- MySQL — full ACID; same isolation levels as PostgreSQL.
1404
- MongoDB — multi-document transactions require a replica set.
1405
- Atlas M10+ (dedicated): supported.
1406
- Atlas M0/M2/M5 (free/shared): NOT supported — all writes are
1407
- single-document atomic only. Confirm tier with user before proceeding.
1408
- Self-hosted: supported if replica set mode is enabled.
1409
- Cassandra — does NOT support ACID transactions. Lightweight transactions (LWT)
1410
- only for single-partition compare-and-set.
1411
- DynamoDB — transactional API supported (TransactWrite) but NOT via Ductape's
1412
- transaction abstraction; use single operations with condition expressions.
1413
-
1414
- Usage patterns:
1415
- // Managed (preferred) — auto-commit on success, auto-rollback on error:
1416
- await ductape.database.transaction(
1417
- { product, env, database, isolationLevel: 'READ_COMMITTED' },
1418
- async (tx) => {
1419
- await ductape.database.insert({ ..., transaction: tx });
1420
- await ductape.database.update({ ..., transaction: tx });
1421
- }
1422
- );
1423
-
1424
- // Manual — commit/rollback yourself:
1425
- const tx = await ductape.database.beginTransaction({ product, env, database });
1426
- try {
1427
- await ductape.database.insert({ ..., transaction: tx });
1428
- await tx.commit();
1429
- } catch (e) {
1430
- await tx.rollback();
1431
- throw e;
1432
- }
1433
-
1434
- Via ductape_execute:
1435
- ductape_execute("databases.beginTransaction", [{ product, env, database, isolationLevel? }])
1436
- → returns a transaction reference you can pass to subsequent insert/update/delete calls.
1437
- `.trim(),
1438
-
1439
- presave: `
1440
- DUCTAPE PRE-SAVE HOOKS
1441
-
1442
- Pre-save operations transform or validate data before it is written to the database.
1443
- Pass a preSave array to insert or update operations. Operations run in priority order.
1444
-
1445
- Available operations (PreSaveOperationType):
1446
- encrypt — AES-256 encrypt field (use for PII, secrets)
1447
- hash — one-way hash (bcrypt/argon2 for passwords)
1448
- mask — mask sensitive data (e.g. **** **** **** 1234)
1449
- trim — strip leading/trailing whitespace
1450
- lowercase — convert string to lowercase
1451
- uppercase — convert string to uppercase
1452
- sanitize — strip HTML/script tags
1453
- validate — throw if field value fails a rule
1454
- transform — apply a custom mapping function
1455
- uuid — generate a UUID for the field
1456
- slug — generate a URL slug from another field
1457
- timestamp — set field to current timestamp (ISO 8601)
1458
- round — round number to N decimal places
1459
- clamp — clamp number to a min/max range
1460
- truncate — truncate string to max length
1461
- default — set a default value if null/undefined
1462
- normalizePhone — normalise phone number format
1463
- normalizeEmail — lowercase + trim email
1464
- parseJson — parse JSON string to object
1465
- stringifyJson — serialise object to JSON string
1466
- compute — derive field value from other fields (runs before validate)
1467
-
1468
- Execution order is fixed regardless of declaration order:
1469
- default → compute → validate → trim → lowercase/uppercase/normalize* →
1470
- sanitize → truncate → slug → round/clamp → parseJson/stringifyJson → uuid/timestamp → transform
1471
-
1472
- Example:
1473
- await ductape.database.insert({
1474
- product, env, database: 'users', entity: 'users',
1475
- data: { email: ' User@Example.com ', password: 'secret', username: 'My App User' },
1476
- preSave: [
1477
- { field: 'email', operation: 'normalizeEmail' },
1478
- { field: 'password', operation: 'hash' },
1479
- { field: 'username', operation: 'slug', target: 'slug' },
1480
- ],
1481
- });
1482
- `.trim(),
1483
-
1484
- triggers: `
1485
- DUCTAPE DATABASE TRIGGERS
1486
-
1487
- Triggers fire automatically in response to database write events.
1488
- Defined via the Workbench UI or databases.trigger.create (admin SDK).
1489
-
1490
- Events (TriggerEvent):
1491
- beforeInsert / afterInsert
1492
- beforeUpdate / afterUpdate
1493
- beforeDelete / afterDelete
1494
- beforeWrite / afterWrite (any write)
1495
-
1496
- Timing (TriggerTiming):
1497
- sync — block the write until trigger completes (adds latency)
1498
- async — fire and forget (default for notifications, cache, broker)
1499
- queued — enqueue for background processing
1500
-
1501
- Action types (TriggerActionType):
1502
- database.* — insert/update/delete/query another collection
1503
- storage.* — upload/delete/copy a file
1504
- notification.* — email/SMS/push/callback
1505
- broker.publish — publish to a message broker topic
1506
- cache.* — set/invalidate/delete a cache key
1507
- feature.* — execute or dispatch a Ductape feature
1508
- action.execute — call an app action
1509
- agent.run — run an AI agent
1510
- quota.run / fallback.run / healthcheck.run
1511
- vector.upsert / vector.delete
1512
- session.revoke
1513
- log.create
1514
- custom.function / custom.http
1515
-
1516
- All triggers support an optional condition (field comparisons) to gate execution.
1517
- All triggers support retry config: { maxAttempts, delay, backoff: "fixed"|"exponential" }.
1518
-
1519
- Triggers are admin-only configuration — they cannot be set at runtime via ductape_execute.
1520
- Use ductape_cli with the Workbench or databases.trigger.create via the admin SDK.
1521
- `.trim(),
1522
-
1523
- aggregations: `
1524
- DUCTAPE DATABASE AGGREGATIONS
1525
-
1526
- databases.aggregate [{ product, env, database, entity, aggregations, where?, groupBy? }]
1527
-
1528
- aggregations array — each entry:
1529
- { type: "count"|"sum"|"avg"|"min"|"max", field?: string, alias: string }
1530
-
1531
- Example — total revenue grouped by category:
1532
- ductape_execute("databases.aggregate", [{
1533
- product: "my-product",
1534
- env: "prd",
1535
- database: "sales-db",
1536
- entity: "orders",
1537
- aggregations: [
1538
- { type: "sum", field: "amount", alias: "totalRevenue" },
1539
- { type: "count", alias: "orderCount" },
1540
- { type: "avg", field: "amount", alias: "avgOrderValue" },
1541
- ],
1542
- groupBy: ["category"],
1543
- where: { status: { $eq: "completed" } },
1544
- }])
1545
-
1546
- Supported where operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $like, $ilike
1547
- groupBy: array of field names to group by (omit for global aggregation)
1548
- `.trim(),
1549
-
1550
- migrations: `
1551
- DUCTAPE DATABASE MIGRATIONS
1552
-
1553
- Migrations are versioned SQL/NoSQL schema change scripts managed per database component.
1554
-
1555
- Create migrations through the project migration files and ductape_cli, never ductape_execute.
1556
-
1557
- Use the access-key administrative CLI for running, rolling back, and inspecting migrations:
1558
- ductape_cli("db migrate") // run pending
1559
- ductape_cli("db migrate rollback") // roll back last
1560
- ductape_cli("db migrate rollback -n 3")
1561
-
1562
- MongoDB: migrations run as raw Mongo shell commands in the up/down arrays.
1563
- `.trim(),
1564
-
1565
- indexes: `
1566
- DUCTAPE DATABASE INDEXES
1567
-
1568
- Create or update indexes through migration files and ductape_cli("db migrate"), not the
1569
- publishable-key runtime proxy. Example definitions may use ["field1", "field2"] with
1570
- { unique: true, name: "idx_email_unique" }, or ordered fields such as
1571
- [{ field: "createdAt", order: "DESC" }, { field: "userId", order: "ASC" }].
1572
-
1573
- Drop and inspect indexes through migration/CLI tooling or the database administration surface,
1574
- not ductape_execute.
1575
-
1576
- Performance guidance:
1577
- - Index fields used in WHERE clauses, JOIN conditions, and ORDER BY.
1578
- - Compound indexes: put high-cardinality fields first.
1579
- - Unique indexes enforce uniqueness at the database level.
1580
- - MongoDB: compound indexes support prefix queries; order matters.
1581
- - PostgreSQL/MySQL: B-tree indexes by default; use partial indexes for sparse columns.
1582
- - Avoid over-indexing writes-heavy collections — each index slows inserts/updates.
1583
-
1584
- Before adding an index ask the user which fields they query/filter on and expected data volume.
1585
- `.trim(),
1586
-
1587
- performance: `
1588
- DUCTAPE DATABASE PERFORMANCE GUIDANCE
1589
-
1590
- 1. Queries
1591
- - Always pass select: ["field1", "field2"] to avoid fetching unused columns.
1592
- - Use limit + offset for pagination; add an index on the ORDER BY field.
1593
- - Use count/sum/avg/aggregate instead of fetching all rows and computing in code.
1594
-
1595
- 2. Indexes (see ductape_docs({ topic: "indexes" }) for syntax)
1596
- - Index every WHERE / ORDER BY field.
1597
- - Compound index order: equality fields first, range fields last.
1598
- - For MongoDB: covered queries (index holds all projected fields) skip the document read.
1599
-
1600
- 3. Connections
1601
- - The SDK pools connections per product+env+database triple.
1602
- - Call databases.connect once per process; the pool reuses the connection.
1603
- - databases.disconnect releases the pool — call only on shutdown.
1604
-
1605
- 4. Transactions (see ductape_docs({ topic: "transactions" }))
1606
- - Keep transactions short; long-lived transactions block row locks (PostgreSQL/MySQL).
1607
- - For MongoDB on M10+, use sessions for multi-document writes — not for reads.
1608
-
1609
- 5. Caching
1610
- - For read-heavy, rarely-changing data use caches.get before databases.query.
1611
- - Invalidate cache keys in an afterWrite trigger (see ductape_docs({ topic: "triggers" })).
1612
- `.trim(),
1613
-
1614
- actions: `
1615
- DUCTAPE DATABASE ACTIONS
1616
-
1617
- A database action is a saved query or mutation (SQL string or NoSQL command) stored
1618
- on the Ductape product and executed by tag at runtime.
1619
-
1620
- Create/update actions in Workbench (administrative access), never through ductape_execute.
1621
-
1622
- Dispatch an action at runtime:
1623
- → CALL ductape_generate_payload FIRST to get the canonical input shape.
1624
- ductape_execute("databases.action.dispatch", [{
1625
- product: "my-product",
1626
- env: "prd",
1627
- database: "core-db",
1628
- action: "get-active-users",
1629
- input: { status: "active" },
1630
- }])
1631
-
1632
- List actions for a database:
1633
- ductape_execute("databases.action.list", ["database_tag"])
1634
-
1635
- Fetch:
1636
- ductape_execute("databases.action.fetch", ["action_tag"])
1637
- Update/delete are administrative and must be performed in Workbench.
1638
-
1639
- Actions are the preferred way to encapsulate complex or reused queries — they can be
1640
- scheduled, dispatched with retries, and audited via logs.
1641
- `.trim(),
1642
-
1643
- graphs: `
1644
- DUCTAPE GRAPH DATABASES
1645
-
1646
- Supported engines: neo4j | neptune | cosmos-gremlin | spanner-graph | arangodb | memgraph
1647
-
1648
- Registration (admin — ductape_cli):
1649
- ductape_cli("resources graphs create -f graph.json")
1650
- File: { name, tag, type, envs: [{ slug, connection_url, username?, password?, graphName?, region? }] }
1651
- Sensitive fields (connection_url, username, password) are auto-wrapped as $Secret{...} when
1652
- a productTag is supplied — do NOT pre-wrap them yourself.
1653
-
1654
- Node operations:
1655
- graph.createNode [{ labels: string[], properties: { key: value } }, transaction?]
1656
- graph.findNodes [{ labels?, where?, limit?, skip? }, transaction?]
1657
- graph.findNodeById [id, transaction?]
1658
- graph.updateNode [{ id, properties }, transaction?]
1659
- graph.deleteNode [{ id, detach?: boolean }, transaction?]
1660
- graph.mergeNode [{ labels, matchProps, setProps? }, transaction?]
1661
- graph.addLabels / removeLabels / setLabels [{ id, labels }, transaction?]
1662
-
1663
- Relationship operations:
1664
- graph.createRelationship [{ fromId, toId, type, properties? }, transaction?]
1665
- graph.findRelationships [{ type?, where?, limit? }, transaction?]
1666
- graph.updateRelationship [{ id, properties }, transaction?]
1667
- graph.deleteRelationship [{ id }, transaction?]
1668
- graph.mergeRelationship [{ fromId, toId, type, matchProps?, setProps? }, transaction?]
1669
-
1670
- Traversal:
1671
- graph.traverse [{ startId, direction: "in"|"out"|"both", relationshipTypes?, maxDepth?, where? }]
1672
- graph.shortestPath [{ fromId, toId, relationshipType?, maxDepth? }]
1673
- graph.allPaths [{ fromId, toId, relationshipType?, maxDepth? }]
1674
- graph.getNeighborhood [{ id, depth?, relationshipTypes? }]
1675
- graph.findConnectedComponents [{ labels? }]
1676
-
1677
- Raw query (provider-native Cypher/Gremlin):
1678
- graph.query [cypher_query: string, params?: { key: value }, transaction?]
1679
-
1680
- Full-text and vector search (where supported):
1681
- graph.fullTextSearch [{ index, query, limit? }]
1682
- graph.vectorSearch [{ index, vector: number[], topK? }]
1683
-
1684
- Transactions:
1685
- graph.beginTransaction [options?: { isolationLevel?, timeout? }] → transaction object
1686
- graph.commitTransaction [transaction]
1687
- graph.rollbackTransaction [transaction]
1688
- Or use withTransaction (managed — auto-commit/rollback):
1689
- graph.withTransaction [graphTag, env, callback, options?]
1690
- Transaction support varies by engine — ask the user which engine they use before enabling.
1691
- neo4j: full ACID; neptune: limited; arangodb: multi-document; memgraph: full ACID.
1692
-
1693
- Schema management:
1694
- graph.createNodeIndex [{ label, field, type: "btree"|"fulltext"|"vector" }]
1695
- graph.createNodeConstraint [{ label, field, type: "unique"|"exists" }]
1696
- graph.dropIndex / dropConstraint / listIndexes / listConstraints
1697
-
1698
- Saved actions (parameterized queries stored on the product):
1699
- graph.createAction [{ graphTag, tag, name, query, params? }, productTag?]
1700
- graph.listActions [graphTag?, productTag?]
1701
- graph.dispatch [data] ← call ductape_generate_payload FIRST
1702
-
1703
- Supported index types: btree | fulltext | vector | range | point | text
1704
- Supported constraint types: UNIQUE | EXISTS | NODE_KEY
1705
- `.trim(),
1706
-
1707
- storage: `
1708
- DUCTAPE FILE STORAGE
1709
-
1710
- Supported providers: aws (S3) | gcp (GCS) | azure (Blob Storage)
1711
-
1712
- Registration (admin — ductape_cli):
1713
- ductape_cli("resources storage create -f storage.json")
1714
- File: { name, tag, envs: [{ slug, type: "aws"|"gcp"|"azure", config: { ... } }] }
1715
- For cloud-linked envs: set config.cloud to the connection tag; omit raw credentials.
1716
- For all envs in the product must be covered — see ductape_docs({ topic: "cloud" }) for
1717
- import-persist-all workflow when connecting existing buckets.
1718
-
1719
- Operations (runtime):
1720
- storage.upload [{ product, env, storage, fileName, buffer: string|Buffer, mimeType? }]
1721
- storage.download [{ product, env, storage, fileName }] → { content, size, mimeType }
1722
- storage.remove [{ product, env, storage, fileName }]
1723
- storage.listFiles [{ product, env, storage, prefix?, limit?, continuationToken? }]
1724
- storage.getSignedUrl [{ product, env, storage, fileName, expiresIn?: number, action?: "read"|"write" }]
1725
- storage.stats [{ product, env, storage, prefix?, cache? }] → file counts by type
1726
- storage.testConnection [{ product, env, storage }]
1727
-
1728
- Background dispatch (fire-and-forget):
1729
- storage.dispatch [{ product, env, storage, operation, input, schedule? }]
1730
- → CALL ductape_generate_payload FIRST (operation_family="storage", method="dispatch")
1731
- → Returns { jobId, status: "queued" } immediately; actual operation runs in background.
1732
- → schedule: { start_at?, cron?, every?, limit?, endDate?, tz? }
1733
-
1734
- Caching:
1735
- download, listFiles, getSignedUrl, and stats all support a cache option.
1736
- Pass cache: "cache_tag" to check the product cache before hitting the provider.
1737
- Cached results are stored fire-and-forget on miss.
1738
-
1739
- Notes:
1740
- - buffer can be a Node.js Buffer (server) or a base64 string (JSON proxy clients).
1741
- - Cloud-linked configs inject runtime credentials via the cloud connection; no plaintext keys stored.
1742
- - $Secret{} references in config are resolved at runtime if cloud-link resolution fails.
1743
- `.trim(),
1744
-
1745
- cloud: `
1746
- DUCTAPE CLOUD CONNECTIONS
1747
-
1748
- Cloud connections link your workspace to an external cloud provider so Ductape can manage
1749
- resources (databases, storage, brokers, graphs, vectors) on your behalf.
1750
-
1751
- Supported providers: aws | gcp | azure | mongodb_atlas | neo4j_aura
1752
- Auth modes: iam_role | oauth | service_principal | workload_identity | api_key
1753
-
1754
- Create a connection (admin — ductape_cli):
1755
- ductape_cli("cloud connections create --provider aws --name my-aws-conn")
1756
- Returns: external_id, trust_policy, setup_instructions, setup_url
1757
- AWS: the caller must create an IAM role using the returned trust policy, then complete:
1758
- ductape_cli("cloud connections complete <tag> --role-arn arn:aws:iam::...")
1759
- GCP: complete with project_id, service_account_email, service_account_json
1760
- Azure: complete with tenant_id, subscription_id, client_id, client_secret, default_location
1761
- Atlas: complete with atlas_public_key, atlas_private_key
1762
- Aura (Neo4j): complete with aura_client_id, aura_client_secret, aura_instance_id
1763
-
1764
- Validate a connection:
1765
- ductape_cli("cloud connections validate <tag>")
1766
- → { valid, status, message?, tested_at? }
1767
-
1768
- List / fetch / delete:
1769
- ductape_cli("cloud connections list")
1770
- ductape_cli("cloud connections fetch <tag>")
1771
-
1772
- Discover resources on a connection:
1773
- ductape_cli("cloud resources list -f query.json --json")
1774
- File: { cloud: "<tag>", service: "gcs"|"s3"|"blob"|"rds"|"atlas-cluster"|..., region? }
1775
- → returns list of available resources (buckets, clusters, instances, etc.)
1776
-
1777
- Import an existing resource and register it on the product:
1778
- Use import-persist-all for multi-env products (required):
1779
- ductape_cli("cloud resources import-persist-all -f all-envs.json --json")
1780
- File is a JSON ARRAY — one entry per env, same product + component tag across all entries.
1781
- Each entry: { cloud, service, type, product, component, env, resource, region?, dbName? }
1782
- Supported service identifiers: s3, gcs, blob, rds, postgresql, cloudsql, sqs, pubsub,
1783
- servicebus, neptune, cosmos-gremlin, opensearch, azure-search, atlas-cluster, aura-instance
1784
-
1785
- Provision a brand-new resource and register it:
1786
- ductape_cli("cloud resources provision-persist-all -f all-envs.json --json")
1787
- Additional per-entry fields: tier, region/location, waitForReady.
1788
- List available tiers first: ductape_cli("cloud tiers --provider aws --type database --db-type postgresql --json")
1789
- NEVER infer region, tier, or cost — always list and confirm with the user first.
1790
- Atlas and Neo4j Aura are IMPORT-ONLY — provision is not supported for these providers.
1791
-
1792
- VPC connector (private networking):
1793
- ductape_cli("cloud connections vpc update <tag> --vpc-id vpc-xxx --subnet-ids subnet-1,subnet-2")
1794
- ductape_cli("cloud connections vpc status <tag>")
1795
- VPC connector enables Ductape to reach resources in a private VPC; agent_status:
1796
- pending → connected → disconnected
1797
-
1798
- Scopes a connection can cover: storage | broker | database | graph | vector | cache
1799
-
1800
- Component types accepted by import/provision:
1801
- storage | messageBrokers | databases | graphs | vectors | caches
1802
- `.trim(),
1803
-
1804
- vector: `
1805
- DUCTAPE VECTOR DATABASES
1806
-
1807
- Supported adapters: pinecone | qdrant | weaviate | opensearch | azure-search | vertex-vector-search
1808
- (chroma, milvus, pgvector are declared but not yet implemented — do not use them)
1809
-
1810
- Registration (admin — ductape_cli):
1811
- ductape_cli("resources vectors create -f vector.json")
1812
- File: { name, tag, type, dimensions: number, metric?: "cosine"|"euclidean"|"dotproduct",
1813
- envs: [{ slug, endpoint?, apiKey?, region?, index?, namespace? }] }
1814
-
1815
- Runtime operations:
1816
- vector.upsert [{ product, env, tag, vectors: [{id, values: number[], metadata?}], namespace? }]
1817
- vector.upsertOne [{ product, env, tag, id, values: number[], metadata?, namespace? }]
1818
- vector.query [{ product, env, tag, vector: number[], topK?, filter?, namespace?,
1819
- includeValues?, includeMetadata? }]
1820
- vector.findSimilar [{ product, env, vector, values: number[], topK?, filter?, namespace? }]
1821
- vector.fetchOne [{ product, env, vector, id, namespace? }]
1822
- vector.fetchVectors [{ product, env, vector, ids: string[], namespace? }]
1823
- vector.updateVector [{ product, env, vector, id, values?, setMetadata?, mergeMetadata?, namespace? }]
1824
- vector.updateMetadata [{ product, env, vector, id, metadata: { key: value }, merge?, namespace? }]
1825
- merge: true → deep-merges metadata rather than replacing it
1826
- vector.deleteByIds [{ product, env, vector, ids: string[], namespace? }]
1827
- vector.deleteAll [{ product, env, vector, namespace? }]
1828
- vector.count [{ product, env, vector, namespace? }]
1829
-
1830
- Namespace management:
1831
- vector.listNamespaces [{ product, env, vector }]
1832
- vector.deleteNamespace [{ product, env, vector, namespace }]
1833
-
1834
- Listing (paginated):
1835
- vector.listVectors [{ product, env, vector, namespace?, prefix?, limit?, cursor? }]
1836
- vector.listAllVectors [{ product, env, vector, namespace?, prefix? }]
1837
- listAllVectors auto-paginates until cursor exhausted — avoid on large indexes.
1838
-
1839
- Index management:
1840
- vector.describeIndex [{ product, env, vector }]
1841
- vector.getStats [{ product, env, vector }] → totalVectorCount per namespace
1842
- vector.createIndex [{ product, env, vector, name, dimensions, metric?, replicas?, shards? }]
1843
- vector.deleteIndex [{ product, env, vector, name }]
1844
- vector.listIndexes [{ product, env, vector }]
1845
-
1846
- Distance metrics: cosine | euclidean | dotproduct | manhattan | hamming
1847
- Index types: flat | ivf | hnsw | pq | ivf_pq | annoy
1848
- Feature flags per adapter: metadata_filtering | namespaces | hybrid_search | batch_operations |
1849
- index_management | vector_updates | sparse_vectors | multi_vector | aggregations
1850
-
1851
- Call vector.supportsFeature(feature) or vector.getSupportedFeatures() to check what the
1852
- chosen adapter supports before using a feature.
1853
- `.trim(),
1854
-
1855
- warehouse: `
1856
- DUCTAPE WAREHOUSE
1857
-
1858
- The Warehouse is a unified query layer over the three structured data stores: Database, Graph, and Vector.
1859
- It provides a single API for cross-store operations without having to address each service separately.
1860
-
1861
- Warehouse is available on the SDK instance as ductape.warehouse (TypeScript) or Warehouse (C#/Go/Java).
1862
- It is NOT a separate Ductape resource — it wraps the existing database, graph, and vector components
1863
- that are already registered on the product.
1864
-
1865
- Key use cases:
1866
- - Run a relational query, a graph traversal, and a vector similarity search in a single call.
1867
- - Build recommendation pipelines: graph neighbors → vector re-rank → database hydration.
1868
- - Federated search across multiple store types with a single await.
1869
-
1870
- Usage (TypeScript SDK):
1871
- const result = await ductape.warehouse.query({
1872
- product: "my-product",
1873
- env: "prd",
1874
- database: { tag: "core-db", entity: "products", where: { active: { $eq: true } }, select: ["id", "name"] },
1875
- graph: { tag: "reco-graph", startId: userId, direction: "out", maxDepth: 2 },
1876
- vector: { tag: "embedding-store", vector: queryEmbedding, topK: 10 },
1877
- });
1878
-
1879
- result.database → relational rows
1880
- result.graph → traversal nodes/relationships
1881
- result.vector → similarity hits with scores
1882
-
1883
- Warehouse context is set at SDK init time (env + workspaceId) and shared across all three stores.
1884
- Underlying store calls use the same auth and product context; individual store errors are surfaced per store.
1885
-
1886
- Before using Warehouse:
1887
- 1. Ensure the database, graph, and vector components are registered on the product.
1888
- 2. Confirm the env slug exists on all three stores.
1889
- 3. Each store can be queried independently — pass only the fields for the stores you need.
1890
-
1891
- Warehouse does not support writes — use the individual service APIs (database.insert, graph.createNode,
1892
- vector.upsert) for mutations.
1893
- `.trim(),
1894
-
1895
- secrets: `
1896
- DUCTAPE SECRETS
1897
-
1898
- Secrets are workspace-level encrypted key-value pairs. They are referenced in resource configs,
1899
- connection URLs, and any string field using the $Secret{KEY_NAME} syntax.
1900
-
1901
- Create/update secrets with ductape_cli or Workbench (administrative access), never ductape_execute.
1902
- Definition fields include key, value, description, token_type, scope, envs, and expires_at.
1903
- The server never receives the plaintext value. Encryption uses the workspace private key.
1904
-
1905
- Fetch / resolve:
1906
- ductape_execute("secrets.fetch", ["STRIPE_API_KEY"]) → decrypted string value
1907
- ductape_execute("secrets.exists", ["STRIPE_API_KEY"]) → boolean
1908
- ductape_execute("secrets.validate", ["$Secret{STRIPE_API_KEY}"]) → { valid, missingKeys, existingKeys }
1909
- ductape_execute("secrets.resolve", ["$Secret{STRIPE_API_KEY}", { env: "prd" }]) → resolved string
1910
-
1911
- $Secret{} reference syntax:
1912
- - Embed anywhere a string is accepted: "postgresql://$Secret{DB_USER}:$Secret{DB_PASS}@host/db"
1913
- - Resolved at runtime before the value is used by the consuming service (storage, broker, graph, etc.)
1914
- - The in-memory cache stores the encrypted form only; decryption happens on each cache hit.
1915
- - Cache TTL: 5 minutes. Clear with: secrets.clearCache()
1916
-
1917
- Lifecycle mutations (revoke/delete/update) are administrative: use ductape_cli or Workbench.
1918
-
1919
- List all secrets (keys only — values are not returned in list):
1920
- ductape_execute("secrets.list", [])
1921
-
1922
- Important:
1923
- - Secrets are workspace-scoped, not product-scoped. scope[] and envs[] control access.
1924
- - Other services (storage, broker, graph, etc.) resolve $Secret{} references automatically
1925
- using the singleton secrets service — no manual resolution needed in most cases.
1926
- - Never log or return resolved secret values to end users.
1927
- `.trim(),
1928
-
1929
- apps: `
1930
- DUCTAPE APPS
1931
-
1932
- WHAT AN APP IS:
1933
- A Ductape App is a pre-configured, versioned API integration definition. It is NOT a generic HTTP
1934
- client, NOT a job scheduler, and NOT anything you can call without registering first.
1935
-
1936
- An App must be fully set up in Ductape before any code can use it:
1937
- 1. The App record must be created (name, tag, description)
1938
- 2. Environments must be added (each environment slug → base URL for that stage)
1939
- 3. Auth scheme must be configured (how outbound requests authenticate: apikey, bearer, OAuth2, etc.)
1940
- 4. Action endpoints must be defined (each action = one HTTP endpoint spec: method, path, body/query/header shape, response shape)
1941
- 5. The App must be connected to the product (product.apps.add) and its envs mapped
1942
-
1943
- ONLY after all five steps can any code call:
1944
- ctx.api.run({ app: '<app_tag>', event: '<action_tag>', input: { ... } }) ← in a feature handler
1945
- actions.run([{ product, env, app: '<app_tag>', action: '<action_tag>', input }]) ← at runtime
1946
-
1947
- ctx.api (alias for ctx.action) is NOT a generic HTTP call. It ONLY invokes a pre-registered
1948
- Ductape App Action. If the App or action tag does not exist in the product, the call will fail.
1949
- actions.dispatch is the same as actions.run but scheduled as a background job — it still requires
1950
- a registered App. There is no way to dispatch a job to an arbitrary URL via ctx.api or actions.dispatch.
1951
-
1952
- If a feature step needs to call an external service and no App is registered for it yet:
1953
- → Flag it as "App to create" in your plan (STEP 4 of the feature design workflow)
1954
- → Create the App and all its actions first (see below)
1955
- → Only then write the ctx.api.run call
1956
-
1957
- An app is a versioned API integration definition. It contains environments (base URLs), actions
1958
- (individual endpoint specs), auth schemes, webhooks, variables, and constants.
1959
-
1960
- Create an app (admin — ductape_cli):
1961
- ductape_cli("apps create --name \\"Email Service\\" --description \\"Transactional email\\"")
1962
- ductape_cli("app.init", ["app_tag"]) → loads app into builder state
1963
-
1964
- Import from a file:
1965
- ductape_cli("apps import <file.json> -t postman|openapi")
1966
- Supports Postman v2.1 collection and OpenAPI 3.0 spec.
1967
-
1968
- Manage environments (base URLs per stage) in Workbench; this currently has no CLI command.
1969
-
1970
- Discover apps in a product and their actions (ALWAYS do this before writing any ctx.api.run call):
1971
- Step 1 — list apps connected to the product:
1972
- ductape_cli("products get --tag <product_tag> --json") → full product document; check apps[]
1973
- ductape_cli("products apps list --product <product_id> --json") → apps[] with access_tag, envs
1974
- Step 2 — list actions in an app:
1975
- ductape_execute("actions.list", [app_tag]) → returns all action tags + names
1976
- Step 3 — fetch the input schema for an action:
1977
- ductape_execute("actions.fetch", [app_tag, action_tag])
1978
- → returns { body: {fieldName: {type, required}}, params: {}, query: {}, headers: {} }
1979
- OR: call ductape_generate_payload (operation_family="action", method="run",
1980
- targets={app: "app_tag", action: "action_tag"}) to get the exact resolved payload shape
1981
- Step 4 — call ductape_schema({ module: "app" }) if you need the JSON schema for creating/updating
1982
- app resources (not for runtime input — use actions.fetch or ductape_generate_payload for that)
1983
- NEVER assume action input field names. Always fetch the action definition first.
1984
-
1985
- Manage actions (individual API endpoints) in Workbench or import an OpenAPI/Postman file.
1986
- Creation/update are administrative and must never use ductape_execute.
1987
- ductape_execute("actions.list", [app_tag])
1988
- ductape_execute("actions.fetch", [app_tag, action_tag])
1989
-
1990
- Action input — flat input format:
1991
- Fields are resolved to the correct location (body/params/query/headers) by matching the action schema.
1992
- For ambiguous keys, use explicit prefixes:
1993
- input: { amount: 1000 } → auto-resolved (body.amount if body field exists)
1994
- input: { "body:amount": 1000 } → explicit body
1995
- input: { "params:id": "user_123" } → route parameter
1996
- input: { "query:limit": 10 } → query string
1997
- input: { "headers:X-Idempotency-Key": "..." } → request header
1998
- Always use ductape_generate_payload or actions.fetch to know the exact field names — never guess.
1999
-
2000
- Run an action at runtime:
2001
- → CALL ductape_generate_payload FIRST (operation_family="action", method="run", targets={app, action})
2002
- ductape_execute("actions.run", [{ product, env, app, action, input: { "body:field": value } }])
2003
- ductape_execute("actions.dispatch", [{ product, env, app, action, input, schedule? }])
2004
-
2005
- Auth schemes (how the app authenticates outbound requests):
2006
- Setup types: header | bearer | basic | oauth2 | apikey
2007
- Configure auth in Workbench (administrative).
2008
- ductape_execute("auths.list", [app_tag])
2009
-
2010
- Webhooks (inbound events from the external service):
2011
- Configure webhooks and webhook events in Workbench (administrative).
2012
-
2013
- Variables (per-env mutable values) and Constants (fixed values):
2014
- Configure variables and constants in Workbench (administrative).
2015
-
2016
- Connecting an app to a product (after creation):
2017
- NOTE: All product.* module methods require the access key and CANNOT use ductape_execute.
2018
- Use ductape_cli for all product-level operations.
2019
-
2020
- FULL FLOW to make an app callable from a product:
2021
- 1. Create the app: ductape_cli("apps create --name \\"Service Name\\" --description \\"...\\"")
2022
- 2. Add environments: Workbench
2023
- 3. Configure auth: Workbench
2024
- 4. Define actions: Workbench
2025
- OR import: ductape_cli("apps import <file.json> -t postman|openapi")
2026
- 5. Connect to product: Requires the product_id (from ductape_cli("products get --tag <tag> --json")).
2027
- There is no CLI command for this step — the SDK product.apps.add method requires
2028
- an access key which only the backend can provide. Connect via Workbench.
2029
- 6. Verify: ductape_cli("products get --tag <product_tag> --json") → check apps[] contains the app
2030
- ductape_execute("actions.list", [app_tag]) → verify actions are registered
2031
- `.trim(),
2032
-
2033
- products: `
2034
- DUCTAPE PRODUCTS
2035
-
2036
- A product is the top-level namespace for all Ductape infrastructure: apps, databases, graphs,
2037
- vectors, storage, brokers, sessions, caches, notifications, resilience, features, jobs, and envs.
2038
- Every SDK service call resolves within a product context.
2039
-
2040
- IMPORTANT: The product module requires the access key. ALL product operations must use ductape_cli,
2041
- not ductape_execute (which only accepts the publishable key and will return 403 for product.*).
2042
-
2043
- Create a product:
2044
- ductape_cli("products create --name \\"My App\\" --tag my-app")
2045
-
2046
- Environments — every resource's envs array MUST cover all product env slugs:
2047
- ductape_cli("products environments list <product_tag> --json")
2048
- ductape_cli("products environments get <product_tag> <slug> --json")
2049
- BEFORE registering any resource, always run environments list and collect all slugs.
2050
-
2051
- Fetch / update:
2052
- ductape_cli("products get --tag <product_tag> --json")
2053
- ductape_cli("products get --id <product_id> --json")
2054
-
2055
- Connect apps to a product:
2056
- See ductape_docs({ topic: "apps" }) for product.apps.add / product.apps.list.
2057
-
2058
- Resource registration (all via ductape_cli or ductape_execute — see per-topic docs):
2059
- databases → ductape_docs({ topic: "transactions" })
2060
- storage → ductape_docs({ topic: "storage" })
2061
- graphs → ductape_docs({ topic: "graphs" })
2062
- vectors → ductape_docs({ topic: "vector" })
2063
- events → ductape_docs({ topic: "events" })
2064
- caches → ductape_docs({ topic: "caches" })
2065
- notifications → ductape_docs({ topic: "notifications" })
2066
- sessions → ductape_docs({ topic: "sessions" })
2067
- frontend analytics → ductape_docs({ topic: "frontend-analytics" })
2068
- resilience → ductape_docs({ topic: "resilience" })
2069
- features → ductape_docs({ topic: "features" })
2070
-
2071
- Product structure (IProduct fields):
2072
- _id, workspace_id, name, tag, description, private_key,
2073
- apps[], envs[], databases[], graphs[], vectors[], storage[], messageBrokers[],
2074
- caches[], sessions[], notifications[], quota[], fallback[], healthchecks[],
2075
- workflows[] (features), models[], agents[], jobs[]
2076
-
2077
- Bootstrap (single API call returning product context + component config + private key):
2078
- Each service makes a single bootstrap call at first use; results are cached in BootstrapCache
2079
- (Redis when available). This avoids repeated round-trips in high-frequency paths.
2080
- `.trim(),
2081
-
2082
- sessions: `
2083
- DUCTAPE SESSIONS
2084
-
2085
- A session is a named JWT schema on a product. It defines:
2086
- - tag / name — unique identifier and display name
2087
- - expiry + period — how long each issued JWT is valid (duration, not an absolute date)
2088
- - selector — MUST be in the format "$Session{fieldName}" where fieldName is the key
2089
- in the schema that is the PRIMARY user identifier (e.g. "$Session{playerId}").
2090
- Plain dot-paths like "playerId" are REJECTED by the validator.
2091
- This field becomes the lookup key for revoke, list, and analytics.
2092
- - schema — SAMPLE DATA showing example values for each field embedded in the JWT.
2093
- This is NOT a type declaration. Use actual example values.
2094
- The value at the selector path must be a primitive (string/number/boolean),
2095
- not an object or array.
2096
-
2097
- IMPORTANT — schema is sample data, not type declarations:
2098
- CORRECT: schema: { playerId: "player_abc123", username: "Alice", role: "player" }
2099
- INCORRECT: schema: { playerId: { type: "string", required: true } } ← WILL FAIL
2100
-
2101
- IMPORTANT — selector must be "$Session{fieldName}" format:
2102
- CORRECT: selector: "$Session{playerId}"
2103
- INCORRECT: selector: "playerId" ← WILL FAIL with "Selector should be in the format $Session{...}{key}"
2104
-
2105
- Example definition (configure via declarative apply or Workbench, not ductape_execute):
2106
- {
2107
- tag: "player-session",
2108
- name: "Player Session",
2109
- expiry: 24,
2110
- period: "hours",
2111
- selector: "$Session{playerId}", // $Session{} wrapper required
2112
- schema: {
2113
- playerId: "player_abc123", // sample value — primitive required at selector path
2114
- username: "ShadowBlade",
2115
- role: "player",
2116
- accountId: "acct_xyz",
2117
- },
2118
- }
2119
-
2120
- Runtime — create a session (sign a JWT):
2121
- → CALL ductape_generate_payload FIRST (operation_family="session", method="start", targets={tag})
2122
- to discover the exact data field names accepted for this session tag.
2123
- ductape_execute("sessions.start", [{ product, env, tag: "player-session",
2124
- data: {
2125
- playerId: "player_abc123", // must match selector path — used as the revocation/lookup key
2126
- username: "ShadowBlade",
2127
- role: "player",
2128
- accountId: "acct_xyz",
2129
- } }])
2130
- → returns token: "player-session:eyJ..." ← format is always "session_tag:jwt"
2131
- The token embeds all schema fields in the JWT payload, signed with the product private key.
2132
-
2133
- Verify a token:
2134
- ductape_execute("sessions.verify", [{ product, env, tag: "user-session", token: "user-session:eyJ..." }])
2135
- → decodes JWT + checks revocation blacklist; throws if revoked or expired
2136
-
2137
- Refresh (atomic token rotation):
2138
- ductape_execute("sessions.refresh", [{ product, env, tag: "user-session", refreshToken: "..." }])
2139
- Old refresh token is invalidated server-side atomically — no partial state possible.
2140
- The refresh token is AES-encrypted JSON (NOT a JWT).
2141
-
2142
- Revoke:
2143
- ductape_execute("sessions.revoke", [{ product, env, tag, sessionId?, identifier? }])
2144
- Requires at least one of sessionId or identifier.
2145
- Revocation is enforced via a ProcessorAPI blacklist check on every verify() call.
2146
-
2147
- Analytics:
2148
- sessions.listActive [{ product, env, tag, identifier?, page?, limit? }]
2149
- sessions.fetchUsers [{ product, session, env?, page?, limit? }]
2150
- sessions.fetchUserDetails [{ product, session, identifier, env? }]
2151
- sessions.fetchDashboard [{ product, session, env? }]
2152
- → { DAU, WAU, MAU, activityTimeline, peakHours, environmentBreakdown, avgSessionDuration }
2153
-
2154
- Token format: "session_tag:jwt_token" — pass this format verbatim wherever session is expected.
2155
- JWT is signed with the product private key (not a symmetric shared secret).
2156
-
2157
- SESSION PROPAGATION
2158
- Extract the original full "session_tag:jwt" token once at the HTTP/transport boundary and retain
2159
- it separately from verified/decoded claims. Claims are authorization data; they are not a token.
2160
- Store a request ActorContext in a NestJS request-scoped provider or AsyncLocalStorage:
2161
- type ActorContext =
2162
- | { kind: "user"; session: string; actorId: string }
2163
- | { kind: "delegated"; delegatedIdentity: string; actorId: string; initiatedAt: string }
2164
- | { kind: "system"; reason: string };
2165
- Pass ActorContext explicitly through service boundaries. At each immediate Ductape runtime call,
2166
- pass actor.kind === "user" ? actor.session : the SDK-approved delegated identity. Put only
2167
- non-secret actor metadata (actorId, kind, initiatedAt, correlationId) in Event envelopes.
2168
- Feature execution should receive the same explicit actor classification. Never log, serialize
2169
- into business payloads, or attach the raw session/refresh token to traces or error messages.
2170
-
2171
- SECURITY BOUNDARY FOR DURABLE WORK
2172
- The current SDK accepts a session string on dispatch, but it does not expose an immutable
2173
- attribution snapshot API, an expired-token attribution contract, or a general delegated-session
2174
- issuer. Therefore do NOT persist reusable JWTs indefinitely or assume an expired initiating token
2175
- will remain valid when delayed work executes. Pass the full token only for immediate work within
2176
- its validity window. For delayed/recurring work, classify execution as system-context, or use an
2177
- application-owned immutable actor-context envelope plus a short-lived delegated identity issued
2178
- by an explicitly approved auth design. Until the SDK provides that design, actor metadata is for
2179
- audit/correlation and must not be treated as authorization.
2180
-
2181
- Distinguish contexts deliberately:
2182
- user — active request; original valid full token is required when the operation accepts it
2183
- delegated — delayed/on-behalf-of work; approved short-lived credential + immutable actor metadata
2184
- system — scheduler/maintenance with no user authority; omit session and record a reason
2185
-
2186
- Backend propagation attributes component activity to an actor, but it does not record frontend
2187
- pageviews, navigation, UI intent, funnels, or client failures. Continue immediately with
2188
- ductape_docs({ topic: "frontend-analytics" }); both layers are required for a complete picture.
2189
- `.trim(),
2190
-
2191
- caches: `
2192
- DUCTAPE CACHES
2193
-
2194
- Caches are product-level Redis (or in-memory) stores for temporary key-value data with optional TTL.
2195
-
2196
- Registration (admin — ductape_cli):
2197
- ductape_cli("resources caches create -f cache.json")
2198
- File: { name, tag, description?, expiry: <milliseconds> }
2199
- No type or envs — Ductape manages the store infrastructure.
2200
- expiry is in MILLISECONDS: 3600000 = 1 hour, 86400000 = 1 day, 604800000 = 1 week.
2201
-
2202
- Operations:
2203
- caches.set [{ product, cache, key, value: string, expiry?: string (ISO 8601), env }]
2204
- expiry is an ABSOLUTE TIMESTAMP (not a duration).
2205
- To expire in 1 hour: expiry = new Date(Date.now() + 3600_000).toISOString()
2206
- To expire in 24 hours: expiry = new Date(Date.now() + 86400_000).toISOString()
2207
- To never expire: omit expiry entirely.
2208
- Via MCP: pass an ISO 8601 string e.g. "2026-07-17T12:00:00.000Z"
2209
- → Writes to Redis synchronously, then fires remote API write in background (non-blocking).
2210
- caches.get [{ key: string }]
2211
- → Checks Redis first; on miss falls through to remote API; on hit from API, populates Redis.
2212
- → Enforces TTL by comparing the stored expiry timestamp against current time (client-side check).
2213
- caches.clear [{ key: string }]
2214
- → Deletes from Redis and from remote API.
2215
- caches.clearAll [{ product, cache, env? }]
2216
- → Bulk delete via remote API only. Redis may still have stale keys until naturally evicted.
2217
- caches.fetchValues [{ product, cache, env?, page?, limit?,
2218
- expiryFilter?: "all"|"expiring"|"permanent"|"expired" }]
2219
- caches.fetchDashboard [{ product, cache, env? }]
2220
- → { totalValues, activeValues, expiredValues, totalSize }
2221
-
2222
- Tier architecture (three tiers applied automatically):
2223
- Tier 1: in-process Map (SDK metadata cache, 5-min TTL — not for user data)
2224
- Tier 2: Redis hash (hSet/hGetAll) with optional EXPIRE
2225
- Tier 3: Remote Ductape API
2226
-
2227
- Practical examples:
2228
- // Cache a player leaderboard for 5 minutes:
2229
- { product, cache: "leaderboard-cache", key: "top-100", value: JSON.stringify(rows),
2230
- expiry: new Date(Date.now() + 300_000).toISOString(), env: "prd" }
2231
-
2232
- // Cache a session token lookup for 1 hour:
2233
- { product, cache: "session-cache", key: "player:u_123", value: token,
2234
- expiry: new Date(Date.now() + 3_600_000).toISOString(), env: "prd" }
2235
-
2236
- Important:
2237
- - Redis is optional; without it all reads/writes go through the remote API.
2238
- - expiry is stored as a Date field, not a Redis TTL — the expiry check happens client-side on read.
2239
- - clearAll only clears via the remote API bulk endpoint; Redis may retain stale entries until evicted.
2240
- - Cache entries are stored as Redis hashes (not plain strings).
2241
- - Other services (storage, graph, notifications, sessions) also use CacheManager internally —
2242
- configure a shared Redis URL at SDK init to share the pool.
2243
- `.trim(),
2244
-
2245
- notifications: `
2246
- DUCTAPE NOTIFICATIONS
2247
-
2248
- Notifications send messages across multiple channels: email, SMS, push, or HTTP callback.
2249
-
2250
- ADMINISTRATION — CLI (never ductape_execute)
2251
- ductape_cli("resources notifications create -f notification.json")
2252
- ductape_cli("resources notifications get --tag <notification-tag> --json")
2253
- ductape_cli("resources notifications list --json")
2254
- ductape_cli("resources notifications update --tag <notification-tag> -f patch.json")
2255
- ductape_cli("resources notifications delete --tag <notification-tag>")
2256
- ductape_cli("notifications messages create -f message.json")
2257
- ductape_cli("notifications messages list --notification <notification-tag> --json")
2258
- ductape_cli("notifications messages get --tag <notification:message> --json")
2259
- ductape_cli("notifications messages update --tag <notification:message> -f patch.json")
2260
- Declarative alternative: ductape/notifications.json then ductape_cli("apply notifications").
2261
- The file MUST be a top-level JSON array. Each item is a notification definition and may contain
2262
- a nested "messages" array. {"notifications":[],"messages":[]} is not a valid envelope.
2263
-
2264
- Notification definition:
2265
- {
2266
- tag: "welcome-email", // component tags cannot contain ":"
2267
- name: "Welcome Email",
2268
- description: "Transactional welcome messages",
2269
- envs: [{
2270
- slug: "prd",
2271
- emails: {
2272
- provider: "smtp",
2273
- smtp: {
2274
- host: "smtp.example.com",
2275
- port: "$Secret{smtp-port}",
2276
- sender_email: "hello@example.com",
2277
- auth: { user: "$Secret{smtp-user}", pass: "$Secret{smtp-password}" },
2278
- secure: true
2279
- }
2280
- }
2281
- }],
2282
- messages: []
2283
- }
2284
-
2285
- Create a message template through declarative apply or Workbench:
2286
- {
2287
- tag: "welcome-email:default", // message tags use component-tag:message-tag
2288
- name: "Default welcome",
2289
- description: "Sent after account creation",
2290
- push_notification: {
2291
- title: "Welcome, {{name}}!",
2292
- body: "Thanks for signing up.",
2293
- data: {}
2294
- },
2295
- email: {
2296
- subject: "Welcome, {{name}}!",
2297
- template: "Hi {{name}}, thanks for signing up."
2298
- }
2299
- }
2300
-
2301
- TAG AND SMTP RULES
2302
- Component tag: "game-alerts-critical" (no colon).
2303
- Message tag: "game-alerts-critical:match-launch" (one colon separator).
2304
- Do not add "notification", "subject", or "body" at message root.
2305
- SMTP requires emails.smtp.sender_email.
2306
- emails.smtp.secure is a boolean and cannot be a $Secret{...} string.
2307
- Credential strings such as auth.user and auth.pass may use $Secret{...}.
2308
-
2309
- Send at runtime (one channel at a time):
2310
- → CALL ductape_generate_payload FIRST (operation_family="notification", method="email.send")
2311
- notifications.email.send [{ product, env, notification, input: { recipients, subject?, template? } }]
2312
- notifications.push.send [{ product, env, notification, input: { device_tokens, title?, body?, data? } }]
2313
- notifications.sms.send [{ product, env, notification, input: { recipients, body? } }]
2314
- notifications.callback.send [{ product, env, notification, input: { query?, headers?, params?, body? } }]
2315
- notifications.slack.send [{ product, env, notification, input: { text?, blocks?, channel? } }]
2316
- notifications.discord.send [{ product, env, notification, input: { content?, embeds? } }]
2317
-
2318
- Multi-channel send (all channels in parallel):
2319
- notifications.send [{ product, env, event: "notif_tag:message_tag", input: { ... } }]
2320
- → success is true if at least one channel succeeds; channel failures do not abort siblings.
2321
-
2322
- Background dispatch with scheduling:
2323
- notifications.dispatch [{ product, env, notification, event, input,
2324
- schedule?: { start_at?, cron?, every?, limit?, endDate?, tz? } }]
2325
- → Returns { job_id, status: "queued", scheduled_at, recurring, next_run_at }
2326
-
2327
- Query delivery logs:
2328
- notifications.getMessages [{ product_tag?, env?, notification_tag?, status?,
2329
- type?, start_date?, end_date?, page?, limit? }]
2330
- Status values: pending | sent | failed | reprocessing
2331
-
2332
- Supported providers:
2333
- Email: smtp | mailgun | sendgrid | postmark | brevo
2334
- SMS: twilio | nexmo | plivo | other
2335
- Push: firebase | expo
2336
- Callback: any HTTP endpoint
2337
-
2338
- Each notification env may configure push_notifications, emails, sms, callbacks, slack, and discord.
2339
- Email providers: smtp, sendgrid, mailgun, postmark, brevo.
2340
- SMS providers: twilio, nexmo/vonage, plivo.
2341
-
2342
- FIREBASE THROUGH A GCP CLOUD CONNECTION
2343
- Complete and validate a GCP cloud connection with a service account authorized for Firebase
2344
- Cloud Messaging. Then configure the notification env without copying service-account JSON:
2345
- {
2346
- "slug": "snd",
2347
- "push_notifications": {
2348
- "type": "firebase",
2349
- "cloud": "gcp-connection-tag",
2350
- "authMode": "cloud_connection",
2351
- "databaseUrl": "https://<project>.firebaseio.com"
2352
- }
2353
- }
2354
- The SDK requests current GCP credentials from the cloud connection at send time. Never put the
2355
- service-account private key in the notification file. Expo does not use a GCP cloud connection.
2356
- The recommended cloud connection scope is "notifications". It expresses Ductape capability/UI
2357
- intent; creating a notification does not prove Google-side FCM permission. Runtime delivery
2358
- requires fcm.googleapis.com and roles/firebasecloudmessaging.admin, so validate the connection
2359
- and perform a delivery test.
2360
- Notification tag and message tag are ALWAYS passed together as "notification_tag:message_tag".
2361
- `.trim(),
2362
-
2363
- resilience: `
2364
- DUCTAPE RESILIENCE
2365
-
2366
- Resilience covers three mechanisms: quotas (rate-limited provider pools), fallbacks (automatic
2367
- provider switching), and healthchecks (continuous probe monitoring with failure actions).
2368
-
2369
- CONFIGURATION BOUNDARY
2370
- Quotas, fallbacks, and health checks are administrative product configuration. Configure them in
2371
- Workbench (or a future access-key administrative tool explicitly documented for the asset).
2372
- Never route administrative create/update methods through ductape_execute: its publishable-key
2373
- runtime proxy will fail.
2374
-
2375
- QUOTAS — rate-limited multi-provider pools:
2376
- Workbench definition shape:
2377
- {
2378
- tag: "sms-quota",
2379
- name: "SMS Provider Pool",
2380
- input: { to: { type: "string", required: true }, message: { type: "string" } },
2381
- options: [
2382
- { provider: "twilio", app: "twilio-app", type: "action", event: "send-sms",
2383
- quota: 1000, uses: 0, retries: 2,
2384
- input: { "body:to": "$Input{to}", "body:message": "$Input{message}" },
2385
- output: {} },
2386
- { provider: "nexmo", app: "nexmo-app", type: "action", event: "send-sms",
2387
- quota: 500, uses: 0, retries: 1,
2388
- input: { "body:to": "$Input{to}", "body:body": "$Input{message}" },
2389
- output: {} },
2390
- ],
2391
- }
2392
- Providers are tried in order until quota is not exhausted.
2393
- quotas.run [{ product, env, tag, input }] ← CALL ductape_generate_payload FIRST
2394
- quotas.dispatch [{ product, env, tag, input, schedule? }]
2395
-
2396
- FALLBACKS — automatic provider switching on failure:
2397
- Same schema as quotas but options are ordered: primary first, then fallback(s).
2398
- Primary is used first; on failure, the next provider is tried automatically.
2399
- Configure in Workbench: { tag, name, input: { ... }, options: [...] }
2400
- fallback.run [{ product, env, tag, input }] ← CALL ductape_generate_payload FIRST
2401
- fallback.dispatch [{ product, env, tag, input, schedule? }]
2402
-
2403
- HEALTHCHECKS — continuous probe with failure notifications:
2404
- Workbench definition shape:
2405
- {
2406
- tag: "payment-health",
2407
- name: "Payment Service Health",
2408
- probe: { type: "app", app: "stripe-app", event: "ping" },
2409
- interval: 30000, // ms between checks
2410
- retries: 3,
2411
- envs: [{ slug: "prd", input: {} }],
2412
- onFailure: {
2413
- notifications: [{ notification: "ops-alerts", message: "payment-down",
2414
- channels: { email: { recipients: ["ops@example.com"] } } }],
2415
- webhooks: [{ url: "https://hooks.example.com/alert", method: "POST" }],
2416
- },
2417
- }
2418
- health.run [{ product, env, tag }] → triggers an immediate probe
2419
- health.check [{ product, env, tag }] → same as run
2420
- health.status [{ product, env, tag }] → current health status
2421
-
2422
- Probe types: app | database | feature | graph | message_broker | storage
2423
- Failure actions: notification channels, HTTP webhooks, and/or message broker emit — all can
2424
- be configured simultaneously on the same healthcheck.
2425
- Input template references: $Input{field} → maps declared input to the probe's action input.
2426
- Provider status: available | unavailable
2427
-
2428
- DECISION MATRIX
2429
- Rate/capacity allocation across providers → quota
2430
- Equivalent provider after operational failure → fallback
2431
- Detect failure before routing provider traffic → health check
2432
- Transient failure of one operation → bounded retry + idempotency policy
2433
- Multi-step business recovery/compensation → Feature
2434
- Database atomicity → database transaction, not Feature rollback
2435
- Scheduled single operation → that component's dispatch
2436
- Scheduled multi-step process → Feature dispatch
2437
-
2438
- COMBINING MECHANISMS
2439
- A health check may keep an unhealthy provider out of a fallback/quota pool; the pool controls
2440
- provider selection; each operation may use bounded retries and an idempotency key; a Feature
2441
- coordinates business steps and compensation around those resilient operations. Do not stack them
2442
- reflexively: each layer needs a distinct failure it owns, bounded retry budgets, and observable
2443
- terminal behavior. Database transactions remain the atomicity boundary for related DB writes.
2444
- `.trim(),
2445
-
2446
- 'frontend-analytics': `
2447
- DUCTAPE FRONTEND PRODUCT ANALYTICS AND SESSION ACTIVITY
2448
-
2449
- Frontend analytics and backend session attribution are complementary; neither replaces the other.
2450
-
2451
- BACKEND OPERATION ATTRIBUTION
2452
- Immediate user-initiated backend work should pass the original full Ductape session token to every
2453
- database, Event, Feature, notification, storage, graph, vector, action, and other runtime operation
2454
- that accepts session. This attributes immediate server work to the authenticated actor.
2455
- For delayed/durable work, do not persist raw JWTs indefinitely. Use system context, or an approved
2456
- delegated actor design with immutable non-secret actor metadata. Actor metadata supports audit and
2457
- correlation; it is not authorization. Intentional system/background work should remain sessionless.
2458
-
2459
- FRONTEND PRODUCT ANALYTICS
2460
- Use the browser Analytics service for anonymous visits, authenticated page views, navigation,
2461
- UI interactions, funnels, client errors, realtime state, and product-surface engagement.
2462
-
2463
- IMPORTANT:
2464
- Passing session to backend component calls does not replace frontend analytics.
2465
- Calling analytics.identify(sessionToken) and tracking frontend events does not replace backend
2466
- session propagation. A complete activity picture requires both.
2467
-
2468
- VERSION AND DOCUMENTATION PRECEDENCE
2469
- 1. Installed package types and exports determine what can be called now.
2470
- 2. Version-matched package documentation explains intended usage.
2471
- 3. Current docs/docs/Frontend describes the latest supported design.
2472
- 4. If they disagree, report the mismatch; never fabricate compatibility.
2473
-
2474
- Inspect package.json plus package exports/type declarations before recommending framework hooks.
2475
- The current repository exports useAnalytics from @ductape/react and @ductape/vue. Their installed
2476
- hook/composable exposes track, pageview, identify, visitorId, enableAutoCapture, and flush.
2477
- clearSession and disableAutoCapture are available on client.analytics but are not currently
2478
- returned by those framework wrappers. Use useDuctape().client.analytics for those calls, or
2479
- recommend a package version that exports them; do not generate a hook method that does not exist.
2480
-
2481
- CLIENT API — IDENTIFY AFTER AUTHENTICATION
2482
- ductape.analytics.identify(sessionToken);
2483
-
2484
- sessionToken is the complete value returned by Ductape, in "player-session:jwt" format.
2485
- It is NOT a player ID, session tag, session ID, decoded claims, or refresh token.
2486
- identify links subsequent frontend analytics to the authenticated Ductape session and lets
2487
- supported analytics correlate anonymous pre-login activity with authenticated activity.
2488
-
2489
- LOGOUT / ACCOUNT SWITCHING
2490
- await ductape.analytics.flush();
2491
- ductape.analytics.clearSession();
2492
-
2493
- Clear analytics identity when logout or revocation succeeds, refresh fails irrecoverably, local
2494
- authentication is removed, or a different user is about to authenticate in the same browser.
2495
- Removing only the application token can leave later anonymous/next-user events associated with
2496
- the previous analytics identity. Flush is best-effort; browser shutdown does not guarantee it.
2497
-
2498
- CUSTOM EVENTS
2499
- await ductape.analytics.track({
2500
- event: 'order_submitted',
2501
- traceId,
2502
- properties: { matchId, orderType },
2503
- });
2504
-
2505
- IAnalyticsTrackOptions:
2506
- event: string
2507
- properties?: Record<string, unknown>
2508
- session?: string
2509
- product?: string
2510
- env?: string
2511
- traceId?: string
2512
- context?: { url?, path?, referrer?, locale?, screen?: { width, height } }
2513
-
2514
- identify establishes the default analytics session. An individual event may explicitly provide
2515
- session. Normally use product/env from client configuration. traceId correlates frontend intent
2516
- with backend logs, Events, Features, and order processing.
2517
-
2518
- PAGE VIEWS
2519
- await ductape.analytics.pageview({
2520
- path: location.pathname,
2521
- title: document.title,
2522
- properties: { matchId, screen: 'governance' },
2523
- });
2524
-
2525
- IAnalyticsPageviewOptions:
2526
- path?: string
2527
- title?: string
2528
- session?: string
2529
- product?: string
2530
- env?: string
2531
- properties?: Record<string, unknown>
2532
-
2533
- AUTO-CAPTURE — OPT IN ONLY AFTER A PRIVACY AUDIT
2534
- const stopAutoCapture = ductape.analytics.enableAutoCapture({
2535
- session: () => authSession?.token,
2536
- clicks: false,
2537
- pageviews: true,
2538
- maskTextSelectors: [
2539
- '[data-private]',
2540
- '[data-secret]',
2541
- '[data-player-message]',
2542
- '[data-intelligence-report]',
2543
- ],
2544
- });
2545
- stopAutoCapture(); // or ductape.analytics.disableAutoCapture()
2546
-
2547
- Mark sensitive UI with data-private/data-secret attributes. Text masking may not mask attributes,
2548
- IDs, URLs, element names, custom properties, console errors, or network errors. Inspect actual
2549
- payloads before production. For hidden-information products, begin with reviewed automatic
2550
- pageviews, clicks disabled, and preferred custom named events.
2551
-
2552
- VISITOR ID
2553
- const visitorId = ductape.analytics.getVisitorId();
2554
- A visitor ID is anonymous analytics identity, not authentication or authorization.
2555
-
2556
- FRONTEND SESSION LIFECYCLE
2557
- Before login:
2558
- - Track anonymous pageviews/onboarding; do not invent a session.
2559
- After login/registration:
2560
- - Store the session securely, identify(fullSessionToken), track success, start authenticated
2561
- pageviews, and pass the same token to user-context realtime/component operations.
2562
- After refresh:
2563
- - Replace the old token, identify(newSessionToken), update realtime connections/subscriptions,
2564
- and use the refreshed token for future backend operations.
2565
- Logout:
2566
- - Optionally track logout_initiated, flush, revoke, disconnect realtime, clearSession, then
2567
- remove local authentication.
2568
- Refresh failure/revocation:
2569
- - Disconnect user-context clients, clearSession, clear local auth, navigate to authentication,
2570
- and track only anonymous events afterward.
2571
-
2572
- REACT ROUTE TRACKING (verify installed exports first)
2573
- import { useAnalytics, useDuctape } from '@ductape/react';
2574
- import { useEffect } from 'react';
2575
- import { useLocation } from 'react-router-dom';
2576
-
2577
- function ProductAnalytics({ sessionToken }: { sessionToken?: string }) {
2578
- const analytics = useAnalytics();
2579
- const { client } = useDuctape();
2580
- const location = useLocation();
2581
- useEffect(() => {
2582
- if (sessionToken) analytics.identify(sessionToken);
2583
- else client.analytics.clearSession();
2584
- }, [analytics, client, sessionToken]);
2585
- useEffect(() => {
2586
- void analytics.pageview({ path: location.pathname, title: document.title });
2587
- }, [analytics, location.pathname]);
2588
- return null;
2589
- }
2590
-
2591
- SAFE EVENT TAXONOMY
2592
- Define stable names centrally; do not invent variants throughout components.
2593
- Authentication:
2594
- registration_started, registration_completed, registration_failed,
2595
- login_started, login_completed, login_failed, session_refreshed,
2596
- session_refresh_failed, logout_completed
2597
- Match:
2598
- match_list_viewed, match_creation_started, match_created, match_joined, lobby_viewed,
2599
- player_marked_ready, match_preparation_started, world_loaded, match_reconnected,
2600
- match_completed, endgame_viewed
2601
- Orders:
2602
- order_form_opened, order_previewed, order_submission_started, order_submitted,
2603
- order_submission_failed, order_cancelled, boundary_result_viewed
2604
- Realtime:
2605
- realtime_connect_started, realtime_connected, realtime_disconnected,
2606
- realtime_reconnect_attempted, realtime_subscription_failed, projection_refresh_failed,
2607
- client_error
2608
- Funnels:
2609
- tutorial_started, tutorial_step_completed, tutorial_abandoned, first_match_created,
2610
- first_order_submitted, first_boundary_viewed, first_match_completed
2611
-
2612
- Safe properties include matchId, orderType, screen, tick, result, and errorCategory.
2613
-
2614
- HIDDEN AND SENSITIVE DATA — NEVER SEND TO ANALYTICS
2615
- Do not track exact hidden formations, operative/handler identities, secret operation payloads,
2616
- false-report truth markers, undiscovered evasion details, invisible treaties, canonical hidden
2617
- map state, private messages, passwords, authorization headers, session/refresh tokens in event
2618
- properties, or full errors/records that may contain secrets. Analytics must observe usage, not
2619
- become a hidden-state side channel.
2620
-
2621
- CORRELATION
2622
- Frontend: create traceId = crypto.randomUUID(), track intent with traceId, and send traceId with
2623
- the actual request. Backend logs the same traceId and propagates session to Ductape operations.
2624
- Track completion with the same traceId.
2625
-
2626
- traceId = correlation
2627
- session = actor attribution
2628
- idempotencyKey = duplicate prevention
2629
- matchId/orderId = domain identity
2630
- These values are not interchangeable.
2631
-
2632
- OWNERSHIP BOUNDARIES
2633
- Analytics is never an authoritative order, authorization proof, or gameplay source of truth.
2634
- Analytics failure must not block or alter gameplay. The server verifies sessions independently;
2635
- authoritative database and Event streams remain the source of truth. Prefer non-blocking
2636
- analytics except explicit best-effort flushes at safe lifecycle transitions.
2637
-
2638
- FRONTEND PROJECT AUDIT
2639
- Inspect installed @ductape/client/react/vue versions and actual exports; client/provider setup;
2640
- identify after login and refresh; clearSession on logout/account switch; SPA pageviews; auto-
2641
- capture and masking; taxonomy consistency; sensitive custom properties; and shared trace IDs.
2642
- Report capability as Present/Missing/Partial/Not applicable with concrete findings.
2643
-
2644
- WHEN “SESSION ACTIVITY IS MISSING FROM THE DASHBOARD”
2645
- Investigate both tracks before blaming the SDK.
2646
- Backend: start/verify format, correct env, session on database/Event/Feature/notification/etc.,
2647
- and intentional background sessionlessness.
2648
- Frontend: identify after login/refresh, clearSession on logout, pageviews/custom events, flush,
2649
- correct publishable key/product/env, installed API compatibility, browser failures, and privacy
2650
- controls that may suppress events.
2651
-
2652
- PAYLOAD RECIPES
2653
- Anonymous:
2654
- await ductape.analytics.pageview({ path: window.location.pathname, title: document.title });
2655
- Authenticated:
2656
- ductape.analytics.identify(playerSessionToken);
2657
- await ductape.analytics.track({
2658
- event: 'match_created', session: playerSessionToken, traceId, properties: { matchId },
2659
- });
2660
- Logout:
2661
- await ductape.analytics.flush();
2662
- ductape.analytics.clearSession();
2663
- `.trim(),
2664
-
2665
- features: `
2666
- DUCTAPE FEATURES
2667
-
2668
- A feature is an orchestrated workflow of durable steps. Steps can call app actions, database
2669
- operations, graph queries, storage uploads, notifications, broker publishes, child features,
2670
- quotas, fallbacks, and more. Features support rollback, signals, checkpoints, and sleep.
2671
-
2672
- ━━━ AI DESIGN WORKFLOW — follow this process every time a user asks you to build or plan a feature ━━━
2673
-
2674
- ALWAYS use features.define (code-first). NEVER use features.create.
2675
- The feature handler is real code written into the project's source files — find the project's
2676
- language and framework, write the feature into an appropriate file in the codebase, and call
2677
- features.define from there. Do not generate inline snippets and stop — write the actual file.
2678
-
2679
- STEP 1 — UNDERSTAND the goal
2680
- Ask clarifying questions if the user's intent is unclear. Do not start designing until you
2681
- understand: what the feature does, what it returns, what can fail and how failures should behave.
2682
-
2683
- STEP 2 — INVENTORY existing Ductape components
2684
- Call ductape_cli("products get --tag <product_tag> --json") to read the full product document.
2685
- (product.* requires the access key — never use ductape_execute for product reads, it will return 403)
2686
- Note what already exists:
2687
- - databases[] → available for ctx.database.insert/query/update/delete steps
2688
- - apps[] → available for ctx.api.run steps ONLY if the App is fully registered:
2689
- (a) App record exists, (b) environments defined with base URLs,
2690
- (c) auth scheme configured, (d) action endpoints defined,
2691
- (e) connected to the product via product.apps.add.
2692
- Check app.events[] for action tags. If no app exists for a service the
2693
- feature needs to call, flag it as "App to create" in the plan — do NOT
2694
- assume ctx.api can call any URL or schedule any job without a registered App.
2695
- - notifications[] → available for ctx.notification.email/sms/push steps
2696
- - storage[] → available for ctx.storage.upload/download steps
2697
- - messageBrokers[] → available for ctx.events.produce steps
2698
- - graphs[] → available for ctx.graph steps
2699
- - features[] → can be called as child features via ctx.feature.execute()
2700
- - caches[], sessions[]
2701
- Do NOT assume a component or event tag exists — verify from the product before using it.
2702
- Do NOT treat ctx.api as a generic HTTP call or job scheduler. It requires a registered App.
2703
-
2704
- STEP 3 — PLAN each step
2705
- For every logical step:
2706
- a. Identify which existing component handles it, or flag it as needing creation.
2707
- If a step calls an external service, it MUST go through a registered Ductape App.
2708
- If no App for that service exists in the product → mark it "App to create: <service name>".
2709
- DO NOT plan a raw HTTP call, a direct dispatch to a URL, or any workaround in place of a missing App.
2710
- b. Note how inputs flow: ctx.input fields, or return values from earlier steps (plain JS variables — no special notation needed)
2711
- c. Decide if a rollback handler is needed (e.g. charge → refund on later failure)
2712
- d. Decide allow_fail: true for non-critical steps (email, analytics, audit logs)
2713
-
2714
- STEP 4 — PRESENT the plan and get approval BEFORE writing any code or creating anything
2715
- Show the user:
2716
- Feature: <name> (<tag>)
2717
- Input fields: list them with types
2718
- Steps (in order):
2719
- 1. <step-tag> — <what it does> — <component> [rollback: yes/no] [allow_fail: yes/no]
2720
- 2. ...
2721
- Return value: describe what the handler returns
2722
- Components to create: list anything missing with a short description
2723
- Ask: "Should I proceed? Shall I create the missing components first?"
2724
- Wait for confirmation before writing code or calling any create tool.
2725
-
2726
- STEP 5 — CREATE missing components (only with user approval)
2727
- Administrative assets must never be created through ductape_execute. Use ductape_cli for products,
2728
- apps, supported resources, broker topics, cloud connections, secrets, and declarative apply flows.
2729
- App actions, auths, quotas, fallbacks, health checks, and other administrative assets for which
2730
- the CLI has no command must be configured in Workbench. Feature definitions are the exception:
2731
- they are code-first via features.define, are registered by application boot/runtime initialization,
2732
- and cannot be created with the CLI. Do not generate a Workbench-only or publishable-key
2733
- create/update call for a Feature. For a missing database action, configure it in Workbench, then verify it exists.
2734
- For a missing child feature, recursively apply this same workflow.
2735
- Tell the user what you are about to create before each tool call.
2736
-
2737
- STEP 6 — WRITE the feature into the project codebase
2738
- - Locate or create an appropriate file in the project (e.g. src/features/feature-name.ts, features/feature_name.py)
2739
- - Use features.define with an async handler
2740
- - Step results are plain variables — just await ctx.step(...) and use the return value in the next step
2741
- - No special notation needed: const user = await ctx.step('create-user', async () => { ... }); then use user.id directly in the next step
2742
- - Write rollback handlers inline as the third argument to ctx.step()
2743
- - Return a plain object as the feature's output
2744
-
2745
- STEP 7 — HANDLE conditionals, loops, and branching (when applicable)
2746
- Branch on step result (early return in handler):
2747
- → Add branchOverrides: { stepTag: { field: value } } so all branches are recorded
2748
- → At runtime the executor evaluates the real result and skips or runs later steps accordingly
2749
- Loop over input array:
2750
- → Add recordInput: { items: [{ id: "1" }, { id: "2" }] } so the loop runs during recording
2751
- → Use unique step tags per iteration (e.g. "process-" + item.id)
2752
- Switch/if-else on feature input values:
2753
- → Add recordScenarios: [{ type: "a" }, { type: "b" }] — handler runs once per scenario
2754
- → Only the scenario whose input matches runs at execution time
2755
-
2756
- STEP 8 — SET rollbacks for reversible steps
2757
- Any step that allocates a resource should undo it if a later step fails.
2758
- ctx.api.run requires a pre-registered Ductape App — 'stripe' below is the tag of a registered App:
2759
- const charge = await ctx.step(
2760
- 'charge',
2761
- async () => ctx.api.run({ app: 'stripe', event: 'create-charge', input: { amount: ctx.input.amount } }),
2762
- async (result) => ctx.api.run({ app: 'stripe', event: 'refund', input: { chargeId: result.id } })
2763
- );
2764
-
2765
- Step types: action | database | graph | notification | storage | produce | quota | fallback |
2766
- vector | child_feature | sleep | wait_for_signal | checkpoint
2767
-
2768
- Define a feature (write this into the project's source files — do NOT use features.create):
2769
- // src/features/onboard-user.ts (or the equivalent path/language for the project)
2770
- await ductape.features.define({
2771
- tag: "onboard-user",
2772
- name: "Onboard User",
2773
- handler: async (ctx) => {
2774
- const account = await ctx.step("create-account", async () =>
2775
- ctx.database.insert({ database: "core-db", event: "insert-user",
2776
- data: { userId: ctx.input.userId, email: ctx.input.email } }),
2777
- async (result) => ctx.database.delete({ database: "core-db", event: "delete-user",
2778
- where: { id: result.id } }) // rollback
2779
- );
2780
-
2781
- await ctx.step("send-welcome", async () =>
2782
- ctx.notification.email({ notification: "welcome-email", event: "welcome",
2783
- recipients: [ctx.input.email], subject: {}, template: {} }),
2784
- null,
2785
- { allow_fail: true }
2786
- );
2787
-
2788
- return { userId: account.id };
2789
- },
2790
- });
2791
-
2792
- Execute at runtime:
2793
- → CALL ductape_generate_payload FIRST (operation_family="features", method="execute", targets={tag})
2794
- features.execute [{ product, env, tag, input: { userId: "u_123", email: "..." },
2795
- idempotency_key?: string, retries?: number, timeout?: number }]
2796
-
2797
- Background dispatch with scheduling:
2798
- features.dispatch [{ product, env, feature, input,
2799
- schedule?: { start_at?, cron?, every?, limit?, endDate?, tz? } }]
2800
- → Returns { job_id, status: "queued"|"scheduled", scheduled_at, next_run_at }
2801
-
2802
- Execution management:
2803
- features.status [executionId] → { status, current_step, completed_steps, output, error }
2804
- features.cancel [executionId, reason?] → triggers rollback of running steps
2805
- features.replay [executionId, options?] → re-run with original input
2806
- features.restart [executionId] → re-run with new input
2807
- features.resume [executionId] → continue from a checkpoint
2808
- features.replayFromStep [executionId, stepTag] → replay from a specific step
2809
- features.history [executionId] → { events[], checkpoints[], replays[], restarts[] }
2810
- features.stepDetail [executionId, stepTag] → per-step input/output/error/timing
2811
-
2812
- Signals and queries (for long-running features):
2813
- features.signal [{ product, env, feature_id, signal: "payment-confirmed", payload? }]
2814
- features.query [{ product, env, feature_id, query: "current-status", params? }]
2815
-
2816
- Rollback strategies: reverse_all | reverse_critical | compensate | none
2817
- Feature statuses: pending | running | completed | failed | rolled_back | rolling_back | paused
2818
-
2819
- ━━━ FEATURE RECORDING SEMANTICS ━━━
2820
-
2821
- When you call features.define({ handler }), the handler runs TWICE:
2822
-
2823
- 1. RECORDING PHASE (at define time) — handler is called with a RecordingContext.
2824
- All ctx.step() calls return lightweight proxy objects, not real data.
2825
- This phase captures the step graph: which steps exist, their types, tags, and declared
2826
- inputs/outputs. No real API calls, DB queries, or side effects occur.
2827
- Arbitrary JS code OUTSIDE ctx.step() ALSO runs during recording — with proxy values.
2828
- For loops: use recordInput so the handler sees sample data and all iterations are recorded.
2829
- For branches: use branchOverrides so each path is captured.
2830
-
2831
- 2. EXECUTION PHASE (at runtime) — handler is called with a real ExecutionContext.
2832
- ctx.step() actually executes. All real Ductape component calls happen.
2833
- Arbitrary JS logic (math, string ops, conditionals on step results) runs for real.
2834
-
2835
- Implication: put all meaningful business logic INSIDE ctx.step() handlers, not in the
2836
- outer handler body. Code in the outer body runs during recording with proxy values and
2837
- may behave unexpectedly (e.g. typeof proxy === 'object' is true but .someField is a proxy).
2838
-
2839
- Features do NOT execute arbitrary NestJS or server code directly. A feature handler can only
2840
- call Ductape component primitives (ctx.api, ctx.database, ctx.notification, etc.) as steps.
2841
- To invoke internal application business logic, produce a broker event from a feature step
2842
- (ctx.events.produce in the currently published SDK) and consume it in your NestJS service.
2843
- ctx.publish is deprecated; do not use it. Do not assume a ctx.messaging alias exists unless the
2844
- installed SDK types explicitly expose it.
2845
-
2846
- ━━━ ORCHESTRATION DECISION RULE ━━━
2847
-
2848
- One component operation at a future time:
2849
- → use that component's own dispatch method
2850
- e.g. ductape.events.dispatch({ ..., schedule: { start_at: ... } })
2851
- e.g. ductape.api.dispatch({ ..., schedule: { start_at: ... } })
2852
- e.g. ductape.database.dispatch({ ..., schedule: { start_at: ... } })
2853
-
2854
- Several durable Ductape component operations in sequence (with rollback / retry / state):
2855
- → define a Feature, then features.dispatch to schedule it
2856
-
2857
- Invoke internal application business logic (your own NestJS/backend service code):
2858
- → produce a broker event (ctx.events.produce inside a Feature, or ductape.events.produce outside it)
2859
- → follow ductape_docs({ topic: "events" }) and use the canonical NestJS decorator:
2860
- @Events.Consumer({ event: "broker-tag:topic-tag" })
2861
- async handle(message: MessageShape) { /* injected-service business logic; throw to nack */ }
2862
- → DuctapeModule auto-registers the decorated consumer; no manual onModuleInit is needed
2863
- → events.consume() remains a supported lower-level alternative for plain TypeScript/Node.js
2864
- → your service method runs with full access to DI, DB transactions, etc.
2865
- Do NOT create an App Action just to call your own service over HTTP.
2866
-
2867
- Invoke an external/public HTTP service:
2868
- → create a Ductape App (register base URL, auth, action endpoints) then use ctx.api.run
2869
- → requires the App to be fully registered and connected to the product first
2870
- `.trim(),
2871
-
2872
- events: `
2873
- DUCTAPE EVENTS (MESSAGE BROKERS)
2874
-
2875
- ARCHITECTURE — always two separate steps:
2876
- Step 1: Register the BROKER COMPONENT (establishes the connection to the broker service).
2877
- The broker's envs[] holds connection credentials and host/project info, NOT topics.
2878
- Step 2: Create TOPIC DEFINITIONS on that broker (unlimited; each is a named subject or queue).
2879
- Topics are separate from the broker registration and added after.
2880
- A single broker component can have as many topics as needed.
2881
-
2882
- ━━━ SUPPORTED BROKER TYPES ━━━
2883
-
2884
- Cloud-managed (provision OR import via cloud connection):
2885
- GCP Pub/Sub → service: "pubsub", type in envs: "google_pubsub"
2886
- AWS SQS → service: "sqs", type in envs: "aws_sqs"
2887
- Azure SvcBus → service: "servicebus", type in envs: "azure_servicebus"
2888
-
2889
- Self-hosted (import-only — supply connection URL manually):
2890
- Kafka → type in envs: "kafka"
2891
- RabbitMQ → type in envs: "rabbitmq"
2892
- Redis → type in envs: "redis"
2893
- NATS → type in envs: "nats"
2894
-
2895
- ━━━ STEP 1A: REGISTER VIA CLOUD CONNECTION (cloud-managed brokers) ━━━
2896
-
2897
- Provision (create a NEW resource in the cloud):
2898
- ductape_cli("cloud resources provision-persist-all -f brokers.json --json")
2899
- File: JSON ARRAY — one entry per env, same product + component tag across all entries.
2900
- type field: "messageBrokers" (exact — not "messagebrokers" or "events")
2901
-
2902
- GCP Pub/Sub — creates a new Pub/Sub topic in GCP, stores credentials in secrets:
2903
- Cost: GCP Pub/Sub is usage-based — no upfront cost, no tier selection required.
2904
- You pay per GB of data published/subscribed (first 10 GB/month free).
2905
- It is safe to provision without user approval of a fixed monthly cost.
2906
- [{"cloud":"gcp-snd","service":"pubsub","type":"messageBrokers",
2907
- "product":"my-product","component":"notifications-broker","env":"snd",
2908
- "topicName":"my-product-notifications-snd"},
2909
- {"cloud":"gcp-prd","service":"pubsub","type":"messageBrokers",
2910
- "product":"my-product","component":"notifications-broker","env":"prd",
2911
- "topicName":"my-product-notifications-prd"}]
2912
- If topicName is omitted a timestamped name is generated — always supply it explicitly.
2913
-
2914
- AWS SQS — creates a new SQS queue per env:
2915
- Cost: SQS is usage-based — no upfront cost, no tier selection required.
2916
- First 1 million requests/month free; $0.40 per million after that.
2917
- It is safe to provision without user approval of a fixed monthly cost.
2918
- [{"cloud":"aws-snd","service":"sqs","type":"messageBrokers",
2919
- "product":"my-product","component":"notifications-broker","env":"snd",
2920
- "queueName":"my-product-notifications-snd"},
2921
- {"cloud":"aws-prd","service":"sqs","type":"messageBrokers",
2922
- "product":"my-product","component":"notifications-broker","env":"prd",
2923
- "queueName":"my-product-notifications-prd"}]
2924
-
2925
- Azure Service Bus — creates a namespace + queue per env:
2926
- Cost: Azure Service Bus has TIERED pricing — confirm the tier with the user before provisioning.
2927
- Basic: queues only, ~$0.05/million operations. No topics/subscriptions.
2928
- Standard: queues + topics, ~$10/month base + $0.10/million operations.
2929
- Premium: dedicated capacity, starts ~$677/month. Not needed for standard workloads.
2930
- DO NOT provision Azure Service Bus without confirming the tier with the user.
2931
- [{"cloud":"azure-snd","service":"servicebus","type":"messageBrokers",
2932
- "product":"my-product","component":"notifications-broker","env":"snd",
2933
- "namespaceName":"myproduct-snd","queueName":"notifications"},
2934
- {"cloud":"azure-prd","service":"servicebus","type":"messageBrokers",
2935
- "product":"my-product","component":"notifications-broker","env":"prd",
2936
- "namespaceName":"myproduct-prd","queueName":"notifications"}]
2937
-
2938
- Import (register an EXISTING cloud resource):
2939
- Same as above but use import-persist-all and supply "resource" (the existing resource name/ID):
2940
- ductape_cli("cloud resources import-persist-all -f brokers.json --json")
2941
- Each entry: { cloud, service, type: "messageBrokers", product, component, env, resource: "<id>" }
2942
-
2943
- IMPORTANT: Never share one cloud resource (topic/queue) across snd and prd envs — use
2944
- separate resources per env to avoid mixing sandbox and production events.
2945
-
2946
- ━━━ STEP 1B: REGISTER SELF-HOSTED BROKER (no cloud connection needed) ━━━
2947
-
2948
- ductape_cli("resources events create -f broker.json")
2949
- File: {
2950
- name: string, tag: string, description?: string,
2951
- envs: [
2952
- {
2953
- slug: "snd",
2954
- type: "kafka"|"rabbitmq"|"redis"|"nats",
2955
- config: <see config shapes below>
2956
- },
2957
- { slug: "prd", type: "kafka", config: { ... } }
2958
- ]
2959
- }
2960
-
2961
- Config shapes per type:
2962
- kafka: { brokers: ["host:9092"], clientId: "my-app", groupId?: "...",
2963
- ssl?: true, sasl?: { mechanism: "plain", username, password } }
2964
- rabbitmq: { url: "amqp://user:pass@host:5672/vhost" }
2965
- redis: { host: "...", port: 6379, password?: "..." }
2966
- nats: { servers: ["nats://host:4222"], token?: "...", user?: "...", pass?: "...", tls?: true }
2967
-
2968
- ━━━ STEP 2: DEFINE TOPICS ━━━
2969
-
2970
- Topics MUST be defined before any consumer can subscribe to them.
2971
- Producing to a topic also calls ensureTopicRegistered in the background — but DO NOT rely on
2972
- auto-registration for consume paths. Always create topics explicitly.
2973
-
2974
- IMPORTANT: events.topics.create requires an access key (admin operation).
2975
- Use ductape_cli — NOT ductape_execute — to create topics.
2976
-
2977
- Write a topic.json file, then:
2978
- ductape_cli("events topics create -f topic.json")
2979
-
2980
- topic.json schema:
2981
- {
2982
- "tag": "order-events:order-created", // ALWAYS "broker-tag:topic-tag" — full event string
2983
- "name": "Order Created",
2984
- "description": "...", // optional
2985
- "sample": { "orderId": "string", "total": 0 }, // expected message shape
2986
- "idempotent": false, // optional — deduplicates by idempotency_key when true
2987
- // AWS SQS only — per-env queue URL:
2988
- "queueUrls": [
2989
- { "env_slug": "snd", "url": "https://sqs.us-east-1.amazonaws.com/123/queue-snd" },
2990
- { "env_slug": "prd", "url": "https://sqs.us-east-1.amazonaws.com/123/queue-prd" }
2991
- ]
2992
- }
2993
-
2994
- Other topic operations (all require access key via ductape_cli):
2995
- ductape_cli("events topics list --tag order-events") → list topics for a broker
2996
- ductape_cli("events topics get --tag order-events:order-created")
2997
- ductape_cli("events topics update --tag order-events:order-created -f patch.json")
2998
- ductape_cli("events topics delete --tag order-events:order-created")
2999
-
3000
- Read-only fetches (safe with publishable key via ductape_execute):
3001
- ductape_execute("events.fetch", [product_tag, "broker-tag"]) → includes topics[]
3002
-
3003
- ━━━ STEP 3: PRODUCE — WRITTEN IN APPLICATION CODE ━━━
3004
-
3005
- There is NO admin command to declare a producer. Producers are auto-registered by the SDK on
3006
- the first produce call — you do not pre-declare them.
3007
- Do NOT call ductape_generate_payload for messaging. The producer owns the schema.
3008
- Infer the message shape from context, present it to the user for approval, then implement.
3009
-
3010
- GENERAL BACKEND (TypeScript/Node.js — not NestJS):
3011
- import Ductape from '@ductape/sdk';
3012
- // produce() does not need redis_url.
3013
- // dispatch() requires redis_url in the Ductape initialization options — it throws at runtime without it.
3014
- const ductape = new Ductape({
3015
- accessKey: process.env.DUCTAPE_ACCESS_KEY,
3016
- redis_url: process.env.DUCTAPE_REDIS_URL, // required for any dispatch(); omit only if never dispatching
3017
- });
3018
- await ductape.events.produce({
3019
- product: "my-product",
3020
- env: "prd",
3021
- event: "broker-tag:topic-tag", // always colon-separated
3022
- message: { key: value },
3023
- session?: "session-tag:jwt", // optional — traces message to a user session
3024
- });
3025
- // Idempotent publish (deduplicates — prevents double-processing on retries):
3026
- await ductape.events.publishIdempotent({
3027
- product, env, event, message,
3028
- idempotencyKey: "order-123-charge", // stable key unique to this logical operation
3029
- idempotencyTtl?: 86400, // seconds; default 86400 (24h)
3030
- });
3031
-
3032
- NESTJS — initialization + method decorators:
3033
- // AppModule — redisUrl is required whenever any *.dispatch() is used:
3034
- DuctapeModule.forRootAsync({
3035
- useFactory: () => ({
3036
- accessKey: process.env.DUCTAPE_ACCESS_KEY,
3037
- product: 'my-product',
3038
- env: process.env.NODE_ENV === 'production' ? 'prd' : 'snd',
3039
- redisUrl: process.env.DUCTAPE_REDIS_URL, // required — dispatch() throws without this
3040
- }),
3041
- });
3042
- // Environment: DUCTAPE_REDIS_URL=redis://localhost:6379 (local) or rediss://:<pw>@host:6380 (managed)
3043
- // produce() and @Events.Consumer do NOT need DUCTAPE_REDIS_URL — only dispatch() does.
3044
-
3045
- import { Events } from '@ductape/nestjs';
3046
- @Injectable() export class OrdersService {
3047
- // Immediate produce — method returns the message payload:
3048
- @Events.Produce({ event: 'order-events:order-created' })
3049
- emitOrderCreated(payload: { orderId: string; total: number }) { return payload; }
3050
-
3051
- // Dispatch with static schedule — requires redisUrl in DuctapeModule initialization:
3052
- @Events.Dispatch({ broker: 'order-events', event: 'order-events:reminder-due',
3053
- schedule: { every: 86400000 } })
3054
- scheduleReminder(payload: { message: { orderId: string } }) { return payload; }
3055
-
3056
- // Dispatch with dynamic schedule (known at call time) — method returns { message, schedule?, retries? }:
3057
- @Events.Dispatch({ broker: 'order-events', event: 'order-events:fulfillment-due' })
3058
- scheduleFulfillment(order: Order) {
3059
- return {
3060
- message: { orderId: order.id, items: order.items },
3061
- schedule: { start_at: order.expectedAt },
3062
- retries: 3,
3063
- };
3064
- }
3065
- // Called as: await this.ordersService.scheduleFulfillment(order);
3066
- // When method return has a 'message' key, schedule/retries from return take precedence over decorator config.
3067
- // For even more control (dynamic broker/event), use sdk.events.dispatch() directly.
3068
- }
3069
-
3070
- CLIENT-SIDE (browser — publishable key):
3071
- Clients CAN produce messages using a publishable key + session token.
3072
- Only produce to topics whose schema is safe for client authorship.
3073
- NEVER allow clients to produce to topics that trigger privileged server-side operations
3074
- (payments, admin actions, state mutations) — those must go through a backend endpoint first.
3075
- import { createClient } from '@ductape/client';
3076
- // Equivalent supported import: import Ductape from '@ductape/client';
3077
- const ductape = createClient({
3078
- publishableKey: 'pk_...',
3079
- env: 'prd',
3080
- product: 'my-product',
3081
- });
3082
- await ductape.connect();
3083
- await ductape.brokers.connect({
3084
- broker: 'user-events',
3085
- session: 'user-session:eyJ...',
3086
- });
3087
- await ductape.brokers.publish({
3088
- topic: 'user-action',
3089
- message: { action: "button-click", screen: "dashboard" },
3090
- session: "user-session:eyJ...", // REQUIRED for client-side produce
3091
- });
3092
-
3093
- SCHEDULED DISPATCH (background job):
3094
- ductape_execute("events.dispatch", [{
3095
- product, env,
3096
- event: "order-events:reminder-due", // fully-qualified "broker-tag:topic-tag" — no separate broker field
3097
- input: { message: { orderId: "123" } },
3098
- retries?: 3,
3099
- session?: "session-tag:jwt",
3100
- schedule?: {
3101
- start_at?: 1735689600000, // Unix ms or ISO string
3102
- cron?: "0 9 * * *", // recurring cron
3103
- every?: 86400000, // recurring interval ms
3104
- limit?: 10, // max repetitions
3105
- endDate?: "2026-12-31",
3106
- tz?: "America/New_York",
3107
- },
3108
- }])
3109
- Returns: { job_id, status: "scheduled"|"queued", scheduled_at, recurring, next_run_at? }
3110
-
3111
- ━━━ STEP 4: CONSUME — WRITTEN IN APPLICATION CODE ━━━
3112
-
3113
- ACK BEHAVIOR (automatic):
3114
- - Callback returns successfully → message is acknowledged (ack)
3115
- - Callback throws → message is tracked as failed; broker nacks/retries per provider behavior
3116
- - After max retries → message moves to dead-letter queue (DLQ)
3117
- There is no manual ack API. Acknowledgement is implicit from callback outcome.
3118
-
3119
- CONSUMER GROUPS (Kafka-specific):
3120
- Consumer groups are set in the broker's envs[].config.groupId (at broker registration time).
3121
- All service instances sharing the same groupId form a consumer group and share partition load.
3122
- To configure: set groupId in the kafka config when creating/updating the broker.
3123
-
3124
- CONCURRENCY:
3125
- Ductape has no per-consumer concurrency setting. Concurrency is determined by:
3126
- - Number of running service instances (horizontal scale)
3127
- - Broker-level partition count (Kafka) or visibility timeout (SQS)
3128
- Run multiple instances of your service to scale consumption.
3129
-
3130
- GENERAL BACKEND (TypeScript/Node.js — not NestJS):
3131
- await ductape.events.consume({
3132
- product: "my-product",
3133
- env: "prd",
3134
- event: "order-events:order-created", // ALWAYS "broker-tag:topic-tag"
3135
- callback: async (message) => {
3136
- // Throw to nack. Return to ack.
3137
- await processOrder(message as { orderId: string; total: number });
3138
- },
3139
- });
3140
-
3141
- NESTJS — use @Events.Consumer decorator (preferred):
3142
- import { Events } from '@ductape/nestjs';
3143
- @Injectable()
3144
- export class OrderConsumerService {
3145
- @Events.Consumer({ event: 'order-events:order-created' })
3146
- async onOrderCreated(message: { orderId: string; total: number }) {
3147
- await this.processOrder(message);
3148
- // return to ack; throw to nack
3149
- }
3150
- private async processOrder(msg: { orderId: string; total: number }) { /* ... */ }
3151
- }
3152
- // DuctapeEventsConsumerService (auto-registered by DuctapeModule) wires this up at startup.
3153
- // No manual onModuleInit needed.
3154
-
3155
- // If you need to override product/env for a specific consumer:
3156
- @Events.Consumer({ event: 'order-events:order-created', product: 'my-product', env: 'prd' })
3157
-
3158
- CLIENT-SIDE: Clients CANNOT consume. Event consumption is always server-side only.
3159
- This is the key distinction between server topics (produce + consume) and client-observable
3160
- topics (produce from client, consume on server). Never set up a consumer in browser code.
3161
-
3162
- DEAD-LETTER QUEUE (DLQ):
3163
- Messages whose callbacks consistently throw are automatically moved to the DLQ.
3164
- Query: ductape_execute("events.messages.getDeadLetters",
3165
- [{ product, env, brokerTag, topicTag?, consumerTag?, limit? }])
3166
- Reprocess: ductape_execute("events.reprocessDLQ",
3167
- [{ product, env, brokerTag, topicTag?, messageIds?, limit? }])
3168
- Replay: ductape_execute("events.replayEvent",
3169
- [{ product, env, eventId, force? }])
3170
-
3171
- ━━━ OBSERVABILITY ━━━
3172
-
3173
- events.messages.query [{ product, env, brokerTag, topicTag?, status?, page?, limit? }]
3174
- events.messages.getStats [{ product, env, brokerTag }]
3175
- events.messages.getDashboard [{ product, env, brokerTag }]
3176
- events.messages.getDeadLetters [{ product, env, brokerTag, topicTag?, consumerTag?, limit? }]
3177
- events.messages.getProducers [{ product, env, brokerTag, topicTag?, page?, limit? }]
3178
- events.messages.getConsumers [{ product, env, brokerTag, topicTag?, page?, limit? }]
3179
- events.replayEvent [{ product, env, eventId, force? }]
3180
- events.reprocessDLQ [{ product, env, brokerTag, topicTag?, messageIds?, limit? }]
3181
- events.checkIdempotency [{ product, env, brokerTag, idempotency_key }]
3182
- `.trim(),
3183
-
3184
- logs: `
3185
- DUCTAPE LOGS
3186
-
3187
- Logs record every SDK operation — actions, features, database queries, notifications, sessions,
3188
- brokers, storage, cache hits, graph, vector, and resilience events.
3189
-
3190
- Fetch logs (runtime — ductape_execute):
3191
- ductape_execute("logs.fetch", [{
3192
- app_id?: string, // query by app (requires component: "app")
3193
- product_id?: string, // query by product (requires component: "product")
3194
- env?: string,
3195
- start_date?: string, // ISO 8601
3196
- end_date?: string,
3197
- page?: number,
3198
- limit?: number,
3199
- }])
3200
- component "app" or "product" is required — there is no default.
3201
- When component="app", tag is only valid with type: "actions".
3202
-
3203
- Log data fields (ILogData):
3204
- process_id, product_tag, env, type (LogEventType), status, data (encrypted if private key set)
3205
- message?, parent_tag?, child_tag?, app_id?, action?, method?,
3206
- cache_tag?, cache_key?, cache_status (true = hit),
3207
- start, end, latency (auto-calculated from start/end),
3208
- session_user_id?, session_id?, session_tag?, visitor_id?,
3209
- ip_address?, language?, data_encrypted?, successful_execution?, failed_execution?
3210
-
3211
- Log event types (LogEventTypes):
3212
- notifications | push | email | sms | callbacks | slack | discord |
3213
- database_actions | actions | functions | storage | webhook | jobs |
3214
- message_broker | producer | consumer | quota | fallback | database_migration |
3215
- feature | feature_step | database | graph | session | vector | cache | frontend
3216
-
3217
- Log statuses: success | fail | waiting | processing
3218
-
3219
- Encryption:
3220
- When workspace_private_key is provided at SDK init, each log entry's data field is
3221
- AES-encrypted client-side before transmission. data_encrypted: true is set on the entry.
3222
- The backend can decrypt when returning logs to the Workbench.
3223
-
3224
- Emit a log manually (SDK only — not via MCP):
3225
- logs.add({ process_id, product_tag, env, type, status, data, message?, ... })
3226
- logs.publish() → flushes all buffered entries to the API in one call
3227
-
3228
- Notes:
3229
- - Every SDK service (storage, broker, feature, graph, notifications, sessions, vector, cache,
3230
- resilience) emits its own logs automatically — manual add/publish is only needed for custom logs.
3231
- - language defaults to "typescript" so the backend knows which SDK emitted the entry.
3232
- - Logs are batched in memory and sent in a single publish() call per operation.
3233
- `.trim(),
3234
-
3235
- client: `
3236
- DUCTAPE CLIENT SDK (@ductape/client)
3237
-
3238
- The client SDK is for frontend applications (React, Vue, Svelte, vanilla JS). All HTTP
3239
- operations are routed through the Ductape proxy using a publishableKey. WebSocket subscriptions
3240
- connect to the proxy's /realtime gateway. Never use this package on the server — use @ductape/sdk
3241
- for server-side code.
3242
-
3243
- INSTALL
3244
- npm install @ductape/client
3245
-
3246
- INITIALIZATION
3247
- import { createClient } from '@ductape/client';
3248
- // or: import Ductape from '@ductape/client';
3249
-
3250
- const ductape = createClient({
3251
- publishableKey: 'pk_live_...', // from Workbench → Settings → Publishable Keys
3252
- baseUrl: 'https://your-proxy.example.com', // your deployed proxy URL
3253
- product: 'my-product', // default product tag (can be overridden per call)
3254
- env: 'prd', // default environment slug
3255
- });
3256
-
3257
- When publishableKey is set:
3258
- - HTTP calls go to baseUrl/proxy/v1/sdk-proxy/execute
3259
- - WebSocket connects to wss://your-proxy.example.com/realtime?token=pk_live_...
3260
- - No server secret is exposed to the browser
3261
-
3262
- Use accessKey only for server-side or trusted environments — never in a browser bundle.
3263
- Override wsUrl to point to a custom WebSocket endpoint.
3264
-
3265
- REAL-TIME CONNECTION
3266
- await ductape.connect(); // opens WebSocket; required before any .subscribe() call
3267
- ductape.disconnect(); // closes WebSocket and clears all subscriptions
3268
- ductape.isConnected // boolean
3269
- ductape.connectionState // 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
3270
- ductape.onConnectionChange(cb) // listen to state changes; returns unsubscribe fn
3271
-
3272
- SERVICES
3273
- ductape.databases DatabaseService — CRUD + real-time query subscriptions
3274
- ductape.features FeatureService — execute features, subscribe to execution status
3275
- ductape.agents AgentService — run AI agents, subscribe to output stream
3276
- ductape.vectors VectorService — vector upsert / search
3277
- ductape.graphs GraphService — graph queries + subscriptions
3278
- ductape.brokers BrokerService — publish / subscribe to message brokers
3279
- ductape.sessions SessionService — verify, refresh, revoke sessions
3280
- ductape.resilience ResilienceService — subscribe to healthcheck status
3281
- ductape.storage StorageService — upload / download files
3282
- ductape.warehouse WarehouseService — cross-store federated queries
3283
- ductape.cache CacheService — key-value cache get / set / clear
3284
- ductape.api ApiService — run app actions with OAuth credential support
3285
- ductape.notifications NotificationsService — send push / email / SMS / callback
3286
- ductape.analytics AnalyticsService — track pageviews, clicks, custom events
3287
- ductape.workflows (deprecated alias → features)
3288
- ductape.actions (deprecated alias → api)
3289
-
3290
- DATABASE SERVICE
3291
- await ductape.databases.connect({ database: 'core-db' });
3292
- const { data } = await ductape.databases.query({ entity: 'users', where: { active: true } });
3293
- await ductape.databases.insert({ entity: 'users', data: { name: 'Alice', email: 'a@b.com' } });
3294
- await ductape.databases.update({ entity: 'users', where: { id: 1 }, data: { name: 'Bob' } });
3295
- await ductape.databases.delete({ entity: 'users', where: { id: 1 } });
3296
- await ductape.databases.upsert({ entity: 'users', data: { email: 'a@b.com' }, conflict: ['email'] });
3297
- const total = await ductape.databases.count({ entity: 'users', where: { active: true } });
3298
- await ductape.databases.raw({ query: 'SELECT * FROM users WHERE id = $1', params: [1] });
3299
- await ductape.databases.transaction(async (tx) => {
3300
- await tx.insert({ entity: 'orders', data: { ... } });
3301
- await tx.update({ entity: 'inventory', where: { ... }, data: { ... } });
3302
- });
3303
- await ductape.databases.disconnect();
3304
-
3305
- Real-time subscription (ductape.connect() must be called first):
3306
- const sub = ductape.databases.subscribe(
3307
- { entity: 'orders', where: { status: 'pending' } },
3308
- (rows) => console.log('live update', rows),
3309
- );
3310
- sub.unsubscribe(); // stop receiving updates
3311
-
3312
- FEATURE SERVICE
3313
- const { executionId } = await ductape.features.execute({ feature: 'onboard-user', input: { userId: 'u_1' } });
3314
- const status = await ductape.features.status({ feature: 'onboard-user', executionId });
3315
- await ductape.features.cancel({ feature: 'onboard-user', executionId, reason: 'user request' });
3316
- await ductape.features.signal({ feature: 'onboard-user', executionId, signal: 'payment-confirmed' });
3317
- const history = await ductape.features.history({ feature: 'onboard-user', executionId });
3318
- const { executions } = await ductape.features.list({ status: 'running', limit: 20 });
3319
-
3320
- Real-time status subscription:
3321
- const sub = ductape.features.subscribe(
3322
- { feature: 'onboard-user', executionId },
3323
- (events) => {
3324
- const ev = events[0]; // { executionId, feature, status, currentStep, output, error }
3325
- console.log(ev.status, ev.currentStep);
3326
- if (ev.status === 'completed' || ev.status === 'failed') sub.unsubscribe();
3327
- },
3328
- );
3329
-
3330
- AGENT SERVICE
3331
- const result = await ductape.agents.run({ agent: 'support-bot', input: { message: 'Help!' } });
3332
- const sub = ductape.agents.subscribe(
3333
- { agent: 'support-bot', executionId: result.executionId },
3334
- (events) => console.log('chunk', events[0]),
3335
- );
3336
- sub.unsubscribe();
3337
-
3338
- BROKER SERVICE
3339
- await ductape.brokers.connect({ broker: 'notifications-broker', session: 'user-session:eyJ...' });
3340
- await ductape.brokers.publish({ topic: 'chat.message', message: { text: 'Hello', userId: 'u_1' } });
3341
- const sub = ductape.brokers.subscribe(
3342
- { topic: 'chat.message' },
3343
- (msgs) => {
3344
- const m = msgs[0]; // { topic, message, headers?, key?, timestamp, offset?, partition? }
3345
- console.log('new message', m.message);
3346
- },
3347
- );
3348
- sub.unsubscribe();
3349
- await ductape.brokers.disconnect();
3350
-
3351
- Passing session scopes delivery to a specific end-user so the server applies session-level
3352
- authorization. Without session, messages for the whole product+env are delivered.
3353
-
3354
- GRAPH SERVICE
3355
- const nodes = await ductape.graphs.findNodes({ labels: ['User'], where: { active: true } });
3356
- const sub = ductape.graphs.subscribe({ labels: ['Order'] }, (nodes) => console.log(nodes));
3357
- sub.unsubscribe();
3358
-
3359
- RESILIENCE SERVICE (health subscriptions)
3360
- const sub = ductape.resilience.subscribe(
3361
- { tag: 'payment-health' },
3362
- (events) => console.log('health change', events[0]),
3363
- );
3364
- sub.unsubscribe();
3365
-
3366
- SESSIONS SERVICE
3367
- const { valid } = await ductape.sessions.verify({ token: 'user-session:eyJ...' });
3368
- await ductape.sessions.revoke({ token: 'user-session:eyJ...' });
3369
- const { token } = await ductape.sessions.refresh({ refreshToken: '...' });
3370
-
3371
- ANALYTICS SERVICE
3372
- ductape.analytics.identify('player-session:eyJ...'); // full Ductape session token
3373
- await ductape.analytics.pageview({ path: '/dashboard', title: document.title });
3374
- await ductape.analytics.track({
3375
- event: 'button_clicked',
3376
- properties: { button: 'sign-up' },
3377
- });
3378
- await ductape.analytics.flush();
3379
- ductape.analytics.clearSession(); // logout/account switch
3380
- See ductape_docs({ topic: "frontend-analytics" }) before enabling auto-capture.
3381
-
3382
- FRAMEWORK-SPECIFIC PACKAGES
3383
- For React and Vue projects, use the dedicated packages instead of managing the client manually:
3384
- @ductape/react — DuctapeProvider + hooks (useDatabaseQuery, useFeatureSubscription, etc.)
3385
- See: ductape_docs({ topic: "react" })
3386
- @ductape/vue — createDuctape() plugin + composables (useDatabaseQuery, useFeatureSubscription, etc.)
3387
- See: ductape_docs({ topic: "vue" })
3388
-
3389
- Use @ductape/client directly only for vanilla JS, Svelte, Angular, or custom integrations.
3390
-
3391
- IMPORTANT
3392
- - Call ductape.connect() before any .subscribe() — it throws if not connected.
3393
- - Always clean up (sub.unsubscribe()) in component teardown to prevent memory leaks.
3394
- - The client auto-resubscribes after a WebSocket reconnect — no manual retry needed.
3395
- - publishableKey is safe in browser bundles; it grants only proxy-authorized operations.
3396
- - For SSR, skip connect() on the server; call it only client-side (useEffect / onMounted).
3397
- - The client SDK does NOT expose secrets, access keys, or workspace admin operations.
3398
- - Pass session (format session_tag:jwt) to broker connect/subscribe for per-user scoping.
3399
- `.trim(),
3400
-
3401
- react: `
3402
- DUCTAPE REACT (@ductape/react)
3403
-
3404
- React hooks and context provider for Ductape. Wraps @ductape/client.
3405
- Requires react >= 17.
3406
-
3407
- INSTALL
3408
- npm install @ductape/react
3409
-
3410
- SETUP — wrap your app root with DuctapeProvider
3411
- import { DuctapeProvider } from '@ductape/react';
3412
-
3413
- function App() {
3414
- return (
3415
- <DuctapeProvider
3416
- config={{
3417
- publishableKey: import.meta.env.VITE_PUBLISHABLE_KEY,
3418
- baseUrl: import.meta.env.VITE_PROXY_URL,
3419
- product: 'my-product',
3420
- env: 'prd',
3421
- }}
3422
- autoConnect={false}
3423
- >
3424
- <MyApp />
3425
- </DuctapeProvider>
3426
- );
3427
- }
3428
-
3429
- DuctapeProvider props:
3430
- config IDuctapeClientConfig — publishableKey (or accessKey), baseUrl, product, env
3431
- autoConnect boolean (default false) — connect WebSocket automatically on mount
3432
- onConnected () => void
3433
- onDisconnected () => void
3434
- onError (error: Error) => void
3435
- onConnectionChange (state: ConnectionState) => void
3436
-
3437
- CORE HOOKS
3438
- useDuctape() → { client, isConnected, connectionState, connect, disconnect, isReady }
3439
- useDuctapeContext() → same; throws if called outside DuctapeProvider
3440
-
3441
- DATABASE HOOKS
3442
- useDatabase(database)
3443
- → { isConnected, isConnecting, error, connect, disconnect }
3444
- Call connect() in a useEffect to open the database session.
3445
-
3446
- useDatabaseQuery(key, queryOptions, hookOptions?)
3447
- → { data: IQueryResult<T>, isLoading, error, refetch }
3448
- key: string | string[] — deduplication key
3449
- queryOptions: { table, where?, select?, limit?, offset?, sort? }
3450
-
3451
- useDatabaseInsert(hookOptions?) → { mutate({ table, data }), isLoading, error, data }
3452
- useDatabaseUpdate(hookOptions?) → { mutate({ table, where, data }), isLoading, error, data }
3453
- useDatabaseDelete(hookOptions?) → { mutate({ table, where }), isLoading, error, data }
3454
-
3455
- useDatabaseSubscription(subscribeOptions, hookOptions?)
3456
- → { data: T[] | null, isSubscribed, error, unsubscribe, resubscribe }
3457
- subscribeOptions: { table, where?, select? }
3458
- Requires autoConnect: true (or manual connect()) and databases.connect() before subscribing.
3459
-
3460
- FEATURE HOOKS
3461
- useFeatureExecute(hookOptions?)
3462
- → { mutate({ feature, input?, product?, env? }), isLoading, error, data }
3463
- useFeatureStatus(statusInput, hookOptions?)
3464
- → { data: FeatureStatus, isLoading, error, refetch }
3465
- statusInput: { feature, executionId, product?, env? }
3466
- useFeatureSubscription(subscribeOptions, hookOptions?)
3467
- → { data: FeatureStatusEvent[], isSubscribed, error, unsubscribe, resubscribe }
3468
- subscribeOptions: { feature, executionId, product?, env? }
3469
- useFeatureSignal(hookOptions?) → { mutate({ feature, executionId, signal, data? }), isLoading }
3470
- useFeatureCancel(hookOptions?) → { mutate({ feature, executionId, reason? }), isLoading }
3471
-
3472
- AGENT HOOKS
3473
- useAgentRun(hookOptions?)
3474
- → { mutate({ tag, input?, sessionId? }), isLoading, error, data: IAgentExecutionResult }
3475
- useAgentStream(tag, input?, hookOptions?)
3476
- → { events, content, isStreaming, isComplete, error, start, stop, reset }
3477
- content: accumulated string from all stream text events
3478
- useAgentStatus(statusInput, hookOptions?) → { data, isLoading, error, refetch }
3479
- useAgentSignal(hookOptions?) → { mutate, isLoading, error }
3480
-
3481
- BROKER HOOKS
3482
- useBroker()
3483
- → { isConnected, isConnecting, error, connect, disconnect }
3484
- connect({ broker, session?, product?, env? }) forwards the complete options object to
3485
- @ductape/client. Pass session for player-scoped authorization.
3486
- useBrokerPublish(hookOptions?)
3487
- → { mutate({ topic, message, headers?, key? }), isLoading, error, data }
3488
- useBrokerSubscription(subscribeOptions, hookOptions?)
3489
- → { data: BrokerMessage[], isSubscribed, error, unsubscribe, resubscribe }
3490
- subscribeOptions: { broker?, topic, group?, session?, product?, env? }
3491
-
3492
- GRAPH HOOKS
3493
- useGraph(graphTag) → { isConnected, isConnecting, error, connect, disconnect }
3494
- useCreateNode(hookOptions?) → { mutate({ labels, properties }), isLoading, error, data }
3495
- useUpdateNode(hookOptions?) → { mutate({ id, properties }), isLoading, error, data }
3496
- useDeleteNode(hookOptions?) → { mutate({ id, detach? }), isLoading, error, data }
3497
- useQueryNodes(key, options, hookOptions?) → { data, isLoading, error }
3498
- useCreateRelationship(hookOptions?) → { mutate, isLoading, error, data }
3499
- useDeleteRelationship(hookOptions?) → { mutate, isLoading, error }
3500
- useQueryRelationships(key, options, hookOptions?) → { data, isLoading, error }
3501
- useTraverse(key, options, hookOptions?) → { data, isLoading, error }
3502
- useShortestPath(key, options, hookOptions?) → { data, isLoading, error }
3503
- useGraphSubscription(subscribeOptions, hookOptions?)
3504
- → { data, isSubscribed, error, unsubscribe, resubscribe }
3505
-
3506
- VECTOR HOOKS
3507
- useVector(vectorTag) → { isConnected, isConnecting, error, connect, disconnect }
3508
- useVectorQuery(key, options, hookOptions?) → { data, isLoading, error }
3509
- useVectorSearch(key, options, hookOptions?) → { data, isLoading, error }
3510
- useVectorUpsert(hookOptions?) → { mutate({ id, values, metadata? }), isLoading, error, data }
3511
- useVectorDelete(hookOptions?) → { mutate({ ids }), isLoading, error }
3512
- useVectorFetch(key, options, hookOptions?) → { data: VectorFetchResult, isLoading, error }
3513
- useVectorStats(key, options, hookOptions?) → { data: VectorStatsResult, isLoading, error }
3514
- useVectorNamespaces(key, options, hookOptions?) → { data, isLoading, error }
3515
-
3516
- STORAGE HOOKS
3517
- useUpload(hookOptions?)
3518
- → { upload({ storage, fileName, buffer, mimeType? }), isLoading, progress, error, data }
3519
- progress: 0–100 number
3520
- useDownload(hookOptions?) → { mutate, isLoading, error, data }
3521
- useStorageDelete(hookOptions?) → { mutate, isLoading, error }
3522
- useListFiles(key, options, hookOptions?) → { data, isLoading, error }
3523
- useSignedUrl(key, options, hookOptions?) → { data: string, isLoading, error }
3524
-
3525
- SESSION HOOKS
3526
- useSessionStart(hookOptions?) → { mutate, isLoading, error, data: { token, refreshToken } }
3527
- useSessionVerify(hookOptions?) → { mutate, isLoading, error, data }
3528
- useSessionRefresh(hookOptions?) → { mutate, isLoading, error, data }
3529
- useSessionRevoke(hookOptions?) → { mutate, isLoading, error }
3530
- useSessionRevokeAll(hookOptions?) → { mutate, isLoading, error }
3531
- useSessionList(key, options, hookOptions?) → { data, isLoading, error }
3532
- useSessionAutoRefresh(config)
3533
- → { token, isRefreshing }; auto-refreshes before expiry; no manual call needed
3534
-
3535
- CACHE HOOKS
3536
- useCacheGet(key, options, hookOptions?) → { data, isLoading, error }
3537
- useCacheSet(hookOptions?) → { mutate({ key, value, expiry? }), isLoading, error }
3538
- useCacheDelete(hookOptions?) → { mutate({ key }), isLoading, error }
3539
- useCacheExists(key, options, hookOptions?) → { data: boolean, isLoading, error }
3540
- useCacheGetMany / useCacheSetMany / useCacheDeleteMany
3541
-
3542
- RESILIENCE HOOKS
3543
- useHealthStatus(key, options, hookOptions?)
3544
- → { data: HealthStatusResult, isLoading, error, refetch }
3545
- useHealthSubscription(subscribeOptions, hookOptions?)
3546
- → { data: HealthChangeEvent[], isSubscribed, error, unsubscribe, resubscribe }
3547
- useQuotaCheck(hookOptions?) → { mutate, isLoading, error, data: QuotaCheckResult }
3548
- useQuotaStatus(key, options, hookOptions?) → { data, isLoading, error }
3549
- useQuotaSubscription(subscribeOptions, hookOptions?) → { data, isSubscribed, error }
3550
-
3551
- WAREHOUSE HOOKS
3552
- useWarehouseQuery / useWarehouseSelect / useWarehouseInsert
3553
- useWarehouseUpdate / useWarehouseDelete / useWarehouseUpsert / useWarehouseTransaction
3554
-
3555
- ACTIONS HOOKS
3556
- useActions(app) → { isConnected, connect, disconnect }
3557
- useActionRun(hookOptions?) → { mutate, isLoading, error, data }
3558
-
3559
- ANALYTICS HOOK
3560
- useAnalytics() → { pageview, track, identify, visitorId, enableAutoCapture, flush }
3561
- The currently installed hook does NOT return clearSession or disableAutoCapture.
3562
- Use useDuctape().client.analytics.clearSession()/disableAutoCapture(), or verify a newer installed
3563
- package export before generating those hook calls. See ductape_docs({ topic: "frontend-analytics" }).
3564
-
3565
- hookOptions pattern (applies to all hooks):
3566
- enabled? boolean — false skips auto-fetch/subscribe
3567
- onSuccess? (data) => void
3568
- onError? (error: Error) => void
3569
- Subscriptions also accept: onData, onSubscribe, onUnsubscribe
3570
-
3571
- EXAMPLE
3572
- // main.tsx
3573
- import { DuctapeProvider } from '@ductape/react';
3574
- root.render(
3575
- <DuctapeProvider config={{ publishableKey: 'pk_...', baseUrl: '...', product: 'my-app', env: 'prd' }} autoConnect>
3576
- <App />
3577
- </DuctapeProvider>
3578
- );
3579
-
3580
- // UsersList.tsx
3581
- import { useDatabaseQuery } from '@ductape/react';
3582
- function UsersList() {
3583
- const { data, isLoading } = useDatabaseQuery('users', { table: 'users', limit: 20 });
3584
- if (isLoading) return <p>Loading...</p>;
3585
- return <ul>{data?.rows.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
3586
- }
3587
-
3588
- // FeatureTracker.tsx
3589
- import { useFeatureSubscription } from '@ductape/react';
3590
- function FeatureTracker({ executionId }) {
3591
- const { data: events } = useFeatureSubscription(
3592
- { feature: 'onboard-user', executionId },
3593
- { onData: (evs) => console.log('status:', evs[0].status) },
3594
- );
3595
- return <p>{events?.[0]?.status ?? 'waiting'}</p>;
3596
- }
3597
-
3598
- DEPRECATED ALIASES
3599
- useWorkflowExecute → useFeatureExecute
3600
- useWorkflowStatus → useFeatureStatus
3601
- useWorkflowSubscription → useFeatureSubscription
3602
- useWorkflowSignal → useFeatureSignal
3603
- useWorkflowCancel → useFeatureCancel
3604
- `.trim(),
3605
-
3606
- vue: `
3607
- DUCTAPE VUE (@ductape/vue)
3608
-
3609
- Vue 3 composables and plugin for Ductape. Wraps @ductape/client.
3610
- Requires vue >= 3.
3611
-
3612
- INSTALL
3613
- npm install @ductape/vue
3614
-
3615
- SETUP — install the plugin at app root
3616
- // main.ts
3617
- import { createApp } from 'vue';
3618
- import { createDuctape } from '@ductape/vue';
3619
- import App from './App.vue';
3620
-
3621
- const app = createApp(App);
3622
-
3623
- app.use(createDuctape({
3624
- publishableKey: import.meta.env.VITE_PUBLISHABLE_KEY,
3625
- baseUrl: import.meta.env.VITE_PROXY_URL,
3626
- product: 'my-product',
3627
- env: 'prd',
3628
- autoConnect: false, // true = connect WebSocket when plugin installs
3629
- }));
3630
-
3631
- app.mount('#app');
3632
-
3633
- Plugin options (extends IDuctapeClientConfig):
3634
- publishableKey / accessKey — auth
3635
- baseUrl — proxy URL
3636
- product, env — defaults for all composables
3637
- autoConnect — boolean (default false)
3638
-
3639
- The client is provided via inject(DUCTAPE_INJECTION_KEY) and is available on
3640
- this.$ductape in Options API components.
3641
-
3642
- CORE COMPOSABLE
3643
- useDuctape()
3644
- → { client, isReady, isConnected: Ref<boolean>, connectionState: Ref<ConnectionState>,
3645
- error: Ref<Error|null>, connect, disconnect }
3646
-
3647
- DATABASE COMPOSABLES
3648
- useDatabase(database)
3649
- → { isConnected, isConnecting, error, connect, disconnect } — all Refs
3650
- Call connect() in onMounted to open the database session.
3651
-
3652
- useDatabaseQuery(key, queryOptions, composableOptions?)
3653
- → { data: Ref<IQueryResult<T>|null>, isLoading: Ref<boolean>, error: Ref<Error|null>, refetch }
3654
-
3655
- useDatabaseInsert(composableOptions?) → { mutate({ table, data }), isLoading, error, data }
3656
- useDatabaseUpdate(composableOptions?) → { mutate({ table, where, data }), isLoading, error, data }
3657
- useDatabaseDelete(composableOptions?) → { mutate({ table, where }), isLoading, error, data }
3658
-
3659
- useDatabaseSubscription(subscribeOptions, composableOptions?)
3660
- → { data: Ref<T[]|null>, isSubscribed: Ref<boolean>, error, unsubscribe, resubscribe }
3661
- subscribeOptions: { table, where?, select? }
3662
-
3663
- FEATURE COMPOSABLES
3664
- useFeatureExecute(composableOptions?)
3665
- → { mutate({ feature, input?, product?, env? }), isLoading, error, data }
3666
- useFeatureStatus(statusInput, composableOptions?)
3667
- → { data: Ref<FeatureStatus|null>, isLoading, error, refetch }
3668
- useFeatureSubscription(subscribeOptions, composableOptions?)
3669
- → { data: Ref<FeatureStatusEvent[]|null>, isSubscribed, error, unsubscribe, resubscribe }
3670
- subscribeOptions: { feature, executionId, product?, env? }
3671
- useFeatureSignal(composableOptions?) → { mutate, isLoading, error }
3672
- useFeatureCancel(composableOptions?) → { mutate, isLoading, error }
3673
-
3674
- AGENT COMPOSABLES
3675
- useAgentRun(composableOptions?)
3676
- → { mutate({ tag, input?, sessionId? }), isLoading, error, data }
3677
- useAgentStream(tag, input?, composableOptions?)
3678
- → { events, content, isStreaming, isComplete, error, start, stop, reset }
3679
- content: Ref<string> accumulated from stream text events
3680
- useAgentStatus(statusInput, composableOptions?) → { data, isLoading, error, refetch }
3681
- useAgentSignal(composableOptions?) → { mutate, isLoading, error }
3682
-
3683
- BROKER COMPOSABLES
3684
- useBroker(broker, options?)
3685
- → { isConnected, isConnecting, error, connect, disconnect }
3686
- useBrokerPublish(composableOptions?)
3687
- → { mutate({ topic, message, headers?, key? }), isLoading, error, data }
3688
- useBrokerSubscription(subscribeOptions, composableOptions?)
3689
- → { data: Ref<BrokerMessage[]|null>, isSubscribed, error, unsubscribe, resubscribe }
3690
- subscribeOptions: { topic, group? }
3691
-
3692
- GRAPH COMPOSABLES
3693
- useGraph(graphTag) → { isConnected, isConnecting, error, connect, disconnect }
3694
- useCreateNode / useUpdateNode / useDeleteNode / useQueryNodes
3695
- useCreateRelationship / useDeleteRelationship / useQueryRelationships
3696
- useTraverse / useShortestPath
3697
- useGraphSubscription(subscribeOptions, composableOptions?)
3698
- → { data, isSubscribed, error, unsubscribe, resubscribe }
3699
-
3700
- VECTOR COMPOSABLES
3701
- useVector(vectorTag) → { isConnected, isConnecting, error, connect, disconnect }
3702
- useVectorQuery / useVectorSearch / useVectorUpsert / useVectorDelete
3703
- useVectorFetch / useVectorStats / useVectorNamespaces
3704
-
3705
- STORAGE COMPOSABLES
3706
- useUpload(composableOptions?)
3707
- → { upload({ storage, fileName, buffer, mimeType? }), isLoading, progress: Ref<number>, error, data }
3708
- useDownload / useStorageDelete / useListFiles / useSignedUrl
3709
-
3710
- SESSION COMPOSABLES
3711
- useSessionStart / useSessionVerify / useSessionRefresh
3712
- useSessionRevoke / useSessionRevokeAll / useSessionList
3713
- useSessionAutoRefresh(config) → { token: Ref<string|null>, isRefreshing: Ref<boolean> }
3714
-
3715
- CACHE COMPOSABLES
3716
- useCacheGet / useCacheSet / useCacheDelete / useCacheExists
3717
- useCacheGetMany / useCacheSetMany / useCacheDeleteMany
3718
-
3719
- RESILIENCE COMPOSABLES
3720
- useHealthStatus / useHealthSubscription
3721
- useQuotaRun / useQuotaCheck / useQuotaStatus / useQuotaSubscription
3722
- useFallbackRun
3723
-
3724
- WAREHOUSE COMPOSABLES
3725
- useWarehouseQuery / useWarehouseSelect / useWarehouseInsert
3726
- useWarehouseUpdate / useWarehouseDelete / useWarehouseUpsert / useWarehouseTransaction
3727
-
3728
- ACTIONS COMPOSABLES
3729
- useActions(app) → { isConnected, connect, disconnect }
3730
- useActionRun(composableOptions?) → { mutate, isLoading, error, data }
3731
-
3732
- ANALYTICS COMPOSABLE
3733
- useAnalytics() → { pageview, track, identify, visitorId, enableAutoCapture, flush }
3734
- The currently installed composable does NOT return clearSession or disableAutoCapture.
3735
- Use useDuctape().client.analytics.clearSession()/disableAutoCapture(), or verify a newer installed
3736
- package export before generating those composable calls. See ductape_docs({ topic: "frontend-analytics" }).
3737
-
3738
- composableOptions pattern (applies to all composables):
3739
- enabled? boolean — false skips auto-fetch/subscribe
3740
- onSuccess? (data) => void
3741
- onError? (error: Error) => void
3742
- Subscriptions also accept: onData, onSubscribe, onUnsubscribe
3743
-
3744
- EXAMPLE
3745
- <!-- UsersList.vue -->
3746
- <script setup>
3747
- import { useDatabaseQuery } from '@ductape/vue';
3748
- const { data, isLoading, error } = useDatabaseQuery('users', { table: 'users', limit: 20 });
3749
- </script>
3750
- <template>
3751
- <div v-if="isLoading">Loading...</div>
3752
- <div v-else-if="error">{{ error.message }}</div>
3753
- <ul v-else>
3754
- <li v-for="user in data?.rows" :key="user.id">{{ user.name }}</li>
3755
- </ul>
3756
- </template>
3757
-
3758
- <!-- FeatureTracker.vue -->
3759
- <script setup>
3760
- import { useFeatureSubscription } from '@ductape/vue';
3761
- const props = defineProps(['executionId']);
3762
- const { data: events, isSubscribed } = useFeatureSubscription(
3763
- { feature: 'onboard-user', executionId: props.executionId },
3764
- );
3765
- </script>
3766
- <template>
3767
- <p>{{ events?.[0]?.status ?? 'waiting' }}</p>
3768
- </template>
3769
-
3770
- DEPRECATED ALIASES
3771
- useWorkflowExecute → useFeatureExecute
3772
- useWorkflowStatus → useFeatureStatus
3773
- useWorkflowSubscription → useFeatureSubscription
3774
- useWorkflowSignal → useFeatureSignal
3775
- useWorkflowCancel → useFeatureCancel
3776
- `.trim(),
3777
- };
3778
-
3779
- const docsHandler = async (args: { topic: string }) => {
3780
- const key = args.topic.toLowerCase().trim();
3781
- const doc = DOCS[key];
3782
- if (!doc) {
3783
- const available = Object.keys(DOCS).join(', ');
3784
- return {
3785
- content: [{ type: 'text' as const, text: `Unknown topic "${args.topic}". Available topics: ${available}` }],
3786
- };
3787
- }
3788
- return { content: [{ type: 'text' as const, text: doc }] };
3789
- };
3790
-
3791
- const cliInputSchema = z.object({
3792
- command: z.string().describe(
3793
- 'The ductape CLI command to run, without the leading "ductape" word. ' +
3794
- 'Examples: "products list", "products create --name \\"My Product\\" --tag my-product", ' +
3795
- '"apps list", "apps create -f app.json", "resources storage list", ' +
3796
- '"events topics create -f topic.json", "events topics list --tag broker-tag", ' +
3797
- '"cloud connections list", "link --product my-product --env dev".\n\n' +
3798
- 'Use this tool for administrative operations: creating or updating products, apps, ' +
3799
- 'resources (databases, storage, caches…), event broker topics, cloud connections, secrets, ' +
3800
- 'and for apply/migrate workflows.\n\n' +
3801
- 'Note: environments, app actions, quotas, fallbacks, and jobs are configured in the ' +
3802
- 'Workbench UI. Features also have no CLI creation command: define them in application code ' +
3803
- 'with features.define so application boot/runtime registration makes them available.\n\n' +
3804
- 'The CLI uses the user\'s local logged-in session (ductape login) — no key is required.',
3805
- ),
3806
- });
3807
-
3808
- async function loadMcpSdk(): Promise<{
3809
- McpServer: new (info: { name: string; version: string }) => any;
3810
- StdioServerTransport: new () => any;
3811
- }> {
3812
- try {
3813
- const [{ McpServer }, { StdioServerTransport }] = await Promise.all([
3814
- import('@modelcontextprotocol/sdk/server/mcp.js'),
3815
- import('@modelcontextprotocol/sdk/server/stdio.js'),
3816
- ]);
3817
- if (McpServer && StdioServerTransport) {
3818
- return { McpServer, StdioServerTransport };
3819
- }
3820
- } catch {
3821
- // fall through to v2 alpha package
3822
- }
3823
-
3824
- try {
3825
- const sdk = await import('@modelcontextprotocol/server');
3826
- if (sdk.McpServer && sdk.StdioServerTransport) {
3827
- return { McpServer: sdk.McpServer, StdioServerTransport: sdk.StdioServerTransport };
3828
- }
3829
- } catch {
3830
- // fall through
3831
- }
3832
-
3833
- console.error(
3834
- 'Failed to load MCP SDK. Install: npm install @modelcontextprotocol/sdk zod\n' +
3835
- 'Or v2 alpha: npm install @modelcontextprotocol/server zod @cfworker/json-schema',
3836
- );
3837
- process.exit(1);
3838
- throw new Error('MCP SDK not available');
3839
- }
3840
-
3841
- function handleCliFlags(): boolean {
3842
- const arg = process.argv[2];
3843
- if (arg === '--version' || arg === '-v') {
3844
- const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
3845
- process.stdout.write(version + '\n');
3846
- return true;
3847
- }
3848
- if (arg === '--help' || arg === '-h') {
3849
- process.stdout.write(
3850
- 'ductape-mcp — Ductape MCP server\n\n' +
3851
- 'Usage: ductape-mcp\n\n' +
3852
- 'The server communicates over stdio and is meant to be spawned by an MCP\n' +
3853
- 'client (Cursor, Claude Desktop, Claude Code). Run it directly only to\n' +
3854
- 'verify the binary is working.\n\n' +
3855
- 'Options:\n' +
3856
- ' --version, -v Print version and exit\n' +
3857
- ' --help, -h Print this message and exit\n\n' +
3858
- 'Environment:\n' +
3859
- ' DUCTAPE_PUBLISHABLE_KEY Your workspace publishable key. Set this in\n' +
3860
- ' the MCP client env config to avoid passing\n' +
3861
- ' publishable_key on every tool call.\n',
3862
- );
3863
- return true;
3864
- }
3865
- return false;
3866
- }
3867
-
3868
- async function main() {
3869
- const { McpServer, StdioServerTransport } = await loadMcpSdk();
3870
- const server = new McpServer({ name: 'ductape-mcp', version: '0.1.0' });
3871
- const transport = new StdioServerTransport();
3872
-
3873
- const cliHandler = async (args: { command: string }) => {
3874
- const cli = checkCli();
3875
- if (!cli.available) {
3876
- return {
3877
- content: [{
3878
- type: 'text',
3879
- text: [
3880
- 'The Ductape CLI is not installed or not in PATH.',
3881
- '',
3882
- 'Install it with:',
3883
- ' npm install --global @ductape/cli',
3884
- '',
3885
- 'Then log in:',
3886
- ' ductape login',
3887
- '',
3888
- 'After logging in, retry this operation.',
3889
- ].join('\n'),
3890
- }],
3891
- isError: true,
3892
- };
3893
- }
3894
-
3895
- const firstWord = args.command.trim().split(/\s+/)[0];
3896
- const isAuthCommand = firstWord === 'login' || firstWord === 'logout';
3897
-
3898
- if (!isAuthCommand) {
3899
- // Cache successful authentication, but re-check a missing/expired session on every call.
3900
- // The user may complete `ductape login` in another terminal while this MCP process remains
3901
- // alive; caching "none" would otherwise make the MCP blind to the newly written session.
3902
- if (authState === 'unknown' || authState === 'none') {
3903
- checkLoginState();
3904
- }
3905
- if (authState === 'none') {
3906
- const wsFlag = process.env.DUCTAPE_WORKSPACE ? ` --workspace "${process.env.DUCTAPE_WORKSPACE}"` : '';
3907
- return {
3908
- content: [{
3909
- type: 'text',
3910
- text: [
3911
- 'The Ductape CLI has no remotely valid authenticated session.',
3912
- '',
3913
- 'Administrative reads and writes must remain on ductape_cli; do not fall back to ductape_execute.',
3914
- '',
3915
- 'Ask the user to re-authenticate in a trusted local terminal:',
3916
- ` ductape login${wsFlag}`,
3917
- 'or:',
3918
- ` ductape login --browser google${wsFlag}`,
3919
- ` ductape login --browser github${wsFlag}`,
3920
- '',
3921
- 'Never ask the user to paste their password, OAuth callback token, or stored CLI credential into the agent.',
3922
- 'After login succeeds, retry the original ductape_cli command.',
3923
- ].join('\n'),
3924
- }],
3925
- isError: true,
3926
- };
3927
- }
3928
- // Sync to the configured workspace once per process (best-effort)
3929
- if (!workspaceSynced) {
3930
- syncWorkspace();
3931
- }
3932
- }
3933
-
3934
- const result = runCli(args.command);
3935
-
3936
- // Update cached state after auth commands
3937
- if (firstWord === 'login' && result.success) {
3938
- authState = 'ok';
3939
- workspaceSynced = false; // re-sync workspace after fresh login
3940
- }
3941
- if (firstWord === 'logout' && result.success) {
3942
- authState = 'none';
3943
- workspaceSynced = false;
3944
- }
3945
-
3946
- return {
3947
- content: [{ type: 'text', text: result.output || '(no output)' }],
3948
- ...(result.success ? {} : { isError: true }),
3949
- };
3950
- };
3951
-
3952
- const executeHandler = async (args: { publishable_key?: string; module: SDKModule; method: string; params: unknown[] }) => {
3953
- try {
3954
- const runtimeMutationMethods: Partial<Record<SDKModule, Set<string>>> = {
3955
- databases: new Set(['insert', 'update', 'delete', 'upsert']),
3956
- graph: new Set(['insert', 'update', 'delete']),
3957
- vector: new Set(['insert', 'upsert', 'upsertOne', 'delete']),
3958
- sessions: new Set(['revoke']),
3959
- };
3960
- const isRuntimeDataMutation = runtimeMutationMethods[args.module]?.has(args.method) === true;
3961
- const isAdministrativeMutation =
3962
- (/^migration\./.test(args.method) ||
3963
- /^schema\.(create|drop|add|remove|update)/.test(args.method) ||
3964
- /(^|\.)(create|update|delete|configure|add|remove|revoke)$/.test(args.method)) &&
3965
- !isRuntimeDataMutation;
3966
- if (isAdministrativeMutation) {
3967
- throw new Error(
3968
- `Administrative operation "${args.module}.${args.method}" is blocked in ductape_execute. ` +
3969
- 'Use ductape_cli when that resource is supported by the CLI; otherwise configure it in Workbench. ' +
3970
- 'The runtime proxy uses a publishable key and cannot administer platform assets.',
3971
- );
3972
- }
3973
- const key = args.publishable_key || process.env.DUCTAPE_PUBLISHABLE_KEY;
3974
- if (!key) {
3975
- throw new Error(
3976
- 'Runtime authentication is missing. Set DUCTAPE_PUBLISHABLE_KEY in the MCP server environment ' +
3977
- 'or pass publishable_key to ductape_execute. The MCP server never accepts access keys.',
3978
- );
3979
- }
3980
- // The TS SDK uses ductape.events.* for broker operations; the backend proxy uses messageBrokers.
3981
- const proxyModule: SDKModule = args.module === 'events' ? 'messageBrokers' : args.module;
3982
-
3983
- // Normalize events.dispatch params: event must be fully-qualified "broker:topic".
3984
- // If an agent passes both broker and event="broker:topic", strip broker and de-duplicate.
3985
- let params = args.params;
3986
- if ((args.module === 'events' || args.module === 'messageBrokers') && args.method === 'dispatch') {
3987
- params = params.map((p) => {
3988
- if (p && typeof p === 'object' && !Array.isArray(p)) {
3989
- const obj = p as Record<string, unknown>;
3990
- const broker = typeof obj['broker'] === 'string' ? obj['broker'] : '';
3991
- const event = typeof obj['event'] === 'string' ? obj['event'] : '';
3992
- if (broker && event) {
3993
- // Strip broker prefix if event is already "broker:..." to avoid "broker:broker:topic"
3994
- const normalized = event.startsWith(broker + ':') ? event : `${broker}:${event}`;
3995
- const { broker: _removed, ...rest } = obj;
3996
- return { ...rest, event: normalized };
3997
- }
3998
- }
3999
- return p;
4000
- });
4001
- }
4002
-
4003
- let result: unknown;
4004
- try {
4005
- result = await executeViaProxy(key, proxyModule, args.method, params);
4006
- } catch (error) {
4007
- const message = error instanceof Error ? error.message : String(error);
4008
- if (/authentication failed|unauthorized|invalid.*key/i.test(message)) {
4009
- throw new Error(
4010
- 'The DUCTAPE_PUBLISHABLE_KEY was rejected by the runtime proxy. ' +
4011
- 'Use a publishable key for the same workspace/product. The MCP server never accepts or forwards access keys.',
4012
- );
4013
- }
4014
- throw error;
4015
- }
4016
- return { content: [{ type: 'text', text: JSON.stringify(result ?? null, null, 2) }] };
4017
- } catch (err) {
4018
- let message = err instanceof Error ? err.message : String(err);
4019
- const isAuthFailed = /authentication failed/i.test(message);
4020
- const isDbWrite = args.module === 'databases' && /^(insert|update|delete|upsert)$/.test(args.method);
4021
- if (isAuthFailed && isDbWrite) {
4022
- message += '\n\nThe publishable key does not have write access for this database operation. ' +
4023
- 'Enable it in Workbench → Tokens → Publishable Key. ' +
4024
- 'If write access cannot be granted to the publishable key, perform this operation server-side using a full Ductape SDK instance initialized with an access key.';
4025
- }
4026
- return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
4027
- }
4028
- };
4029
-
4030
- const payloadGenerateHandler = async (
4031
- args: z.infer<typeof payloadGenerateInputSchema>,
4032
- ) => {
4033
- try {
4034
- const key = args.publishable_key || process.env.DUCTAPE_PUBLISHABLE_KEY;
4035
- if (!key) {
4036
- throw new Error('Not authenticated. Set DUCTAPE_PUBLISHABLE_KEY in your MCP server env config, or pass publishable_key on every tool call.');
4037
- }
4038
- const includeSession = args.include_session ?? args.execution_context !== 'system';
4039
- const result = addSessionAwarenessMetadata(
4040
- await generateExecutablePayload({ ...args, include_session: includeSession, publishable_key: key }),
4041
- args,
4042
- );
4043
-
4044
- let text = JSON.stringify(result ?? null, null, 2);
4045
-
4046
- if (args.operation_family === 'database') {
4047
- const meta = (result as any)?.meta ?? {};
4048
- const fields = meta.schema_context?.database?.fields ?? {};
4049
- const warnings: string[] = Array.isArray(meta.schema_warnings) ? meta.schema_warnings : [];
4050
- const noSchema = Object.keys(fields).length === 0 || warnings.some((w: string) => w.includes('No table schema'));
4051
- if (noSchema) {
4052
- const dbTag = (args.targets as any)?.database_tag ?? '<database_tag>';
4053
- const envSlug = (args as any).env_slug ?? '<env_slug>';
4054
- text += `\n\n--- SCHEMA NOT FOUND ---\nNo table schema is available for database "${dbTag}" in environment "${envSlug}".\nTell the user:\n Run: ductape db schema push --db ${dbTag} --env ${envSlug}\n This reads the live schema from the database and syncs it to the Ductape server so the AI can provide accurate field guidance.\n Once complete, retry the operation.\n------------------------`;
4055
- }
4056
- }
4057
-
4058
- return { content: [{ type: 'text', text }] };
4059
- } catch (err) {
4060
- const message = err instanceof Error ? err.message : String(err);
4061
- return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
4062
- }
4063
- };
4064
-
4065
- const snippetGenerateHandler = async (
4066
- args: z.infer<typeof snippetGenerateInputSchema>,
4067
- ) => {
4068
- try {
4069
- const key = args.publishable_key || process.env.DUCTAPE_PUBLISHABLE_KEY;
4070
- if (!key) {
4071
- throw new Error('Not authenticated. Set DUCTAPE_PUBLISHABLE_KEY in your MCP server env config, or pass publishable_key on every tool call.');
4072
- }
4073
- ensureSupportedSnippetOperation(args.operation_family, args.method);
4074
- const includeSession = args.include_session ?? args.execution_context !== 'system';
4075
- const generated = addSessionAwarenessMetadata(
4076
- await generateExecutablePayload({ ...args, include_session: includeSession, publishable_key: key }),
4077
- args,
4078
- );
4079
- const payload = (generated as any)?.payload ?? {};
4080
- const snippet = buildSnippet(args.language, payload, args.operation_family, args.method);
4081
- return {
4082
- content: [
4083
- {
4084
- type: 'text',
4085
- text: JSON.stringify(
4086
- {
4087
- payload: generated,
4088
- snippet,
4089
- },
4090
- null,
4091
- 2,
4092
- ),
4093
- },
4094
- ],
4095
- };
4096
- } catch (err) {
4097
- const message = err instanceof Error ? err.message : String(err);
4098
- return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
4099
- }
4100
- };
4101
-
4102
- const schemaHandler = async (args: { module?: 'app' | 'product' }) => {
4103
- try {
4104
- const data = await getAssetSchemas(args.module);
4105
- return { content: [{ type: 'text', text: JSON.stringify(data ?? null, null, 2) }] };
4106
- } catch (err) {
4107
- const message = err instanceof Error ? err.message : String(err);
4108
- return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
4109
- }
4110
- };
4111
-
4112
- if (typeof server.registerTool === 'function') {
4113
- server.registerTool(
4114
- 'ductape_execute',
4115
- {
4116
- title: 'Ductape SDK Execute',
4117
- description:
4118
- 'Execute a Ductape SDK RUNTIME operation via the backend proxy (publishable key).\n\n' +
4119
- 'ONLY use this tool for runtime operations: run, dispatch, execute, start, send, produce, query, insert, update, delete.\n' +
4120
- 'Do NOT use this for creating or updating any asset (product, app, action, environment, database, storage, etc.) — ' +
4121
- 'those require an access key. Use ductape_cli for all creation and update operations.\n\n' +
4122
- 'Two-step rule for runtime operations:\n' +
4123
- ' 1. Call ductape_generate_payload first to get the canonical payload template.\n' +
4124
- ' This reveals the exact "input" field keys — they are product/env/operation-specific.\n' +
4125
- ' 2. Fill in the values from the template, then call ductape_execute.',
4126
- inputSchema: executeInputSchema,
4127
- },
4128
- executeHandler,
4129
- );
4130
- server.registerTool(
4131
- 'ductape_generate_payload',
4132
- {
4133
- title: 'Ductape Payload Generator',
4134
- description:
4135
- 'Generate the canonical input payload template for a runtime SDK operation.\n\n' +
4136
- 'CALL THIS BEFORE ductape_execute when you need to run, dispatch, execute, start, send, produce, ' +
4137
- 'or otherwise trigger any Ductape operation that takes an "input" field.\n\n' +
4138
- 'The "input" field shape is defined by how each action/feature/session/quota/etc. was configured ' +
4139
- 'in the product — it cannot be inferred from the SDK schema alone. This tool returns the exact ' +
4140
- 'field names, types, and placeholder values for that specific operation in that specific environment.\n\n' +
4141
- 'Returns: { payload: { product, env, input: { fieldName: placeholder, ... }, session?, cache? }, meta: { ... } }',
4142
- inputSchema: payloadGenerateInputSchema,
4143
- },
4144
- payloadGenerateHandler,
4145
- );
4146
- server.registerTool(
4147
- 'ductape_generate_snippet',
4148
- {
4149
- title: 'Ductape Snippet Generator',
4150
- description: 'Generate canonical payload and ready TypeScript/Python snippet for engineers',
4151
- inputSchema: snippetGenerateInputSchema,
4152
- },
4153
- snippetGenerateHandler,
4154
- );
4155
- server.registerTool(
4156
- 'ductape_schema',
4157
- {
4158
- title: 'Ductape Asset Schema',
4159
- description:
4160
- 'Returns the full field manifest for Ductape asset creation/update methods, ' +
4161
- 'derived live from the SDK Joi validators. Includes field types, required flags, ' +
4162
- 'enum values, nested structures, and all enum constants. ' +
4163
- 'Pass module="app" or module="product" to scope the result.\n\n' +
4164
- 'ALWAYS call this before constructing a file for "resources <type> create" or any cloud ' +
4165
- 'import/provision operation — field shapes are not guessable from context.\n\n' +
4166
- 'Conditional fields: some fields are returned as oneOf (an array of variant shapes). ' +
4167
- 'For example, storage.create envs[].config is oneOf [awsConfig, gcpConfig, azureConfig] — ' +
4168
- 'pick the variant whose fields match the envs[].type value (aws/gcp/azure). ' +
4169
- 'Fields inside oneOf variants are context-dependent and should all be treated as optional ' +
4170
- 'unless the chosen variant explicitly marks them required.',
4171
- inputSchema: schemaInputSchema,
4172
- },
4173
- schemaHandler,
4174
- );
4175
- server.registerTool(
4176
- 'ductape_docs',
4177
- {
4178
- title: 'Ductape Feature Docs',
4179
- description:
4180
- 'Look up detailed documentation for a specific Ductape SDK feature.\n\n' +
4181
- 'Call this when you need guidance on how a feature works before using it — ' +
4182
- 'especially for features that require configuration decisions (tier, isolation level, ' +
4183
- 'index strategy, operation types) that should be confirmed with the user first.\n\n' +
4184
- 'Available topics: transactions, presave, triggers, aggregations, migrations, indexes, performance, actions, ' +
4185
- 'graphs, storage, cloud, vector, warehouse, secrets, apps, products, sessions, caches, ' +
4186
- 'notifications, resilience, features, events, logs, frontend, frontend-analytics, client, react, vue',
4187
- inputSchema: docsInputSchema,
4188
- },
4189
- docsHandler,
4190
- );
4191
- server.registerTool(
4192
- 'ductape_cli',
4193
- {
4194
- title: 'Ductape CLI',
4195
- description:
4196
- 'Run a Ductape CLI command for administrative operations.\n\n' +
4197
- 'USE THIS TOOL for any operation that creates or modifies platform configuration:\n' +
4198
- ' - Creating or updating products (products create/update) and apps (apps create/update)\n' +
4199
- ' - Importing an app from a Postman v2.1 or OpenAPI 3.0 file: "apps import <file> -t postman|openapi"\n' +
4200
- ' - Managing resources via "resources <type> <verb>" (databases, storage, caches…)\n' +
4201
- ' ALWAYS call ductape_schema(module="product") first to get the exact field shape before\n' +
4202
- ' constructing a file. Storage envs[].config is conditional on envs[].type (aws/gcp/azure)\n' +
4203
- ' and returned as oneOf — pick the variant matching the type.\n' +
4204
- ' REQUIRED: before building any resource file, run\n' +
4205
- ' ductape_cli("products environments list <product_tag> --json")\n' +
4206
- ' and include an envs entry for EVERY environment returned. Omitting any env causes a\n' +
4207
- ' validation error. If the user has not supplied connection details for every env, ask\n' +
4208
- ' them explicitly. Confirm per-env before reusing the same resource across environments.\n' +
4209
- ' - Three distinct storage registration flows — choose the right one:\n' +
4210
- ' 1. Manual credentials (bucket already exists, you supply keys directly):\n' +
4211
- ' resources storage create -f storage.json\n' +
4212
- ' File shape: { name, tag, envs: [{ slug, type: "aws"|"gcp"|"azure", config: { bucketName, ...creds } }] }\n' +
4213
- ' For cloud-linked envs set config.cloud to the connection tag and omit raw credentials.\n' +
4214
- ' 2. Import existing cloud buckets via cloud connections (no provisioning):\n' +
4215
- ' cloud resources import-persist-all -f import-all.json --json\n' +
4216
- ' File is a JSON ARRAY — one entry per env, all with the same product and component tag.\n' +
4217
- ' Each entry: cloud (connection tag), service (e.g. "gcs","s3"), type ("storage"),\n' +
4218
- ' product, component (new tag), env, resource (bucket name/identifier).\n' +
4219
- ' Example (two envs): [{"cloud":"gcp-snd","service":"gcs","type":"storage",\n' +
4220
- ' "product":"my-product","component":"assets","env":"snd","resource":"snd-bucket"},\n' +
4221
- ' {"cloud":"gcp-prd","service":"gcs","type":"storage","product":"my-product",\n' +
4222
- ' "component":"assets","env":"prd","resource":"prd-bucket"}].\n' +
4223
- ' All envs are imported in parallel, drafts merged, then the asset is created once.\n' +
4224
- ' Use import-persist (single-env form) only when the product has exactly one environment.\n' +
4225
- ' If import-persist-all fails, the error reports all create/update failures — do not retry blindly.\n' +
4226
- ' 3. Provision brand-new buckets AND register them (see provision-persist-all docs below).\n' +
4227
- ' - Managing cloud connections and cloud-linked resources\n' +
4228
- ' - Discovering cloud tiers: "cloud tiers --provider <provider> --type <database|storage> [--db-type <type>] --json"\n' +
4229
- ' IMPORTANT: `cloud tiers` has no `list` verb. Use only a tier returned for the selected provider/type.\n' +
4230
- ' - Provisioning and persisting cloud resources:\n' +
4231
- ' For a product with multiple environments always use provision-persist-all (not provision-persist):\n' +
4232
- ' cloud resources provision-persist-all -f file.json --json\n' +
4233
- ' File is a JSON ARRAY — one entry per env, all with the same product and component tag.\n' +
4234
- ' Each entry requires: cloud (connection tag), service (provider service), type (Ductape component type),\n' +
4235
- ' product (product tag), component (new component tag), env (existing environment slug).\n' +
4236
- ' Provider parameters such as region, location, bucketName, dbName, tier, and waitForReady\n' +
4237
- ' may be supplied per entry in the file.\n' +
4238
- ' Example (two envs): [{"cloud":"gcp-snd","service":"gcs","type":"storage",\n' +
4239
- ' "product":"product-tag","component":"assets","env":"snd","location":"us-central1"},\n' +
4240
- ' {"cloud":"gcp-prd","service":"gcs","type":"storage","product":"product-tag",\n' +
4241
- ' "component":"assets","env":"prd","location":"us-central1"}].\n' +
4242
- ' Use provision-persist (single-env) only when the product has exactly one environment.\n' +
4243
- ' Use provision alone when you only need the provider resource without registering an asset.\n' +
4244
- ' Tier discovery does not by itself prove that a connection supports provisioning that service:\n' +
4245
- ' if provisioning reports an unsupported provider/service pair, stop and report it.\n' +
4246
- ' Never infer an environment, provider service, region, tier, or cost. List/verify each before provisioning.\n' +
4247
- ' - Known provisioning limitations: MongoDB Atlas (M0 free tier) and Neo4j Aura cannot be provisioned via\n' +
4248
- ' Ductape — they are import-only. Only AWS, GCP, and Azure managed services support provision-persist.\n' +
4249
- ' Supported service/component pairs include: rds→database, aurora→database, gcs→storage, s3→storage,\n' +
4250
- ' azure-blob→storage, cloud-sql→database, neptune→graph, opensearch→vector. Attempting an unsupported pair\n' +
4251
- ' will return an error; do not retry with a different tier — report the limitation to the user.\n' +
4252
- ' - Atlas (MongoDB Atlas) import flow — service identifier is "atlas-cluster" (required, not optional):\n' +
4253
- ' IMPORTANT: dbName is mandatory for every MongoDB env — see "MONGODB CLOUD CONNECTION RULE" in the\n' +
4254
- ' databases module above. The SDK enforces this at create time; omitting it throws a validation error.\n' +
4255
- ' Step 1 — discover the cluster name:\n' +
4256
- ' ductape_cli("cloud resources list -f /tmp/atlas-list.json --json")\n' +
4257
- ' File: {"cloud": "<atlas-connection-tag>", "service": "atlas-cluster"}\n' +
4258
- ' Returns a list of clusters; note the "name" field (this is your resource identifier).\n' +
4259
- ' Step 2 — check if the database component already exists:\n' +
4260
- ' ductape_cli("resources databases list <product_tag> --json")\n' +
4261
- ' If a component already uses the same Atlas cluster, do NOT re-import — instead update it:\n' +
4262
- ' use ductape_cli resource update when supported; otherwise update it in Workbench\n' +
4263
- ' Add or change the dbName in the env\'s connection_url to switch databases on the same cluster.\n' +
4264
- ' Step 3 — import (only if no existing component uses this cluster):\n' +
4265
- ' Use import-persist-all with one entry per product env. Required fields per entry:\n' +
4266
- ' cloud (connection tag), service: "atlas-cluster", type: "databases",\n' +
4267
- ' product, component (new tag), env, resource (cluster name from Step 1),\n' +
4268
- ' dbName (the MongoDB database name to connect to — required for Atlas).\n' +
4269
- ' Example: [{"cloud":"atlas-tag","service":"atlas-cluster","type":"databases",\n' +
4270
- ' "product":"my-product","component":"core-db","env":"snd","resource":"Cluster0","dbName":"myapp_snd"},\n' +
4271
- ' {"cloud":"atlas-tag","service":"atlas-cluster","type":"databases",\n' +
4272
- ' "product":"my-product","component":"core-db","env":"prd","resource":"Cluster0","dbName":"myapp_prd"}]\n' +
4273
- ' - Message broker / event broker import:\n' +
4274
- ' CLI accepts these aliases for the messageBrokers module: events, event, broker, brokers, message-brokers.\n' +
4275
- ' List existing brokers: ductape_cli("resources events list --json")\n' +
4276
- ' GCP Pub/Sub service identifier is "pubsub". AWS SQS is "sqs". Azure Service Bus is "servicebus".\n' +
4277
- ' Message brokers are import-only (no provision-persist). Import flow is the same as storage.\n' +
4278
- ' type field = "messageBrokers" (not "messagebrokers" or "events").\n' +
4279
- ' After importing, create topics first with ductape_cli("events topics create -f topic.json") — SQS requires explicit topic creation with queueUrls. For other providers, topics auto-register on first produce but should still be created explicitly before any consumer subscribes.\n' +
4280
- ' - Listing workspaces, products, focused product components, secrets\n' +
4281
- ' Prefer "products components list --tag <tag> --json" for compact inventory; use\n' +
4282
- ' "products components get --tag <tag> --type notifications|events --json" for focused detail.\n' +
4283
- ' - Managing notification components and message templates through "resources notifications" and "notifications messages"\n' +
4284
- ' - Linking a project folder: "link --product <tag> --env <slug>"\n' +
4285
- ' - Syncing sessions/notifications/events: "apply" or "apply sessions" etc.\n' +
4286
- ' - Running database migrations: "db migrate", "db schema generate"\n\n' +
4287
- 'NOTE: Environments, app actions, auths, quotas, fallbacks, and jobs are configured ' +
4288
- 'in the Workbench UI. Features have no CLI creation command because definitions are ' +
4289
- 'code-first through features.define and registered by the application runtime.\n\n' +
4290
- 'DO NOT use ductape_execute for admin operations — it uses a publishable key which only ' +
4291
- 'covers runtime operations. Administrative operations will fail with "Authentication failed".\n\n' +
4292
- 'The CLI uses the user\'s local logged-in session (ductape login). ' +
4293
- 'If the CLI is not installed, this tool will return install instructions automatically.',
4294
- inputSchema: cliInputSchema,
4295
- },
4296
- cliHandler,
4297
- );
4298
- } else if (typeof server.tool === 'function') {
4299
- server.tool('ductape_execute', executeInputSchema.shape, executeHandler);
4300
- server.tool('ductape_generate_payload', payloadGenerateInputSchema.shape, payloadGenerateHandler);
4301
- server.tool('ductape_generate_snippet', snippetGenerateInputSchema.shape, snippetGenerateHandler);
4302
- server.tool('ductape_schema', schemaInputSchema.shape, schemaHandler);
4303
- server.tool('ductape_docs', docsInputSchema.shape, docsHandler);
4304
- server.tool('ductape_cli', cliInputSchema.shape, cliHandler);
4305
- } else {
4306
- console.error('MCP server does not expose .registerTool() or .tool()');
4307
- process.exit(1);
4308
- }
4309
-
4310
- await server.connect(transport);
4311
- }
4312
-
4313
- if (!handleCliFlags()) {
4314
- main().catch((err) => {
4315
- console.error(err);
4316
- process.exit(1);
4317
- });
4318
- }