@mugwork/mug 0.1.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/LICENSE +21 -0
- package/README.md +251 -0
- package/dist/explorer.js +3 -0
- package/dist/packages/email-template/src/email-template.d.ts +18 -0
- package/dist/packages/email-template/src/email-template.js +74 -0
- package/dist/packages/email-template/src/index.d.ts +1 -0
- package/dist/packages/email-template/src/index.js +1 -0
- package/dist/packages/surface-renderer/src/form-renderer.d.ts +117 -0
- package/dist/packages/surface-renderer/src/form-renderer.js +719 -0
- package/dist/packages/surface-renderer/src/index.d.ts +4 -0
- package/dist/packages/surface-renderer/src/index.js +2 -0
- package/dist/packages/surface-renderer/src/portal-renderer.d.ts +177 -0
- package/dist/packages/surface-renderer/src/portal-renderer.js +1089 -0
- package/dist/packages/surface-renderer/src/workspace-home.d.ts +46 -0
- package/dist/packages/surface-renderer/src/workspace-home.js +345 -0
- package/dist/runtime/agent-types.d.ts +48 -0
- package/dist/runtime/agent-types.js +3 -0
- package/dist/runtime/ai-router.d.ts +32 -0
- package/dist/runtime/ai-router.js +112 -0
- package/dist/runtime/app.d.ts +6 -0
- package/dist/runtime/app.js +399 -0
- package/dist/runtime/chunker.d.ts +6 -0
- package/dist/runtime/chunker.js +30 -0
- package/dist/runtime/context.d.ts +115 -0
- package/dist/runtime/context.js +440 -0
- package/dist/runtime/do/workspace-database.d.ts +10 -0
- package/dist/runtime/do/workspace-database.js +199 -0
- package/dist/runtime/form-types.d.ts +143 -0
- package/dist/runtime/form-types.js +1 -0
- package/dist/runtime/runtime.d.ts +9 -0
- package/dist/runtime/runtime.js +7 -0
- package/dist/runtime/source-types.d.ts +15 -0
- package/dist/runtime/source-types.js +1 -0
- package/dist/runtime/source.d.ts +70 -0
- package/dist/runtime/source.js +21 -0
- package/dist/runtime/sync-runtime.d.ts +10 -0
- package/dist/runtime/sync-runtime.js +185 -0
- package/dist/runtime/types.d.ts +21 -0
- package/dist/runtime/types.js +1 -0
- package/dist/runtime/workflow-entrypoint.d.ts +31 -0
- package/dist/runtime/workflow-entrypoint.js +1297 -0
- package/dist/runtime/workflow.d.ts +285 -0
- package/dist/runtime/workflow.js +1008 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +44116 -0
- package/dist/src/commands/ai-gateway-route.d.ts +24 -0
- package/dist/src/commands/ai-gateway-route.js +192 -0
- package/dist/src/commands/auth.d.ts +1 -0
- package/dist/src/commands/auth.js +42 -0
- package/dist/src/commands/billing.d.ts +6 -0
- package/dist/src/commands/billing.js +76 -0
- package/dist/src/commands/brain.d.ts +1 -0
- package/dist/src/commands/brain.js +194 -0
- package/dist/src/commands/demo.d.ts +12 -0
- package/dist/src/commands/demo.js +147 -0
- package/dist/src/commands/deploy.d.ts +1 -0
- package/dist/src/commands/deploy.js +1052 -0
- package/dist/src/commands/dev.d.ts +14 -0
- package/dist/src/commands/dev.js +2818 -0
- package/dist/src/commands/form.d.ts +8 -0
- package/dist/src/commands/form.js +396 -0
- package/dist/src/commands/init.d.ts +1 -0
- package/dist/src/commands/init.js +139 -0
- package/dist/src/commands/issue.d.ts +7 -0
- package/dist/src/commands/issue.js +191 -0
- package/dist/src/commands/login.d.ts +9 -0
- package/dist/src/commands/login.js +163 -0
- package/dist/src/commands/logs.d.ts +8 -0
- package/dist/src/commands/logs.js +113 -0
- package/dist/src/commands/portal.d.ts +2 -0
- package/dist/src/commands/portal.js +111 -0
- package/dist/src/commands/pull.d.ts +3 -0
- package/dist/src/commands/pull.js +184 -0
- package/dist/src/commands/push.d.ts +4 -0
- package/dist/src/commands/push.js +183 -0
- package/dist/src/commands/run.d.ts +6 -0
- package/dist/src/commands/run.js +91 -0
- package/dist/src/commands/secret.d.ts +7 -0
- package/dist/src/commands/secret.js +105 -0
- package/dist/src/commands/shutdown.d.ts +1 -0
- package/dist/src/commands/shutdown.js +46 -0
- package/dist/src/commands/sql.d.ts +8 -0
- package/dist/src/commands/sql.js +142 -0
- package/dist/src/commands/status.d.ts +5 -0
- package/dist/src/commands/status.js +39 -0
- package/dist/src/commands/sync.d.ts +7 -0
- package/dist/src/commands/sync.js +991 -0
- package/dist/src/commands/usage.d.ts +6 -0
- package/dist/src/commands/usage.js +78 -0
- package/dist/src/commands/webhooks.d.ts +1 -0
- package/dist/src/commands/webhooks.js +102 -0
- package/dist/src/commands/workspace.d.ts +23 -0
- package/dist/src/commands/workspace.js +590 -0
- package/dist/src/connector-migration.d.ts +20 -0
- package/dist/src/connector-migration.js +43 -0
- package/dist/src/connector-parser.d.ts +14 -0
- package/dist/src/connector-parser.js +94 -0
- package/dist/src/connector-service/discover.d.ts +37 -0
- package/dist/src/connector-service/discover.js +79 -0
- package/dist/src/connector-service/gather.d.ts +22 -0
- package/dist/src/connector-service/gather.js +89 -0
- package/dist/src/connector-service/init.d.ts +14 -0
- package/dist/src/connector-service/init.js +109 -0
- package/dist/src/connector-service/scaffold.d.ts +17 -0
- package/dist/src/connector-service/scaffold.js +194 -0
- package/dist/src/connector-service/spec-storage.d.ts +8 -0
- package/dist/src/connector-service/spec-storage.js +48 -0
- package/dist/src/connector-service/types.d.ts +57 -0
- package/dist/src/connector-service/types.js +2 -0
- package/dist/src/connector-service/verify.d.ts +24 -0
- package/dist/src/connector-service/verify.js +575 -0
- package/dist/src/email-template.d.ts +2 -0
- package/dist/src/email-template.js +1 -0
- package/dist/src/manifest.d.ts +31 -0
- package/dist/src/manifest.js +25 -0
- package/dist/src/mug-icon.d.ts +1 -0
- package/dist/src/mug-icon.js +12 -0
- package/dist/src/slack-manifest.d.ts +119 -0
- package/dist/src/slack-manifest.js +163 -0
- package/dist/src/source-migration.d.ts +20 -0
- package/dist/src/source-migration.js +43 -0
- package/dist/src/surface-renderer.d.ts +5 -0
- package/dist/src/surface-renderer.js +3 -0
- package/dist/src/templates.d.ts +3 -0
- package/dist/src/templates.js +48 -0
- package/dist/src/version-check.d.ts +1 -0
- package/dist/src/version-check.js +28 -0
- package/dist/src/workflow-parser.d.ts +95 -0
- package/dist/src/workflow-parser.js +526 -0
- package/dist/worker/src/agent-types.d.ts +27 -0
- package/dist/worker/src/agent-types.js +3 -0
- package/dist/worker/src/source-types.d.ts +14 -0
- package/dist/worker/src/source-types.js +1 -0
- package/package.json +90 -0
- package/src/data/model-capabilities.json +171 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Auto-generated by scripts/build-templates.ts — do not edit
|
|
2
|
+
export const templates = {
|
|
3
|
+
"app.ts": "import { Hono } from \"hono\";\nimport { WorkspaceContext } from \"./context.js\";\nimport { getSource, allSources, type SourceContext } from \"./source.js\";\nimport { getWorkflow, allWorkflows, runWorkflow, queryLogs, findTriggeredWorkflows } from \"./workflow.js\";\nimport { chunkText } from \"./chunker.js\";\nimport type { Env } from \"./types.js\";\n\nconst app = new Hono<{ Bindings: Env }>();\n\napp.get(\"/health\", (c) => {\n return c.json({\n status: \"ok\",\n workspace: c.env.WORKSPACE_ID ?? \"local\",\n ts: new Date().toISOString(),\n });\n});\n\napp.post(\"/sync/:source\", async (c) => {\n const sourceName = c.req.param(\"source\");\n const def = getSource(sourceName);\n if (!def) {\n return c.json({ error: `Source \"${sourceName}\" not found` }, 404);\n }\n return runSync(c.env, def.name);\n});\n\napp.post(\"/sync\", async (c) => {\n const sources = allSources();\n const results: Record<string, unknown> = {};\n for (const def of sources) {\n const res = await runSync(c.env, def.name);\n results[def.name] = await res.json();\n }\n return c.json(results);\n});\n\napp.post(\"/api/query\", async (c) => {\n const { database, sql, params } = (await c.req.json()) as {\n database: string;\n sql: string;\n params?: (string | number | null)[];\n };\n const ctx = new WorkspaceContext(c.env);\n const rows = await ctx.query(database, sql, params);\n return c.json({ rows });\n});\n\napp.post(\"/api/seed\", async (c) => {\n const { database, tables } = (await c.req.json()) as {\n database: string;\n tables: Record<string, { ddl: string; rows: Record<string, unknown>[] }>;\n };\n const ctx = new WorkspaceContext(c.env);\n const stub = ctx.getDatabaseStub(database);\n const res = await stub.fetch(new Request(\"https://do/seed\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ tables }),\n }));\n return c.json(await res.json());\n});\n\napp.post(\"/api/export\", async (c) => {\n const { database } = (await c.req.json()) as { database: string };\n const ctx = new WorkspaceContext(c.env);\n const stub = ctx.getDatabaseStub(database);\n const res = await stub.fetch(new Request(\"https://do/export\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({}),\n }));\n return c.json(await res.json());\n});\n\napp.post(\"/api/ai\", async (c) => {\n const body = (await c.req.json()) as {\n model: string;\n prompt: string;\n system?: string;\n maxTokens?: number;\n };\n const ctx = new WorkspaceContext(c.env);\n const result = await ctx.ai(body.model, body);\n return c.json(result);\n});\n\napp.get(\"/workflows\", (c) => {\n return c.json({ workflows: allWorkflows().map((w) => w.name) });\n});\n\napp.post(\"/run/:workflow\", async (c) => {\n const name = c.req.param(\"workflow\");\n const def = getWorkflow(name);\n if (!def) {\n return c.json({ error: `Workflow \"${name}\" not found` }, 404);\n }\n let params: Record<string, unknown> | undefined;\n try {\n const body = await c.req.json();\n if (body && typeof body === \"object\") params = body as Record<string, unknown>;\n } catch { /* no body or not JSON — params stays undefined */ }\n const result = await runWorkflow(name, c.env, params);\n return c.json(result);\n});\n\napp.get(\"/logs\", async (c) => {\n const limit = parseInt(c.req.query(\"limit\") ?? \"10\");\n const logs = await queryLogs(c.env, undefined, limit);\n return c.json(logs);\n});\n\napp.get(\"/logs/:workflow\", async (c) => {\n const name = c.req.param(\"workflow\");\n const limit = parseInt(c.req.query(\"limit\") ?? \"10\");\n const logs = await queryLogs(c.env, name, limit);\n return c.json(logs);\n});\n\napp.post(\"/api/notify\", async (c) => {\n const body = (await c.req.json()) as {\n channel: \"sms\" | \"email\" | \"slack\";\n to: string;\n message: string;\n blocks?: unknown[];\n thread_ts?: string;\n unfurl_links?: boolean;\n unfurl_media?: boolean;\n };\n const ctx = new WorkspaceContext(c.env);\n await ctx.notify[body.channel]({\n to: body.to,\n message: body.message,\n blocks: body.blocks,\n thread_ts: body.thread_ts,\n unfurl_links: body.unfurl_links,\n unfurl_media: body.unfurl_media,\n });\n return c.json({ status: \"sent\", channel: body.channel, to: body.to });\n});\n\napp.post(\"/changesets/:database\", async (c) => {\n const database = c.req.param(\"database\");\n const ctx = new WorkspaceContext(c.env);\n const body = await c.req.json().catch(() => ({})) as { changeset_id?: string; limit?: number };\n try {\n const stub = ctx.getDatabaseStub(database);\n const res = await stub.fetch(new URL(\"/changesets\", \"http://do\").toString(), {\n method: \"POST\",\n body: JSON.stringify(body),\n headers: { \"Content-Type\": \"application/json\" },\n });\n const data = await res.json();\n return c.json(data);\n } catch (e) {\n return c.json({ error: (e as Error).message }, 500);\n }\n});\n\napp.post(\"/rollback/:database\", async (c) => {\n const database = c.req.param(\"database\");\n const ctx = new WorkspaceContext(c.env);\n const body = await c.req.json() as { changeset_id: string };\n try {\n const stub = ctx.getDatabaseStub(database);\n const res = await stub.fetch(new URL(\"/rollback\", \"http://do\").toString(), {\n method: \"POST\",\n body: JSON.stringify(body),\n headers: { \"Content-Type\": \"application/json\" },\n });\n const data = await res.json();\n return c.json(data);\n } catch (e) {\n return c.json({ error: (e as Error).message }, 500);\n }\n});\n\nasync function runSync(env: Env, sourceName: string): Promise<Response> {\n const def = getSource(sourceName);\n if (!def) {\n return Response.json({ error: `Source \"${sourceName}\" not found` }, { status: 404 });\n }\n\n const sources = JSON.parse(env.MUG_SOURCES || \"{}\");\n const src = sources[sourceName];\n\n const sourceCtx: SourceContext = {\n credential: async (name?: string) => {\n if (src?.auth?.value) {\n const resolved = (env as unknown as Record<string, unknown>)[src.auth.value];\n if (typeof resolved === \"string\") return resolved;\n return src.auth.value;\n }\n\n if (env.MUG_DISPATCH && env.MUG_INTERNAL_SECRET) {\n const provider = name ?? sourceName;\n const res = await env.MUG_DISPATCH.fetch(\n `https://mug-dispatch/credential/${env.WORKSPACE_ID}/${provider}`,\n { headers: { \"X-Mug-Internal\": env.MUG_INTERNAL_SECRET } },\n );\n if (res.ok) {\n const data = (await res.json()) as { access_token: string };\n return data.access_token;\n }\n }\n\n throw new Error(`No credentials for \"${sourceName}\"`);\n },\n lastSync: null,\n };\n\n const results: Record<string, unknown> = {};\n const isLocal = !!env.WORKSPACE_DB;\n\n for (const table of def.tables) {\n const rows = await table.fetch(sourceCtx);\n\n let syncRes: Response;\n if (isLocal) {\n const stub = env.WORKSPACE_DB!.get(env.WORKSPACE_DB!.idFromName(def.database));\n syncRes = await stub.fetch(new URL(\"/sync\", \"http://do\").toString(), {\n method: \"POST\",\n body: JSON.stringify({ table: table.name, primaryKey: table.primaryKey, rows }),\n headers: { \"Content-Type\": \"application/json\" },\n });\n } else {\n syncRes = await env.MUG_DATA.fetch(\n `https://mug-data/workspace/${env.WORKSPACE_ID}/db/${def.database}/sync`,\n {\n method: \"POST\",\n body: JSON.stringify({ table: table.name, primaryKey: table.primaryKey, rows }),\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-Mug-Internal\": env.MUG_INTERNAL_SECRET,\n },\n }\n );\n }\n const syncData = await syncRes.json() as { upserted: number; deleted: number; deletedPks?: string[] };\n results[table.name] = syncData;\n\n if (!isLocal && env.MUG_DISPATCH && sourceCtx.lastSync) {\n const opsCount = (syncData.upserted ?? 0) + (syncData.deleted ?? 0);\n if (opsCount > 0) {\n await env.MUG_DISPATCH.fetch(`https://mug-dispatch/meter/${env.WORKSPACE_ID}/increment`, {\n method: \"POST\",\n body: JSON.stringify({ dimension: \"operations\", delta: opsCount, source: `sync:${sourceName}-${table.name}` }),\n headers: { \"Content-Type\": \"application/json\", \"X-Mug-Internal\": env.MUG_INTERNAL_SECRET },\n }).catch(() => {});\n }\n }\n\n try {\n const embedResult = await embedSyncBatch(env, def.database, table.name, table.primaryKey, rows, syncData.deletedPks);\n if (embedResult.vectors > 0 || embedResult.deletedVectors > 0) {\n (results[table.name] as Record<string, unknown>).vectors = embedResult;\n }\n } catch (err) {\n console.error(`[${env.WORKSPACE_ID ?? \"local\"}] Embedding failed for ${table.name}:`, err);\n }\n\n const isInitialSync = !sourceCtx.lastSync;\n const hasInserts = (syncData.upserted ?? 0) > 0;\n const hasDeletes = (syncData.deleted ?? 0) > 0;\n if (hasInserts || hasDeletes) {\n const events: (\"insert\" | \"delete\")[] = [];\n if (hasInserts) events.push(\"insert\");\n if (hasDeletes) events.push(\"delete\");\n for (const event of events) {\n const triggered = findTriggeredWorkflows(sourceName, table.name, event, isInitialSync);\n for (const wf of triggered) {\n const triggerParams = {\n _trigger: { source: sourceName, table: table.name, event, count: event === \"insert\" ? syncData.upserted : syncData.deleted },\n rows: event === \"insert\" ? rows : undefined,\n deletedPks: event === \"delete\" ? syncData.deletedPks : undefined,\n };\n if (isLocal) {\n runWorkflow(wf.name, env, triggerParams).catch((err) => {\n console.error(`[trigger] ${wf.name} failed: ${(err as Error).message}`);\n });\n } else if (env.MUG_DISPATCH) {\n env.MUG_DISPATCH.fetch(`https://mug-dispatch/trigger/${env.WORKSPACE_ID}/create-workflow`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", \"X-Mug-Internal\": env.MUG_INTERNAL_SECRET },\n body: JSON.stringify({ workflow: wf.name, ...triggerParams }),\n }).catch(() => {});\n }\n }\n }\n }\n }\n\n if (!isLocal && env.MUG_DISPATCH && env.MUG_DATA) {\n try {\n const countRes = await env.MUG_DATA.fetch(\n `https://mug-data/workspace/${env.WORKSPACE_ID}/db/${def.database}/row-count`,\n { headers: { \"X-Mug-Internal\": env.MUG_INTERNAL_SECRET } },\n );\n if (countRes.ok) {\n const { totalRows } = await countRes.json() as { totalRows: number };\n await env.MUG_DISPATCH.fetch(`https://mug-dispatch/meter/${env.WORKSPACE_ID}/set`, {\n method: \"POST\",\n body: JSON.stringify({ dimension: \"records\", value: totalRows, source: `sync:${sourceName}` }),\n headers: { \"Content-Type\": \"application/json\", \"X-Mug-Internal\": env.MUG_INTERNAL_SECRET },\n }).catch(() => {});\n }\n } catch {}\n }\n\n return Response.json({ source: sourceName, database: def.database, tables: results });\n}\n\nasync function embedSyncBatch(\n env: Env,\n database: string,\n tableName: string,\n primaryKey: string,\n rows: Record<string, unknown>[],\n deletedPks?: string[],\n): Promise<{ embedded: number; vectors: number; deletedVectors: number }> {\n if (!env.VECTORIZE || env.WORKSPACE_DB) {\n return { embedded: 0, vectors: 0, deletedVectors: 0 };\n }\n\n const ctx = new WorkspaceContext(env);\n const skipCols = new Set([primaryKey, \"_mug_synced_at\", \"_mug_deleted_at\"]);\n\n const rowTexts: { pk: string; text: string }[] = [];\n for (const row of rows) {\n const pk = String(row[primaryKey]);\n const parts: string[] = [];\n for (const [key, value] of Object.entries(row)) {\n if (skipCols.has(key) || typeof value !== \"string\" || !value.trim()) continue;\n parts.push(value);\n }\n if (parts.length > 0) {\n rowTexts.push({ pk, text: parts.join(\" \") });\n }\n }\n\n let vectorCount = 0;\n\n if (rowTexts.length > 0) {\n const allChunks: { pk: string; chunkIndex: number; totalChunks: number; text: string }[] = [];\n for (const { pk, text } of rowTexts) {\n for (const chunk of chunkText(text)) {\n allChunks.push({ pk, chunkIndex: chunk.index, totalChunks: chunk.total, text: chunk.text });\n }\n }\n\n const vectors = await ctx.embed(allChunks.map((c) => c.text));\n\n const records: VectorizeVector[] = allChunks.map((c, i) => ({\n id: c.totalChunks === 1 ? `${tableName}:${c.pk}` : `${tableName}:${c.pk}:chunk_${c.chunkIndex}`,\n values: vectors[i],\n metadata: {\n database,\n table: tableName,\n pk_column: primaryKey,\n primary_key: c.pk,\n chunk_index: c.chunkIndex,\n total_chunks: c.totalChunks,\n synced_at: new Date().toISOString(),\n },\n }));\n\n for (let i = 0; i < records.length; i += 1000) {\n await env.VECTORIZE!.upsert(records.slice(i, i + 1000));\n }\n vectorCount = records.length;\n }\n\n let deletedVectors = 0;\n if (deletedPks?.length) {\n const idsToDelete = deletedPks.map((pk) => `${tableName}:${pk}`);\n await env.VECTORIZE!.deleteByIds(idsToDelete);\n deletedVectors = idsToDelete.length;\n }\n\n return { embedded: rowTexts.length, vectors: vectorCount, deletedVectors };\n}\n\nexport default {\n fetch: app.fetch,\n async scheduled(_event: ScheduledEvent, env: Env) {\n console.log(`[${env.WORKSPACE_ID ?? \"local\"}] Cron triggered at ${new Date().toISOString()}`);\n for (const def of allSources()) {\n try {\n await runSync(env, def.name);\n console.log(`[${env.WORKSPACE_ID ?? \"local\"}] Synced ${def.name}`);\n } catch (err) {\n console.error(`[${env.WORKSPACE_ID ?? \"local\"}] Sync failed for ${def.name}:`, err);\n }\n }\n },\n};\n",
|
|
4
|
+
"context.ts": "import type { Env } from \"./types.js\";\nimport type { CollectOptions, FormSchema, FormPage, FormAccess } from \"./form-types.js\";\nimport { scoreComplexity, resolveModel, parseModelSpec, resolveBilling } from \"./ai-router.js\";\nimport type { Tier, RoutingConfig, BillingConfig } from \"./ai-router.js\";\n\nexport interface SearchOptions {\n source?: string;\n limit?: number;\n filter?: Record<string, string>;\n}\n\nexport interface SearchResult {\n score: number;\n table: string;\n primaryKey: string;\n row: Record<string, unknown>;\n}\n\nexport interface AskOptions {\n source?: string;\n limit?: number;\n model?: string;\n system?: string;\n}\n\nexport interface AskResult {\n answer: string;\n sources: SearchResult[];\n usage: { input_tokens: number; output_tokens: number; search_results: number };\n}\n\ninterface AiOptions {\n prompt: string;\n system?: string;\n maxTokens?: number;\n routing?: RoutingConfig;\n billing?: string;\n}\n\ninterface AiResponse {\n text: string;\n model: string;\n usage: { input_tokens: number; output_tokens: number };\n routing?: {\n tier: Tier;\n model: string;\n provider: string;\n reason: string;\n };\n}\n\ninterface NotifyOptions {\n to: string;\n message: string;\n subject?: string;\n fromName?: string;\n cta?: { label: string; url: string };\n blocks?: unknown[];\n thread_ts?: string;\n unfurl_links?: boolean;\n unfurl_media?: boolean;\n}\n\nfunction internalHeaders(env: Env): Record<string, string> {\n return {\n \"Content-Type\": \"application/json\",\n \"X-Mug-Internal\": env.MUG_INTERNAL_SECRET ?? \"\",\n };\n}\n\nfunction titleCase(slug: string): string {\n return (slug ?? \"Mug\").split(\"-\").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(\" \");\n}\n\n\nexport class WorkspaceContext {\n private env: Env;\n private isLocal: boolean;\n\n constructor(env: Env) {\n this.env = env;\n this.isLocal = !!env.WORKSPACE_DB;\n }\n\n getDatabaseStub(database: string): DurableObjectStub {\n if (!this.env.WORKSPACE_DB) throw new Error(\"WORKSPACE_DB binding not available\");\n return this.env.WORKSPACE_DB.get(this.env.WORKSPACE_DB.idFromName(database));\n }\n\n async query(database: string, sql: string, params?: (string | number | null)[]): Promise<Record<string, unknown>[]> {\n if (this.isLocal) {\n const stub = this.env.WORKSPACE_DB!.get(this.env.WORKSPACE_DB!.idFromName(database));\n const res = await stub.fetch(new URL(\"/query\", \"http://do\").toString(), {\n method: \"POST\",\n body: JSON.stringify({ sql, params }),\n headers: { \"Content-Type\": \"application/json\" },\n });\n const data = (await res.json()) as { rows: Record<string, unknown>[] };\n return data.rows;\n }\n\n const res = await this.env.MUG_DATA.fetch(\n `https://mug-data/workspace/${this.env.WORKSPACE_ID}/db/${database}/query`,\n {\n method: \"POST\",\n body: JSON.stringify({ sql, params }),\n headers: internalHeaders(this.env),\n }\n );\n const data = (await res.json()) as { rows: Record<string, unknown>[] };\n return data.rows;\n }\n\n async exec(\n database: string,\n sql: string,\n params?: (string | number | null)[],\n changeset?: { id: string; source: string },\n ): Promise<number> {\n const csHeaders: Record<string, string> = {};\n if (changeset?.id) csHeaders[\"X-Changeset-Id\"] = changeset.id;\n if (changeset?.source) csHeaders[\"X-Changeset-Source\"] = changeset.source;\n\n if (this.isLocal) {\n const stub = this.env.WORKSPACE_DB!.get(this.env.WORKSPACE_DB!.idFromName(database));\n const res = await stub.fetch(new URL(\"/exec\", \"http://do\").toString(), {\n method: \"POST\",\n body: JSON.stringify({ sql, params }),\n headers: { \"Content-Type\": \"application/json\", ...csHeaders },\n });\n const data = (await res.json()) as { changes: number };\n return data.changes;\n }\n\n const res = await this.env.MUG_DATA.fetch(\n `https://mug-data/workspace/${this.env.WORKSPACE_ID}/db/${database}/exec`,\n {\n method: \"POST\",\n body: JSON.stringify({ sql, params }),\n headers: { ...internalHeaders(this.env), ...csHeaders },\n }\n );\n const data = (await res.json()) as { changes: number };\n return data.changes;\n }\n\n private getWorkspaceRouting(): RoutingConfig | undefined {\n if (!this.env.MUG_AI_ROUTING) return undefined;\n try { return JSON.parse(this.env.MUG_AI_ROUTING); } catch { return undefined; }\n }\n\n private getWorkspaceBilling(): BillingConfig | undefined {\n if (!this.env.MUG_AI_BILLING) return undefined;\n try { return JSON.parse(this.env.MUG_AI_BILLING); } catch { return undefined; }\n }\n\n async ai(model: string, options: AiOptions): Promise<AiResponse> {\n let provider: string;\n let resolvedModel: string;\n let tier: Tier | null = null;\n let routingMeta: AiResponse[\"routing\"] | undefined;\n\n const tierNames = [\"fast\", \"balanced\", \"powerful\"] as const;\n if (model === \"auto\") {\n const score = scoreComplexity(options.prompt, options);\n tier = score.tier;\n const resolved = resolveModel(tier, options.routing, this.getWorkspaceRouting());\n provider = resolved.provider;\n resolvedModel = resolved.model;\n routingMeta = { tier, model: resolvedModel, provider, reason: score.reason };\n } else if (tierNames.includes(model as Tier)) {\n tier = model as Tier;\n const resolved = resolveModel(tier, options.routing, this.getWorkspaceRouting());\n provider = resolved.provider;\n resolvedModel = resolved.model;\n routingMeta = { tier, model: resolvedModel, provider, reason: `tier:${tier}` };\n } else {\n const parsed = parseModelSpec(model);\n provider = parsed.provider;\n resolvedModel = parsed.model;\n }\n\n const billingKey = resolveBilling(tier, options.billing, undefined, this.getWorkspaceBilling());\n const billing = billingKey !== \"mug-metered\"\n ? (this.env as unknown as Record<string, string>)[billingKey] ?? billingKey\n : billingKey;\n\n const body = JSON.stringify({\n workspace: this.env.WORKSPACE_ID,\n provider,\n model: resolvedModel,\n prompt: options.prompt,\n system: options.system,\n maxTokens: options.maxTokens,\n routing: routingMeta ? { tier: routingMeta.tier, reason: routingMeta.reason } : undefined,\n billing,\n });\n\n let res: Response;\n if (this.isLocal) {\n res = await fetch(\"http://localhost:8787/_ai/complete\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body,\n });\n } else {\n res = await this.env.MUG_AI.fetch(\"https://mug-ai/complete\", {\n method: \"POST\",\n body,\n headers: internalHeaders(this.env),\n });\n }\n if (!res.ok) throw new Error(`AI request failed (${res.status}): ${await res.text()}`);\n\n const data = (await res.json()) as AiResponse;\n if (routingMeta) data.routing = routingMeta;\n return data;\n }\n\n async embed(texts: string[]): Promise<number[][]> {\n if (texts.length === 0) return [];\n\n const results: number[][] = [];\n for (let i = 0; i < texts.length; i += 100) {\n const batch = texts.slice(i, i + 100);\n const body = JSON.stringify({ workspace: this.env.WORKSPACE_ID, texts: batch });\n\n let res: Response;\n if (this.isLocal) {\n res = await fetch(\"http://localhost:8787/_ai/embed\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body,\n });\n } else {\n res = await this.env.MUG_AI.fetch(\"https://mug-ai/embed\", {\n method: \"POST\",\n body,\n headers: internalHeaders(this.env),\n });\n }\n if (!res.ok) throw new Error(`Embed request failed (${res.status}): ${await res.text()}`);\n\n const data = (await res.json()) as { vectors: number[][] };\n results.push(...data.vectors);\n }\n\n return results;\n }\n\n async search(query: string, options?: SearchOptions): Promise<SearchResult[]> {\n if (!this.env.VECTORIZE) {\n throw new Error(\"Semantic search not available — no VECTORIZE binding (requires deployed workspace)\");\n }\n\n const limit = Math.min(options?.limit ?? 10, 50);\n const [queryVector] = await this.embed([query]);\n\n const filter: Record<string, string> = { ...options?.filter };\n if (options?.source) filter.table = options.source;\n\n const matches = await this.env.VECTORIZE!.query(queryVector, {\n topK: limit * 2,\n returnMetadata: \"all\",\n ...(Object.keys(filter).length > 0 ? { filter } : {}),\n });\n\n interface VectorMeta { database: string; table: string; pk_column: string; primary_key: string }\n const best = new Map<string, { score: number; meta: VectorMeta }>();\n for (const match of matches.matches) {\n const meta = match.metadata as VectorMeta | undefined;\n if (!meta?.table || !meta?.primary_key) continue;\n const key = `${meta.table}:${meta.primary_key}`;\n const existing = best.get(key);\n if (!existing || match.score > existing.score) {\n best.set(key, { score: match.score, meta });\n }\n }\n\n const results: SearchResult[] = [];\n for (const [, { score, meta }] of best) {\n if (results.length >= limit) break;\n try {\n const rows = await this.query(\n meta.database,\n `SELECT * FROM \"${meta.table}\" WHERE \"${meta.pk_column}\" = ? AND _mug_deleted_at IS NULL`,\n [meta.primary_key],\n );\n if (rows.length > 0) {\n results.push({ score, table: meta.table, primaryKey: meta.primary_key, row: rows[0] });\n }\n } catch {\n results.push({ score, table: meta.table, primaryKey: meta.primary_key, row: {} });\n }\n }\n\n return results;\n }\n\n async ask(question: string, options?: AskOptions): Promise<AskResult> {\n const sources = await this.search(question, {\n source: options?.source,\n limit: options?.limit ?? 10,\n });\n\n const contextParts: string[] = [];\n let tokenEstimate = 0;\n for (const result of sources) {\n const entry = `[${result.table}:${result.primaryKey} score=${result.score.toFixed(3)}]\\n${JSON.stringify(result.row)}`;\n const entryTokens = Math.ceil(entry.split(/\\s+/).length * 1.3);\n if (tokenEstimate + entryTokens > 3000) break;\n contextParts.push(entry);\n tokenEstimate += entryTokens;\n }\n\n const baseSystem = \"Answer the question based on the following business data. Cite which records informed your answer. If the data does not contain enough information, say so.\";\n const dataBlock = `\\n\\n--- Business Data ---\\n${contextParts.join(\"\\n\\n\")}`;\n const system = (options?.system ? `${options.system}\\n\\n${baseSystem}` : baseSystem) + dataBlock;\n\n const aiResult = await this.ai(options?.model ?? \"balanced\", {\n prompt: question,\n system,\n });\n\n return {\n answer: aiResult.text,\n sources,\n usage: {\n input_tokens: aiResult.usage.input_tokens,\n output_tokens: aiResult.usage.output_tokens,\n search_results: sources.length,\n },\n };\n }\n\n surfaceUrl(surfaceId: string, path?: string): string {\n const suffix = path ?? \"\";\n if (this.isLocal) {\n return `http://localhost:8787/${surfaceId}${suffix}`;\n }\n return `https://${this.env.WORKSPACE_ID}.mug.work/${surfaceId}${suffix}`;\n }\n\n get notify() {\n return {\n email: (options: NotifyOptions) => this.sendNotification(\"email\", options),\n sms: (options: NotifyOptions) => this.sendNotification(\"sms\", options),\n slack: (options: NotifyOptions) => this.sendNotification(\"slack\", options),\n channel: (name: string, options: NotifyOptions) => this.sendNotification(name, options),\n };\n }\n\n private getBranding(): { logo?: string; logoSquare?: string; accentColor?: string } | undefined {\n if (!this.env.MUG_BRANDING) return undefined;\n try { return JSON.parse(this.env.MUG_BRANDING); } catch { return undefined; }\n }\n\n private async sendNotification(channel: string, options: NotifyOptions): Promise<string> {\n const fromName = options.fromName ?? titleCase(this.env.WORKSPACE_ID);\n const branding = this.getBranding();\n\n if (this.isLocal) {\n console.log(`[${channel}] to=${options.to}${options.subject ? ` subject=${options.subject}` : \"\"}`);\n try {\n const res = await fetch(\"http://localhost:8787/_notify/send\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n workspace: this.env.WORKSPACE_ID,\n channel,\n to: options.to,\n message: options.message,\n subject: options.subject,\n fromName,\n cta: options.cta,\n branding,\n ...(channel === \"slack\" ? {\n blocks: options.blocks,\n thread_ts: options.thread_ts,\n unfurl_links: options.unfurl_links,\n unfurl_media: options.unfurl_media,\n slackBotToken: this.env.SLACK_BOT_TOKEN,\n } : {}),\n }),\n });\n const result = await res.json() as { status: string; error?: string };\n if (result.status === \"blocked\") {\n console.log(`[${channel}] BLOCKED: ${result.error}`);\n } else if (result.status === \"delivery_failed\") {\n console.log(`[${channel}] DELIVERY FAILED: ${result.error ?? \"no detail from provider\"}`);\n } else if (result.status === \"logged\") {\n console.log(`[${channel}] logged but not delivered — missing provider credentials`);\n }\n return result.status;\n } catch {\n console.log(`[${channel}] delivery skipped — dev proxy not reachable`);\n return \"skipped\";\n }\n }\n\n const res = await this.env.MUG_NOTIFY.fetch(\n \"https://mug-notify/send\",\n {\n method: \"POST\",\n body: JSON.stringify({\n workspace: this.env.WORKSPACE_ID,\n channel,\n to: options.to,\n message: options.message,\n subject: options.subject,\n fromName,\n cta: options.cta,\n branding,\n ...(channel === \"slack\" ? {\n blocks: options.blocks,\n thread_ts: options.thread_ts,\n unfurl_links: options.unfurl_links,\n unfurl_media: options.unfurl_media,\n slackBotToken: this.env.SLACK_BOT_TOKEN,\n } : {}),\n }),\n headers: internalHeaders(this.env),\n }\n );\n const result = await res.json() as { status: string };\n return result.status;\n }\n\n async slackApiCall(method: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {\n const token = this.env.SLACK_BOT_TOKEN;\n if (!token) throw new Error(\"SLACK_BOT_TOKEN not configured\");\n\n const res = await fetch(`https://slack.com/api/${method}`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json; charset=utf-8\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(body),\n });\n const data = await res.json() as { ok: boolean; error?: string; [key: string]: unknown };\n if (!data.ok) throw new Error(`Slack API ${method} failed: ${data.error ?? \"unknown error\"}`);\n return data;\n }\n\n async file(path: string): Promise<ArrayBuffer> {\n if (this.isLocal) {\n const res = await fetch(`http://localhost:8787/_files/${path}`);\n if (!res.ok) throw new Error(`File not found: ${path}`);\n return res.arrayBuffer();\n }\n\n const res = await this.env.MUG_DATA.fetch(\n `https://mug-data/workspace/${this.env.WORKSPACE_ID}/files/read/${path}`,\n { headers: { \"X-Mug-Internal\": this.env.MUG_INTERNAL_SECRET ?? \"\" } }\n );\n if (!res.ok) throw new Error(`File not found: ${path}`);\n return res.arrayBuffer();\n }\n\n async fileText(path: string): Promise<string> {\n const buffer = await this.file(path);\n return new TextDecoder().decode(buffer);\n }\n\n async invokeAgent(\n name: string,\n options: { goal: string; context?: Record<string, unknown>; sessionKey?: string; caps?: Record<string, number> },\n ): Promise<{ response: string; output?: Record<string, unknown>; usage: { turns: number; credits: number }; capped?: boolean; cappedReason?: string; status?: string; pendingApproval?: { tool: string; args: Record<string, unknown> } }> {\n const workspace = this.env.WORKSPACE_ID;\n const res = await this.env.MUG_DISPATCH.fetch(\n `https://mug-dispatch/agent/${workspace}/invoke`,\n {\n method: \"POST\",\n body: JSON.stringify({ agent: name, ...options }),\n headers: internalHeaders(this.env),\n },\n );\n if (!res.ok) {\n const errText = await res.text();\n throw new Error(`Agent \"${name}\" failed: ${errText}`);\n }\n return res.json() as any;\n }\n\n async collect(options: CollectOptions): Promise<string> {\n const surfaceId = options.id ?? crypto.randomUUID().slice(0, 8);\n const workspace = this.env.WORKSPACE_ID;\n\n const pages: FormPage[] = options.pages ?? [{\n id: \"main\",\n fields: options.fields ?? [],\n }];\n\n const access: FormAccess = options.access ?? { mode: \"public\" };\n\n const schema: FormSchema = {\n title: options.title,\n description: options.description,\n submitText: options.submitText,\n pages,\n access,\n editMode: options.editMode,\n workflow: options.workflow,\n };\n\n const surfaceConfig = { workspace, surfaceId, ...schema };\n\n const url = this.surfaceUrl(surfaceId);\n\n if (this.isLocal) {\n console.log(`[collect] Surface \"${surfaceId}\" created: ${url}`);\n return url;\n }\n\n await this.env.MUG_DISPATCH.fetch(\n \"https://mug-dispatch/deploy-surface\",\n {\n method: \"POST\",\n body: JSON.stringify(surfaceConfig),\n headers: internalHeaders(this.env),\n },\n );\n\n return url;\n }\n}\n",
|
|
5
|
+
"source.ts": "import type { WorkspaceContext } from \"./context.js\";\n\nexport interface SourceContext {\n credential: (name: string) => Promise<string>;\n lastSync: string | null;\n}\n\nexport interface PaginationConfig {\n style: \"cursor\" | \"offset\" | \"page\" | \"link-header\";\n cursorParam?: string;\n cursorPath?: string;\n offsetParam?: string;\n pageParam?: string;\n pageSizeParam?: string;\n defaultPageSize?: number;\n maxPageSize?: number;\n}\n\nexport interface RateLimitConfig {\n requestsPerSecond?: number;\n requestsPerMinute?: number;\n}\n\nexport interface SyncConfig {\n filterParam?: string;\n filterFormat?: \"iso8601\" | \"unix\" | \"epoch_ms\";\n updatedAtField?: string;\n deletedAtField?: string;\n isDeletedField?: string;\n deletionStrategy?: \"soft-delete-field\" | \"tombstone-endpoint\" | \"full-sync-only\";\n}\n\nexport interface ErrorRetryConfig {\n maxRetries?: number;\n retryOn5xx?: boolean;\n retryOn429?: boolean;\n backoffMs?: number;\n}\n\nexport interface TableDef {\n name: string;\n primaryKey: string;\n endpoint?: string;\n fetch: (ctx: SourceContext) => Promise<Record<string, unknown>[]>;\n extractItems?: (body: unknown) => Record<string, unknown>[];\n pagination?: PaginationConfig;\n sync?: SyncConfig;\n}\n\nexport interface SourceDef {\n name: string;\n description?: string;\n database: string;\n syncSchedule?: string;\n tables: TableDef[];\n baseUrl?: string;\n rateLimits?: RateLimitConfig;\n errorRetry?: ErrorRetryConfig;\n}\n\nconst registry = new Map<string, SourceDef>();\n\nexport function source(def: SourceDef): SourceDef {\n registry.set(def.name, def);\n return def;\n}\n\nexport function getSource(name: string): SourceDef | undefined {\n return registry.get(name);\n}\n\nexport function allSources(): SourceDef[] {\n return [...registry.values()];\n}\n",
|
|
6
|
+
"workflow.ts": "import { WorkspaceContext } from \"./context.js\";\nimport type { SearchOptions, SearchResult, AskOptions, AskResult } from \"./context.js\";\nimport type { Env } from \"./types.js\";\nimport type { CollectOptions, FormSchema, FormPage, FormAccess } from \"./form-types.js\";\nimport type { RoutingConfig } from \"./ai-router.js\";\nimport type { AgentCaps } from \"./agent-types.js\";\n\nconst OPS_DB = \"_mug_ops\";\n\nexport interface AgentInvokeOptions {\n goal: string;\n context?: Record<string, unknown>;\n sessionKey?: string;\n caps?: Partial<AgentCaps>;\n}\n\nexport interface AgentResult {\n response: string;\n output?: Record<string, unknown>;\n usage: { credits: number; turns: number; duration: number };\n capped?: boolean;\n cappedReason?: string;\n pendingApproval?: { tool: string; args: Record<string, unknown>; sessionKey: string };\n}\n\nexport interface WaitForOptions {\n timeout?: string | number;\n message?: string;\n}\n\nexport interface WaitForResult<T = unknown> {\n payload: T;\n type: string;\n timedOut: boolean;\n}\n\ninterface AiOptions {\n prompt: string;\n system?: string;\n maxTokens?: number;\n routing?: RoutingConfig;\n billing?: string;\n}\n\ninterface AiResponse {\n text: string;\n model: string;\n usage: { input_tokens: number; output_tokens: number };\n routing?: {\n tier: string;\n model: string;\n provider: string;\n reason: string;\n };\n}\n\ninterface NotifyOptions {\n to: string;\n message: string;\n subject?: string;\n fromName?: string;\n cta?: { label: string; url: string };\n blocks?: unknown[];\n thread_ts?: string;\n unfurl_links?: boolean;\n unfurl_media?: boolean;\n}\n\nexport interface HttpOptions {\n method?: string;\n headers?: Record<string, string>;\n body?: unknown;\n throwOnError?: boolean;\n retry?: { attempts?: number } | false;\n timeout?: number;\n sign?: { secret: string; header?: string };\n}\n\nexport interface HttpResult {\n status: number;\n headers: Record<string, string>;\n body: string;\n json: unknown;\n ok: boolean;\n}\n\nexport class HttpError extends Error {\n status: number;\n result: HttpResult;\n constructor(result: HttpResult) {\n super(`HTTP ${result.status}: ${result.body.slice(0, 200)}`);\n this.name = \"HttpError\";\n this.status = result.status;\n this.result = result;\n }\n}\n\nfunction truncate(value: unknown, maxLen = 4096): string {\n const s = JSON.stringify(value);\n return s.length > maxLen ? s.slice(0, maxLen) + \"…\" : s;\n}\n\nexport interface StepRecord {\n name: string;\n type: string;\n startedAt: number;\n completedAt?: number;\n durationMs?: number;\n input?: string;\n output?: string;\n error?: string;\n tokensUsed?: number;\n}\n\nexport interface WorkflowResult {\n workflow: string;\n runId: string;\n status: \"complete\" | \"errored\";\n startedAt: string;\n completedAt: string;\n durationMs: number;\n stepCount: number;\n steps: StepRecord[];\n result?: unknown;\n error?: string;\n webhookResponse?: WebhookResponse;\n}\n\nexport interface WebhookResponse {\n body: unknown;\n status: number;\n}\n\nexport class WorkflowContext {\n private ctx: WorkspaceContext;\n private env: Env;\n private stepCounter = 0;\n private workflowBilling?: string;\n readonly steps: StepRecord[] = [];\n params: Record<string, unknown> = {};\n changesetId?: string;\n changesetSource?: string;\n instanceId?: string;\n _webhookResponse?: WebhookResponse;\n\n get isDemo(): boolean {\n return this.params._demo === true;\n }\n\n private get demoNotify(): { mode: string; identity: string; overrides?: Record<string, string>; devEmail?: string } | null {\n return (this.params._demoNotify as { mode: string; identity: string; overrides?: Record<string, string>; devEmail?: string }) ?? null;\n }\n\n private resolveDemoRecipient(channel: \"email\" | \"sms\" | \"slack\", originalTo: string): string | null {\n const cfg = this.demoNotify;\n if (!cfg) return originalTo;\n\n if (cfg.overrides?.[channel]) return cfg.overrides[channel];\n\n switch (cfg.mode) {\n case \"off\":\n return null;\n case \"demo-user\": {\n const isEmail = cfg.identity.includes(\"@\");\n if (channel === \"email\" && isEmail) return cfg.identity;\n if (channel === \"sms\" && !isEmail) return cfg.identity;\n return null;\n }\n case \"dev\": {\n if (channel === \"email\" && cfg.devEmail) return cfg.devEmail;\n return null;\n }\n default:\n return null;\n }\n }\n\n constructor(env: Env, options?: WorkflowOptions) {\n this.env = env;\n this.ctx = new WorkspaceContext(env);\n this.workflowBilling = options?.billing;\n }\n\n secret(name: string): string {\n const val = (this.env as unknown as Record<string, unknown>)[name];\n if (typeof val !== \"string\") throw new Error(`Secret \"${name}\" not found`);\n return val;\n }\n\n private nextStep(type: string, target: string): string {\n this.stepCounter++;\n return `${type}-${target}-${this.stepCounter}`;\n }\n\n private recordStep(\n name: string,\n type: string,\n startedAt: number,\n opts?: { input?: unknown; output?: unknown; error?: string; tokensUsed?: number },\n ): StepRecord {\n const completedAt = Date.now();\n const record: StepRecord = {\n name,\n type,\n startedAt,\n completedAt,\n durationMs: completedAt - startedAt,\n };\n if (opts?.input != null) record.input = truncate(opts.input);\n if (opts?.output != null) record.output = truncate(opts.output);\n if (opts?.error) record.error = opts.error;\n if (opts?.tokensUsed) record.tokensUsed = opts.tokensUsed;\n this.steps.push(record);\n return record;\n }\n\n async query(database: string, sql: string, params?: (string | number | null)[]): Promise<Record<string, unknown>[]> {\n const name = this.nextStep(\"query\", database);\n const start = Date.now();\n try {\n const rows = await this.ctx.query(database, sql, params);\n this.recordStep(name, \"query\", start, { input: { sql, params }, output: `${rows.length} rows` });\n return rows;\n } catch (e) {\n this.recordStep(name, \"query\", start, { input: { sql, params }, error: (e as Error).message });\n throw e;\n }\n }\n\n async exec(database: string, sql: string, params?: (string | number | null)[]): Promise<number> {\n const name = this.nextStep(\"exec\", database);\n const start = Date.now();\n const changeset = this.changesetId\n ? { id: this.changesetId, source: this.changesetSource ?? \"unknown\" }\n : undefined;\n try {\n const changes = await this.ctx.exec(database, sql, params, changeset);\n this.recordStep(name, \"exec\", start, { input: { sql, params }, output: `${changes} changes` });\n return changes;\n } catch (e) {\n this.recordStep(name, \"exec\", start, { input: { sql, params }, error: (e as Error).message });\n throw e;\n }\n }\n\n async ai(model: string, options: AiOptions): Promise<AiResponse> {\n const name = this.nextStep(\"ai\", model);\n const start = Date.now();\n const opts = this.workflowBilling && !options.billing\n ? { ...options, billing: this.workflowBilling }\n : options;\n try {\n const result = await this.ctx.ai(model, opts);\n this.recordStep(name, \"ai\", start, {\n input: { prompt: options.prompt.slice(0, 200) },\n output: result.text.slice(0, 200),\n tokensUsed: result.usage.input_tokens + result.usage.output_tokens,\n });\n return result;\n } catch (e) {\n this.recordStep(name, \"ai\", start, { input: { prompt: options.prompt.slice(0, 200) }, error: (e as Error).message });\n throw e;\n }\n }\n\n async search(query: string, options?: SearchOptions): Promise<SearchResult[]> {\n const name = this.nextStep(\"search\", options?.source ?? \"all\");\n const start = Date.now();\n try {\n const results = await this.ctx.search(query, options);\n this.recordStep(name, \"search\", start, {\n input: { query: query.slice(0, 200), source: options?.source },\n output: `${results.length} results`,\n });\n return results;\n } catch (e) {\n this.recordStep(name, \"search\", start, {\n input: { query: query.slice(0, 200) },\n error: (e as Error).message,\n });\n throw e;\n }\n }\n\n async ask(question: string, options?: AskOptions): Promise<AskResult> {\n const name = this.nextStep(\"ask\", options?.source ?? \"all\");\n const start = Date.now();\n try {\n const result = await this.ctx.ask(question, options);\n this.recordStep(name, \"ask\", start, {\n input: { question: question.slice(0, 200), source: options?.source },\n output: result.answer.slice(0, 200),\n tokensUsed: result.usage.input_tokens + result.usage.output_tokens,\n });\n return result;\n } catch (e) {\n this.recordStep(name, \"ask\", start, {\n input: { question: question.slice(0, 200) },\n error: (e as Error).message,\n });\n throw e;\n }\n }\n\n get notify() {\n return {\n sms: (options: NotifyOptions) => this.sendNotification(\"sms\", options),\n email: (options: NotifyOptions) => this.sendNotification(\"email\", options),\n slack: (options: NotifyOptions) => this.sendNotification(\"slack\", options),\n channel: (name: string, options: NotifyOptions) => this.sendNotification(name, options),\n };\n }\n\n private async sendNotification(channel: string, options: NotifyOptions): Promise<string> {\n const name = this.nextStep(\"notify\", channel);\n const start = Date.now();\n\n if (this.isDemo) {\n const resolved = this.resolveDemoRecipient(channel as \"email\" | \"sms\" | \"slack\", options.to);\n if (resolved === null) {\n const suppressed = `suppressed (demo mode: ${this.demoNotify?.mode ?? \"off\"})`;\n this.recordStep(name, \"notify\", start, {\n input: { to: options.to },\n output: suppressed,\n });\n return \"suppressed\";\n }\n options = { ...options, to: resolved };\n }\n\n try {\n const status = await this.ctx.notify[channel as \"sms\" | \"email\" | \"slack\"](options);\n this.recordStep(name, \"notify\", start, { input: { to: options.to }, output: status });\n return status;\n } catch (e) {\n this.recordStep(name, \"notify\", start, { input: { to: options.to }, error: (e as Error).message });\n throw e;\n }\n }\n\n get slack() {\n return {\n updateMessage: (options: { channel: string; ts: string; text?: string; blocks?: unknown[] }) =>\n this.slackUpdateMessage(options),\n openModal: (options: { triggerId: string; view: Record<string, unknown> }) =>\n this.slackOpenModal(options),\n };\n }\n\n private async slackUpdateMessage(options: { channel: string; ts: string; text?: string; blocks?: unknown[] }): Promise<void> {\n const name = this.nextStep(\"slack\", \"updateMessage\");\n const start = Date.now();\n try {\n await this.ctx.slackApiCall(\"chat.update\", {\n channel: options.channel,\n ts: options.ts,\n text: options.text ?? \"\",\n ...(options.blocks ? { blocks: options.blocks } : {}),\n });\n this.recordStep(name, \"slack.updateMessage\", start, { input: { channel: options.channel, ts: options.ts }, output: \"updated\" });\n } catch (e) {\n this.recordStep(name, \"slack.updateMessage\", start, { input: { channel: options.channel, ts: options.ts }, error: (e as Error).message });\n throw e;\n }\n }\n\n private async slackOpenModal(options: { triggerId: string; view: Record<string, unknown> }): Promise<void> {\n const name = this.nextStep(\"slack\", \"openModal\");\n const start = Date.now();\n try {\n await this.ctx.slackApiCall(\"views.open\", {\n trigger_id: options.triggerId,\n view: options.view,\n });\n this.recordStep(name, \"slack.openModal\", start, { output: \"opened\" });\n } catch (e) {\n this.recordStep(name, \"slack.openModal\", start, { error: (e as Error).message });\n throw e;\n }\n }\n\n async file(path: string): Promise<ArrayBuffer> {\n const name = this.nextStep(\"file\", path);\n const start = Date.now();\n try {\n const buffer = await this.ctx.file(path);\n this.recordStep(name, \"file\", start, { input: { path }, output: `${buffer.byteLength} bytes` });\n return buffer;\n } catch (e) {\n this.recordStep(name, \"file\", start, { input: { path }, error: (e as Error).message });\n throw e;\n }\n }\n\n async fileText(path: string): Promise<string> {\n const name = this.nextStep(\"fileText\", path);\n const start = Date.now();\n try {\n const text = await this.ctx.fileText(path);\n this.recordStep(name, \"fileText\", start, { input: { path }, output: `${text.length} chars` });\n return text;\n } catch (e) {\n this.recordStep(name, \"fileText\", start, { input: { path }, error: (e as Error).message });\n throw e;\n }\n }\n\n surfaceUrl(surfaceId: string, path?: string): string {\n return this.ctx.surfaceUrl(surfaceId, path);\n }\n\n respond(body: unknown, status?: number): void {\n if (this._webhookResponse) return;\n this._webhookResponse = { body, status: status ?? 200 };\n }\n\n async agent(name: string, options: AgentInvokeOptions): Promise<AgentResult> {\n const stepName = this.nextStep(\"agent\", name);\n const start = Date.now();\n const sessionKey = options.sessionKey ?? `${this.changesetId ?? \"run\"}-${name}`;\n\n try {\n const res = await this.ctx.invokeAgent(name, {\n goal: options.goal,\n context: options.context,\n sessionKey,\n caps: options.caps,\n });\n\n if (res.status === \"pending_approval\" && (res as any).pendingApproval) {\n const pending = (res as any).pendingApproval as { tool: string; args: Record<string, unknown> };\n console.log(`[agent] \"${name}\" needs approval for ${pending.tool} — auto-approving in local dev`);\n this.recordStep(stepName, \"agent\", start, {\n input: { goal: options.goal.slice(0, 200) },\n output: `pending approval: ${pending.tool} (auto-approved in dev)`,\n });\n\n const approveRes = await this.ctx.invokeAgent(name, {\n goal: \"__approve__\",\n context: { _approve: true, sessionKey },\n sessionKey,\n });\n\n const duration = Date.now() - start;\n return {\n response: approveRes.response,\n output: approveRes.output ?? res.output ?? undefined,\n usage: { credits: (res.usage?.credits ?? 0) + (approveRes.usage?.credits ?? 0), turns: (res.usage?.turns ?? 0) + (approveRes.usage?.turns ?? 0), duration },\n capped: approveRes.capped,\n cappedReason: approveRes.cappedReason,\n };\n }\n\n const duration = Date.now() - start;\n const result: AgentResult = {\n response: res.response,\n output: res.output ?? undefined,\n usage: { credits: res.usage?.credits ?? 0, turns: res.usage?.turns ?? 0, duration },\n capped: res.capped,\n cappedReason: res.cappedReason,\n };\n\n this.recordStep(stepName, \"agent\", start, {\n input: { goal: options.goal.slice(0, 200) },\n output: res.response.slice(0, 200),\n });\n\n return result;\n } catch (e) {\n this.recordStep(stepName, \"agent\", start, {\n input: { goal: options.goal.slice(0, 200) },\n error: (e as Error).message,\n });\n throw e;\n }\n }\n\n async collect(options: CollectOptions): Promise<string> {\n const name = this.nextStep(\"collect\", options.title);\n const start = Date.now();\n try {\n const url = await this.ctx.collect(options);\n this.recordStep(name, \"collect\", start, { input: { title: options.title }, output: url });\n return url;\n } catch (e) {\n this.recordStep(name, \"collect\", start, { input: { title: options.title }, error: (e as Error).message });\n throw e;\n }\n }\n\n async waitFor<T = unknown>(eventName: string, options?: WaitForOptions): Promise<WaitForResult<T>> {\n const name = this.nextStep(\"waitFor\", eventName);\n const start = Date.now();\n console.log(`[waitFor] Waiting for \"${eventName}\" — in production, workflow pauses here (zero cost).`);\n if (options?.message) console.log(`[waitFor] ${options.message}`);\n console.log(`[waitFor] Resolving immediately for local development.`);\n const result: WaitForResult<T> = { payload: {} as T, type: eventName, timedOut: false };\n this.recordStep(name, \"waitFor\", start, { output: \"resolved (local dev)\" });\n return result;\n }\n\n waitForUrl(eventName: string): string {\n return `http://localhost:8787/_event?type=${encodeURIComponent(eventName)}`;\n }\n\n async http(url: string, options?: HttpOptions): Promise<HttpResult> {\n const method = (options?.method ?? \"GET\").toUpperCase();\n const name = this.nextStep(\"http\", new URL(url).hostname);\n const start = Date.now();\n const timeout = options?.timeout ?? 30000;\n const throwOnError = options?.throwOnError !== false;\n const retryConfig = options?.retry;\n const shouldRetry = retryConfig !== false;\n const maxAttempts = shouldRetry && typeof retryConfig === \"object\" && retryConfig?.attempts\n ? retryConfig.attempts\n : (shouldRetry ? 3 : 1);\n\n const headers: Record<string, string> = { ...options?.headers };\n let bodyStr: string | undefined;\n if (options?.body != null) {\n bodyStr = typeof options.body === \"string\" ? options.body : JSON.stringify(options.body);\n if (!headers[\"Content-Type\"] && !headers[\"content-type\"]) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n }\n\n if (options?.sign) {\n const secretValue = this.secret(options.sign.secret);\n const encoder = new TextEncoder();\n const key = await crypto.subtle.importKey(\n \"raw\", encoder.encode(secretValue), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"],\n );\n const sig = await crypto.subtle.sign(\"HMAC\", key, encoder.encode(bodyStr ?? \"\"));\n const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, \"0\")).join(\"\");\n headers[options.sign.header ?? \"X-Hub-Signature-256\"] = `sha256=${hex}`;\n }\n\n let lastError: Error | undefined;\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeout);\n const res = await fetch(url, {\n method,\n headers,\n body: bodyStr,\n signal: controller.signal,\n });\n clearTimeout(timer);\n\n const resHeaders: Record<string, string> = {};\n res.headers.forEach((v, k) => { resHeaders[k] = v; });\n const resBody = await res.text();\n let json: unknown = null;\n const ct = resHeaders[\"content-type\"] ?? \"\";\n if (ct.includes(\"json\")) {\n try { json = JSON.parse(resBody); } catch {}\n }\n\n const result: HttpResult = {\n status: res.status,\n headers: resHeaders,\n body: resBody,\n json,\n ok: res.status >= 200 && res.status < 300,\n };\n\n if (res.status === 429 && shouldRetry && attempt < maxAttempts) {\n const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);\n console.log(`[http] ${method} ${url} → 429, retrying in ${delay}ms (attempt ${attempt}/${maxAttempts})`);\n await new Promise(r => setTimeout(r, delay));\n continue;\n }\n\n this.recordStep(name, \"http\", start, {\n input: { method, url },\n output: `${res.status} ${resBody.slice(0, 200)}`,\n });\n\n if (!result.ok && throwOnError) {\n throw new HttpError(result);\n }\n return result;\n } catch (e) {\n if (e instanceof HttpError) throw e;\n lastError = e as Error;\n const isRetryable = lastError.name === \"AbortError\" || lastError.message?.includes(\"fetch failed\");\n if (isRetryable && shouldRetry && attempt < maxAttempts) {\n const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);\n console.log(`[http] ${method} ${url} → ${lastError.message}, retrying in ${delay}ms (attempt ${attempt}/${maxAttempts})`);\n await new Promise(r => setTimeout(r, delay));\n continue;\n }\n this.recordStep(name, \"http\", start, {\n input: { method, url },\n error: lastError.message,\n });\n throw lastError;\n }\n }\n this.recordStep(name, \"http\", start, { input: { method, url }, error: lastError?.message ?? \"max retries\" });\n throw lastError ?? new Error(\"ctx.http() failed after retries\");\n }\n}\n\nexport interface TriggerConfig {\n type?: \"slack_command\" | \"slack_event\" | \"slack_shortcut\" | \"data\";\n source?: string;\n table?: string;\n on?: \"insert\" | \"update\" | \"delete\" | \"change\";\n includeInitialSync?: boolean;\n command?: string;\n event?: string;\n}\n\nexport interface WorkflowOptions {\n description?: string;\n billing?: string;\n schedule?: string;\n webhook?: boolean | { auth: \"none\" | \"hmac\" | \"bearer\"; secret?: string };\n inbound?: \"sms\" | \"email\" | \"slack\";\n trigger?: TriggerConfig;\n}\n\nexport type WorkflowHandler = (ctx: WorkflowContext) => Promise<unknown>;\n\nexport interface WorkflowDef {\n name: string;\n handler: WorkflowHandler;\n options?: WorkflowOptions;\n}\n\nconst registry = new Map<string, WorkflowDef>();\n\nexport function workflow(name: string, handler: WorkflowHandler, options?: WorkflowOptions): WorkflowDef {\n const def = { name, handler, options };\n registry.set(name, def);\n return def;\n}\n\nexport function getWorkflow(name: string): WorkflowDef | undefined {\n return registry.get(name);\n}\n\nexport function allWorkflows(): WorkflowDef[] {\n return [...registry.values()];\n}\n\nexport function findTriggeredWorkflows(\n sourceName: string,\n tableName: string,\n event: \"insert\" | \"update\" | \"delete\",\n isInitialSync: boolean,\n): WorkflowDef[] {\n return allWorkflows().filter((def) => {\n const t = def.options?.trigger;\n if (!t || t.source !== sourceName) return false;\n if (t.table && t.table !== tableName) return false;\n if (isInitialSync && !t.includeInitialSync) return false;\n const on = t.on ?? \"change\";\n return on === \"change\" || on === event;\n });\n}\n\nasync function ensureOpsSchema(ctx: WorkspaceContext): Promise<void> {\n await ctx.exec(OPS_DB, `CREATE TABLE IF NOT EXISTS workflow_runs (\n id TEXT PRIMARY KEY,\n workflow TEXT NOT NULL,\n status TEXT NOT NULL,\n started_at TEXT NOT NULL,\n completed_at TEXT,\n duration_ms INTEGER,\n params TEXT,\n result TEXT,\n error TEXT\n )`);\n await ctx.exec(OPS_DB, `CREATE TABLE IF NOT EXISTS workflow_steps (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n run_id TEXT NOT NULL REFERENCES workflow_runs(id),\n step_name TEXT NOT NULL,\n step_type TEXT NOT NULL,\n started_at TEXT NOT NULL,\n completed_at TEXT,\n duration_ms INTEGER,\n input TEXT,\n output TEXT,\n error TEXT,\n tokens_used INTEGER,\n retries INTEGER DEFAULT 0\n )`);\n}\n\nasync function persistRun(ctx: WorkspaceContext, result: WorkflowResult): Promise<void> {\n try {\n await ensureOpsSchema(ctx);\n await ctx.exec(OPS_DB,\n `INSERT INTO workflow_runs (id, workflow, status, started_at, completed_at, duration_ms, result, error)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\n [result.runId, result.workflow, result.status, result.startedAt, result.completedAt,\n result.durationMs, result.result != null ? JSON.stringify(result.result) : null,\n result.error ?? null]);\n for (const step of result.steps) {\n await ctx.exec(OPS_DB,\n `INSERT INTO workflow_steps (run_id, step_name, step_type, started_at, completed_at, duration_ms, input, output, error, tokens_used)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n [result.runId, step.name, step.type,\n new Date(step.startedAt).toISOString(), step.completedAt ? new Date(step.completedAt).toISOString() : null,\n step.durationMs ?? null, step.input ?? null, step.output ?? null, step.error ?? null, step.tokensUsed ?? null]);\n }\n } catch (e) {\n console.error(`[ops] Failed to persist workflow run: ${(e as Error).message}`);\n }\n}\n\nexport async function runWorkflow(name: string, env: Env, params?: Record<string, unknown>): Promise<WorkflowResult> {\n const def = getWorkflow(name);\n if (!def) throw new Error(`Workflow \"${name}\" not found`);\n\n const runId = `${name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n const wfCtx = new WorkflowContext(env, def.options);\n wfCtx.changesetId = runId;\n wfCtx.changesetSource = `workflow:${name}`;\n wfCtx.instanceId = `local-${runId}`;\n if (params) wfCtx.params = params;\n const opsCtx = new WorkspaceContext(env);\n const startedAt = new Date();\n\n let result: WorkflowResult;\n try {\n const value = await def.handler(wfCtx);\n const completedAt = new Date();\n result = {\n workflow: name,\n runId,\n status: \"complete\",\n startedAt: startedAt.toISOString(),\n completedAt: completedAt.toISOString(),\n durationMs: completedAt.getTime() - startedAt.getTime(),\n stepCount: wfCtx.steps.length,\n steps: wfCtx.steps,\n result: value,\n webhookResponse: wfCtx._webhookResponse,\n };\n } catch (e) {\n const completedAt = new Date();\n result = {\n workflow: name,\n runId,\n status: \"errored\",\n startedAt: startedAt.toISOString(),\n completedAt: completedAt.toISOString(),\n durationMs: completedAt.getTime() - startedAt.getTime(),\n stepCount: wfCtx.steps.length,\n steps: wfCtx.steps,\n error: (e as Error).message,\n webhookResponse: wfCtx._webhookResponse,\n };\n }\n\n await persistRun(opsCtx, result);\n return result;\n}\n\nexport async function queryLogs(env: Env, workflowName?: string, limit = 10): Promise<unknown> {\n const ctx = new WorkspaceContext(env);\n await ensureOpsSchema(ctx);\n\n if (workflowName) {\n const runs = await ctx.query(OPS_DB,\n `SELECT id, workflow, status, started_at, completed_at, duration_ms, error\n FROM workflow_runs WHERE workflow = ? ORDER BY started_at DESC LIMIT ?`,\n [workflowName, limit]);\n\n const result = [];\n for (const run of runs) {\n const steps = await ctx.query(OPS_DB,\n `SELECT step_name, step_type, duration_ms, input, output, error, tokens_used\n FROM workflow_steps WHERE run_id = ? ORDER BY id`,\n [run.id as string]);\n result.push({ ...run, steps });\n }\n return result;\n }\n\n return ctx.query(OPS_DB,\n `SELECT id, workflow, status, started_at, completed_at, duration_ms, error\n FROM workflow_runs ORDER BY started_at DESC LIMIT ?`,\n [limit]);\n}\n",
|
|
7
|
+
"workflow-entrypoint.ts": "import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from \"cloudflare:workers\";\nimport { getWorkflow, type WorkflowHandler, HttpError } from \"./workflow.js\";\nimport type { AgentInvokeOptions, AgentResult, WaitForOptions, WaitForResult, HttpOptions, HttpResult } from \"./workflow.js\";\nimport type { SearchOptions, SearchResult, AskOptions, AskResult } from \"./context.js\";\nimport type { CollectOptions, FormSchema, FormPage, FormAccess } from \"./form-types.js\";\nimport { scoreComplexity, resolveModel, parseModelSpec, resolveBilling } from \"./ai-router.js\";\nimport type { Tier, RoutingConfig, BillingConfig } from \"./ai-router.js\";\n\ninterface MugWorkflowEnv {\n WORKFLOWS: unknown;\n WORKSPACE_ID: string;\n MUG_INTERNAL_SECRET: string;\n MUG_DATA?: Fetcher;\n MUG_AI?: Fetcher;\n MUG_NOTIFY?: Fetcher;\n MUG_DISPATCH?: Fetcher;\n MUG_AI_ROUTING?: string;\n MUG_AI_BILLING?: string;\n MUG_BRANDING?: string;\n SLACK_BOT_TOKEN?: string;\n VECTORIZE?: VectorizeIndex;\n}\n\ninterface MugWorkflowParams {\n workspace: string;\n workflow: string;\n [key: string]: unknown;\n}\n\ninterface AiOptions {\n prompt: string;\n system?: string;\n maxTokens?: number;\n routing?: RoutingConfig;\n billing?: string;\n}\n\ninterface AiResponse {\n text: string;\n model: string;\n usage: { input_tokens: number; output_tokens: number };\n routing?: {\n tier: Tier;\n model: string;\n provider: string;\n reason: string;\n };\n}\n\ninterface NotifyOptions {\n to: string;\n message: string;\n subject?: string;\n fromName?: string;\n cta?: { label: string; url: string };\n blocks?: unknown[];\n thread_ts?: string;\n unfurl_links?: boolean;\n unfurl_media?: boolean;\n}\n\nfunction titleCase(slug: string): string {\n return (slug ?? \"Mug\").split(\"-\").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(\" \");\n}\n\ninterface StepLog {\n step_name: string;\n step_type: string;\n started_at: string;\n completed_at?: string;\n duration_ms?: number;\n input?: string;\n output?: string;\n error?: string;\n tokens_used?: number;\n}\n\nfunction truncate(value: unknown, maxLen = 4096): string {\n const s = JSON.stringify(value);\n return s.length > maxLen ? s.slice(0, maxLen) + \"…\" : s;\n}\n\nclass DurableWorkflowContext {\n private env: MugWorkflowEnv;\n private step: WorkflowStep;\n private stepCounter = 0;\n readonly stepLogs: StepLog[] = [];\n params: Record<string, unknown> = {};\n changesetId?: string;\n changesetSource?: string;\n instanceId?: string;\n private responded = false;\n\n constructor(env: MugWorkflowEnv, step: WorkflowStep) {\n this.env = env;\n this.step = step;\n }\n\n secret(name: string): string {\n const val = (this.env as unknown as Record<string, unknown>)[name];\n if (typeof val !== \"string\") throw new Error(`Secret \"${name}\" not found`);\n return val;\n }\n\n get isDemo(): boolean {\n return this.params._demo === true;\n }\n\n private get demoNotify(): { mode: string; identity: string; overrides?: Record<string, string>; devEmail?: string } | null {\n return (this.params._demoNotify as { mode: string; identity: string; overrides?: Record<string, string>; devEmail?: string }) ?? null;\n }\n\n private resolveDemoRecipient(channel: \"email\" | \"sms\" | \"slack\", originalTo: string): string | null {\n const cfg = this.demoNotify;\n if (!cfg) return originalTo;\n if (cfg.overrides?.[channel]) return cfg.overrides[channel];\n switch (cfg.mode) {\n case \"off\":\n return null;\n case \"demo-user\": {\n const isEmail = cfg.identity.includes(\"@\");\n if (channel === \"email\" && isEmail) return cfg.identity;\n if (channel === \"sms\" && !isEmail) return cfg.identity;\n return null;\n }\n case \"dev\": {\n if (channel === \"email\" && cfg.devEmail) return cfg.devEmail;\n return null;\n }\n default:\n return null;\n }\n }\n\n private nextStep(type: string, target: string): string {\n this.stepCounter++;\n return `${type}-${target}-${this.stepCounter}`;\n }\n\n private recordStep(name: string, type: string, startMs: number, opts?: { input?: unknown; output?: unknown; error?: string; tokensUsed?: number }): void {\n const now = Date.now();\n const log: StepLog = {\n step_name: name,\n step_type: type,\n started_at: new Date(startMs).toISOString(),\n completed_at: new Date(now).toISOString(),\n duration_ms: now - startMs,\n };\n if (opts?.input != null) log.input = truncate(opts.input);\n if (opts?.output != null) log.output = truncate(opts.output);\n if (opts?.error) log.error = opts.error;\n if (opts?.tokensUsed) log.tokens_used = opts.tokensUsed;\n this.stepLogs.push(log);\n }\n\n private internalHeaders(): Record<string, string> {\n return {\n \"Content-Type\": \"application/json\",\n \"X-Mug-Internal\": this.env.MUG_INTERNAL_SECRET ?? \"\",\n };\n }\n\n async query(database: string, sql: string, params?: (string | number | null)[]): Promise<Record<string, unknown>[]> {\n const name = this.nextStep(\"query\", database);\n const start = Date.now();\n try {\n const rows = await (this.step as any).do(name, async () => {\n const res = await this.env.MUG_DATA!.fetch(\n `https://mug-data/workspace/${this.env.WORKSPACE_ID}/db/${database}/query`,\n {\n method: \"POST\",\n body: JSON.stringify({ sql, params }),\n headers: this.internalHeaders(),\n },\n );\n const data = (await res.json()) as { rows: Record<string, unknown>[] };\n return data.rows;\n });\n this.recordStep(name, \"query\", start, { input: { sql, params }, output: `${rows.length} rows` });\n return rows;\n } catch (e) {\n this.recordStep(name, \"query\", start, { input: { sql, params }, error: (e as Error).message });\n throw e;\n }\n }\n\n async exec(database: string, sql: string, params?: (string | number | null)[]): Promise<number> {\n const name = this.nextStep(\"exec\", database);\n const start = Date.now();\n try {\n const changes = await (this.step as any).do(name, async () => {\n const headers = this.internalHeaders();\n if (this.changesetId) headers[\"X-Changeset-Id\"] = this.changesetId;\n if (this.changesetSource) headers[\"X-Changeset-Source\"] = this.changesetSource;\n const res = await this.env.MUG_DATA!.fetch(\n `https://mug-data/workspace/${this.env.WORKSPACE_ID}/db/${database}/exec`,\n {\n method: \"POST\",\n body: JSON.stringify({ sql, params }),\n headers,\n },\n );\n const data = (await res.json()) as { changes: number };\n return data.changes;\n });\n if (changes > 0) {\n await this.meterIncrement(\"operations\", 1, `workflow:exec-${database}`);\n }\n this.recordStep(name, \"exec\", start, { input: { sql, params }, output: `${changes} changes` });\n return changes;\n } catch (e) {\n this.recordStep(name, \"exec\", start, { input: { sql, params }, error: (e as Error).message });\n throw e;\n }\n }\n\n private getWorkspaceRouting(): RoutingConfig | undefined {\n if (!this.env.MUG_AI_ROUTING) return undefined;\n try { return JSON.parse(this.env.MUG_AI_ROUTING); } catch { return undefined; }\n }\n\n private getWorkspaceBilling(): BillingConfig | undefined {\n if (!this.env.MUG_AI_BILLING) return undefined;\n try { return JSON.parse(this.env.MUG_AI_BILLING); } catch { return undefined; }\n }\n\n private async meterCheck(dimension: string): Promise<{ allowed: boolean; used: number; limit: number; remaining: number }> {\n if (!this.env.MUG_DISPATCH) return { allowed: true, used: 0, limit: 0, remaining: 0 };\n const res = await this.env.MUG_DISPATCH.fetch(`https://mug-dispatch/meter/${this.env.WORKSPACE_ID}/check`, {\n method: \"POST\",\n body: JSON.stringify({ dimension }),\n headers: this.internalHeaders(),\n });\n return res.json() as any;\n }\n\n private async meterIncrement(dimension: string, delta: number, source?: string): Promise<void> {\n if (!this.env.MUG_DISPATCH) return;\n await this.env.MUG_DISPATCH.fetch(`https://mug-dispatch/meter/${this.env.WORKSPACE_ID}/increment`, {\n method: \"POST\",\n body: JSON.stringify({ dimension, delta, source }),\n headers: this.internalHeaders(),\n }).catch(() => {});\n }\n\n async ai(model: string, options: AiOptions): Promise<AiResponse> {\n const name = this.nextStep(\"ai\", model);\n const start = Date.now();\n try {\n const data = await (this.step as any).do(name, { retries: { limit: 2, delay: \"5 seconds\", backoff: \"exponential\" } }, async () => {\n if (!this.env.MUG_AI) throw new Error(\"AI not configured: missing MUG_AI service binding\");\n\n let provider: string;\n let resolvedModel: string;\n let tier: Tier | null = null;\n let routingMeta: AiResponse[\"routing\"] | undefined;\n\n const tierNames = [\"fast\", \"balanced\", \"powerful\"] as const;\n if (model === \"auto\") {\n const score = scoreComplexity(options.prompt, options);\n tier = score.tier;\n const resolved = resolveModel(tier, options.routing, this.getWorkspaceRouting());\n provider = resolved.provider;\n resolvedModel = resolved.model;\n routingMeta = { tier, model: resolvedModel, provider, reason: score.reason };\n } else if (tierNames.includes(model as Tier)) {\n tier = model as Tier;\n const resolved = resolveModel(tier, options.routing, this.getWorkspaceRouting());\n provider = resolved.provider;\n resolvedModel = resolved.model;\n routingMeta = { tier, model: resolvedModel, provider, reason: `tier:${tier}` };\n } else {\n const parsed = parseModelSpec(model);\n provider = parsed.provider;\n resolvedModel = parsed.model;\n }\n\n const billingKey = resolveBilling(tier, options.billing, undefined, this.getWorkspaceBilling());\n const billing = billingKey !== \"mug-metered\"\n ? (this.env as unknown as Record<string, string>)[billingKey] ?? billingKey\n : billingKey;\n\n const aiCheck = await this.meterCheck(\"ai_credits\");\n if (aiCheck.remaining === 0 && aiCheck.limit > 0) {\n throw new Error(`AI credit limit exceeded: ${aiCheck.used}/${aiCheck.limit}`);\n }\n\n const res = await this.env.MUG_AI.fetch(\n \"https://mug-ai/complete\",\n {\n method: \"POST\",\n body: JSON.stringify({\n workspace: this.env.WORKSPACE_ID,\n provider,\n model: resolvedModel,\n prompt: options.prompt,\n system: options.system,\n maxTokens: options.maxTokens,\n routing: routingMeta ? { tier: routingMeta.tier, reason: routingMeta.reason } : undefined,\n billing,\n }),\n headers: this.internalHeaders(),\n },\n );\n if (!res.ok) throw new Error(`AI request failed (${res.status}): ${await res.text()}`);\n\n const result = (await res.json()) as AiResponse;\n if (routingMeta) result.routing = routingMeta;\n\n const tokens = result.usage.input_tokens + result.usage.output_tokens;\n const credits = Math.ceil(tokens / 1000);\n if (credits > 0 && billing === \"mug-metered\") {\n await this.meterIncrement(\"ai_credits\", credits, `workflow:ai-${resolvedModel}`);\n }\n\n return result;\n });\n this.recordStep(name, \"ai\", start, {\n input: { prompt: options.prompt.slice(0, 200) },\n output: data.text.slice(0, 200),\n tokensUsed: data.usage.input_tokens + data.usage.output_tokens,\n });\n return data;\n } catch (e) {\n this.recordStep(name, \"ai\", start, { input: { prompt: options.prompt.slice(0, 200) }, error: (e as Error).message });\n throw e;\n }\n }\n\n async search(query: string, options?: SearchOptions): Promise<SearchResult[]> {\n const name = this.nextStep(\"search\", options?.source ?? \"all\");\n const start = Date.now();\n try {\n const results = await (this.step as any).do(name, async () => {\n if (!this.env.VECTORIZE) throw new Error(\"Semantic search not available — no VECTORIZE binding\");\n if (!this.env.MUG_AI) throw new Error(\"Search not available — no MUG_AI binding\");\n\n const limit = Math.min(options?.limit ?? 10, 50);\n\n const embedRes = await this.env.MUG_AI.fetch(\"https://mug-ai/embed\", {\n method: \"POST\",\n body: JSON.stringify({ workspace: this.env.WORKSPACE_ID, texts: [query] }),\n headers: this.internalHeaders(),\n });\n if (!embedRes.ok) throw new Error(`Embed failed: ${await embedRes.text()}`);\n const { vectors } = (await embedRes.json()) as { vectors: number[][] };\n\n const filter: Record<string, string> = { ...options?.filter };\n if (options?.source) filter.table = options.source;\n\n const matches = await this.env.VECTORIZE!.query(vectors[0], {\n topK: limit * 2,\n returnMetadata: \"all\",\n ...(Object.keys(filter).length > 0 ? { filter } : {}),\n });\n\n interface VectorMeta { database: string; table: string; pk_column: string; primary_key: string }\n const best = new Map<string, { score: number; meta: VectorMeta }>();\n for (const match of matches.matches) {\n const meta = match.metadata as VectorMeta | undefined;\n if (!meta?.table || !meta?.primary_key) continue;\n const key = `${meta.table}:${meta.primary_key}`;\n const existing = best.get(key);\n if (!existing || match.score > existing.score) {\n best.set(key, { score: match.score, meta });\n }\n }\n\n const searchResults: SearchResult[] = [];\n for (const [, { score, meta }] of best) {\n if (searchResults.length >= limit) break;\n try {\n const qRes = await this.env.MUG_DATA!.fetch(\n `https://mug-data/workspace/${this.env.WORKSPACE_ID}/db/${meta.database}/query`,\n {\n method: \"POST\",\n body: JSON.stringify({\n sql: `SELECT * FROM \"${meta.table}\" WHERE \"${meta.pk_column}\" = ? AND _mug_deleted_at IS NULL`,\n params: [meta.primary_key],\n }),\n headers: this.internalHeaders(),\n },\n );\n const { rows } = (await qRes.json()) as { rows: Record<string, unknown>[] };\n searchResults.push({ score, table: meta.table, primaryKey: meta.primary_key, row: rows[0] ?? {} });\n } catch {\n searchResults.push({ score, table: meta.table, primaryKey: meta.primary_key, row: {} });\n }\n }\n\n return searchResults;\n });\n this.recordStep(name, \"search\", start, {\n input: { query: query.slice(0, 200), source: options?.source },\n output: `${results.length} results`,\n });\n return results;\n } catch (e) {\n this.recordStep(name, \"search\", start, { input: { query: query.slice(0, 200) }, error: (e as Error).message });\n throw e;\n }\n }\n\n async ask(question: string, options?: AskOptions): Promise<AskResult> {\n const name = this.nextStep(\"ask\", options?.source ?? \"all\");\n const start = Date.now();\n try {\n const result = await (this.step as any).do(name, async () => {\n const sources = await this.search(question, {\n source: options?.source,\n limit: options?.limit ?? 10,\n });\n\n const contextParts: string[] = [];\n let tokenEstimate = 0;\n for (const r of sources) {\n const entry = `[${r.table}:${r.primaryKey} score=${r.score.toFixed(3)}]\\n${JSON.stringify(r.row)}`;\n const entryTokens = Math.ceil(entry.split(/\\s+/).length * 1.3);\n if (tokenEstimate + entryTokens > 3000) break;\n contextParts.push(entry);\n tokenEstimate += entryTokens;\n }\n\n const baseSystem = \"Answer the question based on the following business data. Cite which records informed your answer. If the data does not contain enough information, say so.\";\n const dataBlock = `\\n\\n--- Business Data ---\\n${contextParts.join(\"\\n\\n\")}`;\n const system = (options?.system ? `${options.system}\\n\\n${baseSystem}` : baseSystem) + dataBlock;\n\n const aiResult = await this.ai(options?.model ?? \"balanced\", {\n prompt: question,\n system,\n });\n\n return {\n answer: aiResult.text,\n sources,\n usage: {\n input_tokens: aiResult.usage.input_tokens,\n output_tokens: aiResult.usage.output_tokens,\n search_results: sources.length,\n },\n } as AskResult;\n });\n this.recordStep(name, \"ask\", start, {\n input: { question: question.slice(0, 200), source: options?.source },\n output: result.answer.slice(0, 200),\n tokensUsed: result.usage.input_tokens + result.usage.output_tokens,\n });\n return result;\n } catch (e) {\n this.recordStep(name, \"ask\", start, { input: { question: question.slice(0, 200) }, error: (e as Error).message });\n throw e;\n }\n }\n\n surfaceUrl(surfaceId: string, path?: string): string {\n return `https://${this.env.WORKSPACE_ID}.mug.work/${surfaceId}${path ?? \"\"}`;\n }\n\n private getBranding(): { logo?: string; logoSquare?: string; accentColor?: string } | undefined {\n if (!this.env.MUG_BRANDING) return undefined;\n try { return JSON.parse(this.env.MUG_BRANDING); } catch { return undefined; }\n }\n\n get notify() {\n return {\n sms: (options: NotifyOptions) => this.sendNotification(\"sms\", options),\n email: (options: NotifyOptions) => this.sendNotification(\"email\", options),\n slack: (options: NotifyOptions) => this.sendNotification(\"slack\", options),\n };\n }\n\n private async sendNotification(channel: string, options: NotifyOptions): Promise<void> {\n const name = this.nextStep(\"notify\", channel);\n const start = Date.now();\n\n if (this.isDemo) {\n const resolved = this.resolveDemoRecipient(channel as \"email\" | \"sms\" | \"slack\", options.to);\n if (resolved === null) {\n this.recordStep(name, \"notify\", start, { input: { to: options.to }, output: `suppressed (demo mode: ${this.demoNotify?.mode ?? \"off\"})` });\n return;\n }\n options = { ...options, to: resolved };\n }\n\n try {\n await (this.step as any).do(name, { retries: { limit: 2, delay: \"5 seconds\", backoff: \"exponential\" } }, async () => {\n if (this.env.MUG_NOTIFY) {\n if (channel === \"sms\" || channel === \"email\") {\n const dimension = channel === \"sms\" ? \"sms\" : \"email\";\n const check = await this.meterCheck(dimension);\n if (!check.allowed) {\n throw new Error(`Usage limit exceeded for ${dimension}: ${check.used}/${check.limit}`);\n }\n }\n\n const fromName = options.fromName ?? titleCase(this.env.WORKSPACE_ID);\n const res = await this.env.MUG_NOTIFY.fetch(\n \"https://mug-notify/send\",\n {\n method: \"POST\",\n body: JSON.stringify({\n workspace: this.env.WORKSPACE_ID,\n channel,\n to: options.to,\n message: options.message,\n subject: options.subject,\n fromName,\n cta: options.cta,\n branding: this.getBranding(),\n ...(channel === \"slack\" ? {\n blocks: options.blocks,\n thread_ts: options.thread_ts,\n unfurl_links: options.unfurl_links,\n unfurl_media: options.unfurl_media,\n slackBotToken: this.env.SLACK_BOT_TOKEN,\n } : {}),\n }),\n headers: this.internalHeaders(),\n },\n );\n if (!res.ok) {\n const body = await res.text().catch(() => \"\");\n throw new Error(`Notify failed (${res.status}): ${body}`);\n }\n if (channel === \"sms\" || channel === \"email\") {\n await this.meterIncrement(channel === \"sms\" ? \"sms\" : \"email\", 1, `workflow:notify-${channel}`);\n }\n } else {\n console.log(`[notify:${channel}] to=${options.to} message=${options.message}`);\n }\n });\n this.recordStep(name, \"notify\", start, { input: { to: options.to }, output: \"sent\" });\n } catch (e) {\n this.recordStep(name, \"notify\", start, { input: { to: options.to }, error: (e as Error).message });\n throw e;\n }\n }\n\n get slack() {\n return {\n updateMessage: (options: { channel: string; ts: string; text?: string; blocks?: unknown[] }) =>\n this.slackUpdateMessage(options),\n openModal: (options: { triggerId: string; view: Record<string, unknown> }) =>\n this.slackOpenModal(options),\n };\n }\n\n private async slackUpdateMessage(options: { channel: string; ts: string; text?: string; blocks?: unknown[] }): Promise<void> {\n const name = this.nextStep(\"slack\", \"updateMessage\");\n const start = Date.now();\n try {\n await (this.step as any).do(name, async () => {\n const token = this.env.SLACK_BOT_TOKEN;\n if (!token) throw new Error(\"SLACK_BOT_TOKEN not configured\");\n const res = await fetch(\"https://slack.com/api/chat.update\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json; charset=utf-8\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({\n channel: options.channel,\n ts: options.ts,\n text: options.text ?? \"\",\n ...(options.blocks ? { blocks: options.blocks } : {}),\n }),\n });\n const data = await res.json() as { ok: boolean; error?: string };\n if (!data.ok) throw new Error(`Slack chat.update failed: ${data.error ?? \"unknown\"}`);\n });\n this.recordStep(name, \"slack.updateMessage\", start, { input: { channel: options.channel, ts: options.ts }, output: \"updated\" });\n } catch (e) {\n this.recordStep(name, \"slack.updateMessage\", start, { input: { channel: options.channel, ts: options.ts }, error: (e as Error).message });\n throw e;\n }\n }\n\n private async slackOpenModal(options: { triggerId: string; view: Record<string, unknown> }): Promise<void> {\n const name = this.nextStep(\"slack\", \"openModal\");\n const start = Date.now();\n try {\n await (this.step as any).do(name, async () => {\n const token = this.env.SLACK_BOT_TOKEN;\n if (!token) throw new Error(\"SLACK_BOT_TOKEN not configured\");\n const res = await fetch(\"https://slack.com/api/views.open\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json; charset=utf-8\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({\n trigger_id: options.triggerId,\n view: options.view,\n }),\n });\n const data = await res.json() as { ok: boolean; error?: string };\n if (!data.ok) throw new Error(`Slack views.open failed: ${data.error ?? \"unknown\"}`);\n });\n this.recordStep(name, \"slack.openModal\", start, { output: \"opened\" });\n } catch (e) {\n this.recordStep(name, \"slack.openModal\", start, { error: (e as Error).message });\n throw e;\n }\n }\n\n async respond(body: unknown, status?: number): Promise<void> {\n if (this.responded) return;\n this.responded = true;\n if (!this.env.MUG_DISPATCH || !this.instanceId) return;\n await (this.step as any).do(this.nextStep(\"respond\", \"webhook\"), async () => {\n await this.env.MUG_DISPATCH!.fetch(\"https://mug-dispatch/_internal/webhook-respond\", {\n method: \"POST\",\n headers: this.internalHeaders(),\n body: JSON.stringify({\n workspace: this.env.WORKSPACE_ID,\n instanceId: this.instanceId,\n body,\n status: status ?? 200,\n }),\n });\n });\n }\n\n async agent(name: string, options: AgentInvokeOptions): Promise<AgentResult> {\n const stepName = this.nextStep(\"agent\", name);\n const start = Date.now();\n const sessionKey = options.sessionKey ?? `${this.changesetId ?? \"run\"}-${name}`;\n try {\n const data = await (this.step as any).do(stepName, async () => {\n if (!this.env.MUG_DISPATCH) throw new Error(\"Agent not configured: missing MUG_DISPATCH binding\");\n\n const res = await (this.env.MUG_DISPATCH as Fetcher).fetch(\n `https://mug-dispatch/agent/${this.env.WORKSPACE_ID}/invoke`,\n {\n method: \"POST\",\n headers: this.internalHeaders(),\n body: JSON.stringify({ agent: name, goal: options.goal, context: options.context, sessionKey, caps: options.caps }),\n },\n );\n\n if (!res.ok) {\n const errText = await res.text();\n throw new Error(`Agent \"${name}\" failed: ${errText}`);\n }\n\n return res.json();\n });\n\n if (data.status === \"pending_approval\" && data.pendingApproval) {\n this.recordStep(stepName, \"agent\", start, {\n input: { goal: options.goal.slice(0, 200) },\n output: `pending approval: ${data.pendingApproval.tool}`,\n });\n\n const approvalEvent = `agent-approval-${sessionKey}`;\n const callbackUrl = await this.waitForUrl(approvalEvent);\n const approveUrl = `${callbackUrl}?action=approve`;\n\n await (this.step as any).do(this.nextStep(\"notify\", \"agent-approval\"), { retries: { limit: 1, delay: \"2 seconds\" } }, async () => {\n if (this.env.MUG_NOTIFY) {\n await (this.env.MUG_NOTIFY as Fetcher).fetch(\"https://mug-notify/send\", {\n method: \"POST\",\n headers: this.internalHeaders(),\n body: JSON.stringify({\n workspace: this.env.WORKSPACE_ID,\n channel: \"email\",\n to: (options as any).approvalNotify ?? this.env.WORKSPACE_ID,\n subject: `Approval needed: ${data.pendingApproval.tool}`,\n message: `Agent \"${name}\" wants to execute ${data.pendingApproval.tool}.\\n\\n${JSON.stringify(data.pendingApproval.args, null, 2)}`,\n cta: { label: \"Approve\", url: approveUrl },\n }),\n });\n }\n });\n\n const event = await this.waitFor<{ action?: string }>(approvalEvent, { timeout: \"24 hours\" });\n const approved = !event.timedOut && event.payload?.action === \"approve\";\n\n if (event.timedOut) {\n return {\n response: data.response,\n output: data.output ?? undefined,\n usage: { credits: data.usage?.credits ?? 0, turns: data.usage?.turns ?? 0, duration: Date.now() - start },\n capped: true,\n cappedReason: \"approval_timeout\",\n pendingApproval: { tool: data.pendingApproval.tool, args: data.pendingApproval.args, sessionKey },\n };\n }\n\n const resumeData = await (this.step as any).do(this.nextStep(\"agent-resume\", name), async () => {\n const res = await (this.env.MUG_DISPATCH as Fetcher).fetch(\n `https://mug-dispatch/agent/${this.env.WORKSPACE_ID}/approve`,\n {\n method: \"POST\",\n headers: this.internalHeaders(),\n body: JSON.stringify({ sessionKey, approved }),\n },\n );\n if (!res.ok) throw new Error(`Agent approve failed: ${await res.text()}`);\n return res.json();\n });\n\n const totalCredits = (data.usage?.credits ?? 0) + (resumeData.usage?.credits ?? 0);\n const totalTurns = (data.usage?.turns ?? 0) + (resumeData.usage?.turns ?? 0);\n const result: AgentResult = {\n response: resumeData.response,\n output: resumeData.output ?? data.output ?? undefined,\n usage: { credits: totalCredits, turns: totalTurns, duration: Date.now() - start },\n capped: resumeData.capped,\n cappedReason: resumeData.cappedReason,\n };\n\n this.recordStep(this.nextStep(\"agent-complete\", name), \"agent\", start, {\n input: { goal: options.goal.slice(0, 200) },\n output: resumeData.response?.slice(0, 200),\n });\n\n return result;\n }\n\n const duration = Date.now() - start;\n const result: AgentResult = {\n response: data.response,\n output: data.output ?? undefined,\n usage: { credits: data.usage?.credits ?? 0, turns: data.usage?.turns ?? 0, duration },\n capped: data.capped,\n cappedReason: data.cappedReason,\n };\n\n this.recordStep(stepName, \"agent\", start, {\n input: { goal: options.goal.slice(0, 200) },\n output: data.response?.slice(0, 200),\n });\n\n return result;\n } catch (e) {\n this.recordStep(stepName, \"agent\", start, {\n input: { goal: options.goal.slice(0, 200) },\n error: (e as Error).message,\n });\n throw e;\n }\n }\n\n async collect(options: CollectOptions): Promise<string> {\n const name = this.nextStep(\"collect\", options.title);\n return (this.step as any).do(name, async () => {\n const surfaceId = options.id ?? crypto.randomUUID().slice(0, 8);\n const workspace = this.env.WORKSPACE_ID;\n\n const pages: FormPage[] = options.pages ?? [{\n id: \"main\",\n fields: options.fields ?? [],\n }];\n\n const access: FormAccess = options.access ?? { mode: \"public\" };\n\n const schema: FormSchema = {\n title: options.title,\n description: options.description,\n submitText: options.submitText,\n pages,\n access,\n editMode: options.editMode,\n workflow: options.workflow,\n };\n\n const surfaceConfig = { workspace, surfaceId, ...schema };\n\n await fetch(\"https://api.mug.work/deploy-surface\", {\n method: \"POST\",\n body: JSON.stringify(surfaceConfig),\n headers: this.internalHeaders(),\n });\n\n return `https://${workspace}.mug.work/${surfaceId}`;\n });\n }\n\n async waitFor<T = unknown>(eventName: string, options?: WaitForOptions): Promise<WaitForResult<T>> {\n const name = this.nextStep(\"waitFor\", eventName);\n const start = Date.now();\n try {\n const timeout = options?.timeout ?? \"24 hours\";\n const cfTimeout = typeof timeout === \"number\" ? `${timeout} seconds` : timeout;\n const event = await (this.step.waitForEvent as Function)(name, {\n type: eventName,\n timeout: cfTimeout,\n }) as { payload: T; type: string };\n const result: WaitForResult<T> = {\n payload: event.payload as T,\n type: event.type,\n timedOut: false,\n };\n this.recordStep(name, \"waitFor\", start, { output: truncate(result) });\n return result;\n } catch (e) {\n const msg = (e as Error).message ?? \"\";\n if (msg.toLowerCase().includes(\"timeout\") || msg.toLowerCase().includes(\"timed out\")) {\n const result: WaitForResult<T> = { payload: undefined as T, type: eventName, timedOut: true };\n this.recordStep(name, \"waitFor\", start, { output: \"timed out\" });\n return result;\n }\n this.recordStep(name, \"waitFor\", start, { error: msg });\n throw e;\n }\n }\n\n async waitForUrl(eventName: string): Promise<string> {\n const name = this.nextStep(\"callbackUrl\", eventName);\n return (this.step as any).do(name, async () => {\n if (!this.instanceId) throw new Error(\"Instance ID not available — waitForUrl requires a running workflow\");\n const token = crypto.randomUUID();\n if (this.env.MUG_DISPATCH) {\n await (this.env.MUG_DISPATCH as Fetcher).fetch(\"https://mug-dispatch/_internal/event-callback\", {\n method: \"POST\",\n headers: this.internalHeaders(),\n body: JSON.stringify({\n token,\n workspace: this.env.WORKSPACE_ID,\n instanceId: this.instanceId,\n eventType: eventName,\n }),\n });\n }\n return `https://api.mug.work/_callback/${token}`;\n });\n }\n\n async http(url: string, options?: HttpOptions): Promise<HttpResult> {\n const method = (options?.method ?? \"GET\").toUpperCase();\n const name = this.nextStep(\"http\", new URL(url).hostname);\n const start = Date.now();\n const timeout = options?.timeout ?? 30000;\n const throwOnError = options?.throwOnError !== false;\n const retryConfig = options?.retry;\n const shouldRetry = retryConfig !== false;\n const maxAttempts = shouldRetry && typeof retryConfig === \"object\" && retryConfig?.attempts\n ? retryConfig.attempts\n : (shouldRetry ? 3 : 1);\n\n try {\n const result = await (this.step as any).do(name, async () => {\n const headers: Record<string, string> = { ...options?.headers };\n let bodyStr: string | undefined;\n if (options?.body != null) {\n bodyStr = typeof options.body === \"string\" ? options.body : JSON.stringify(options.body);\n if (!headers[\"Content-Type\"] && !headers[\"content-type\"]) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n }\n\n if (options?.sign) {\n const secretValue = this.secret(options.sign.secret);\n const encoder = new TextEncoder();\n const key = await crypto.subtle.importKey(\n \"raw\", encoder.encode(secretValue), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"],\n );\n const sig = await crypto.subtle.sign(\"HMAC\", key, encoder.encode(bodyStr ?? \"\"));\n const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, \"0\")).join(\"\");\n headers[options.sign.header ?? \"X-Hub-Signature-256\"] = `sha256=${hex}`;\n }\n\n let lastError: Error | undefined;\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeout);\n const res = await fetch(url, {\n method,\n headers,\n body: bodyStr,\n signal: controller.signal,\n });\n clearTimeout(timer);\n\n const resHeaders: Record<string, string> = {};\n res.headers.forEach((v, k) => { resHeaders[k] = v; });\n const resBody = await res.text();\n let json: unknown = null;\n const ct = resHeaders[\"content-type\"] ?? \"\";\n if (ct.includes(\"json\")) {\n try { json = JSON.parse(resBody); } catch {}\n }\n\n const httpResult: HttpResult = {\n status: res.status,\n headers: resHeaders,\n body: resBody,\n json,\n ok: res.status >= 200 && res.status < 300,\n };\n\n if (res.status === 429 && shouldRetry && attempt < maxAttempts) {\n const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);\n await new Promise(r => setTimeout(r, delay));\n continue;\n }\n\n return httpResult;\n } catch (e) {\n lastError = e as Error;\n const isRetryable = lastError.name === \"AbortError\" || lastError.message?.includes(\"fetch failed\");\n if (isRetryable && shouldRetry && attempt < maxAttempts) {\n const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);\n await new Promise(r => setTimeout(r, delay));\n continue;\n }\n throw lastError;\n }\n }\n throw lastError ?? new Error(\"ctx.http() failed after retries\");\n });\n\n await this.meterIncrement(\"operations\", 1, `workflow:http-${method.toLowerCase()}`);\n\n this.recordStep(name, \"http\", start, {\n input: { method, url },\n output: `${result.status} ${result.body.slice(0, 200)}`,\n });\n\n if (!result.ok && throwOnError) {\n throw new HttpError(result);\n }\n return result;\n } catch (e) {\n if (e instanceof HttpError) {\n this.recordStep(name, \"http\", start, {\n input: { method, url },\n output: `${e.status} (error thrown)`,\n });\n throw e;\n }\n this.recordStep(name, \"http\", start, {\n input: { method, url },\n error: (e as Error).message,\n });\n throw e;\n }\n }\n}\n\nexport class MugWorkflow extends WorkflowEntrypoint<MugWorkflowEnv, MugWorkflowParams> {\n async run(event: WorkflowEvent<MugWorkflowParams>, step: WorkflowStep) {\n const workflowName = event.payload.workflow;\n const def = getWorkflow(workflowName);\n if (!def) throw new Error(`Workflow \"${workflowName}\" not registered`);\n\n const workspace = this.env.WORKSPACE_ID;\n const runId = `${workspace}-${workflowName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n const startedAt = new Date();\n const ctx = new DurableWorkflowContext(this.env, step);\n ctx.changesetId = runId;\n ctx.changesetSource = `workflow:${workflowName}`;\n ctx.instanceId = event.instanceId;\n ctx.params = event.payload;\n\n let result: unknown;\n let error: string | undefined;\n let status: \"complete\" | \"errored\" = \"complete\";\n try {\n result = await def.handler(ctx as any);\n } catch (e) {\n error = (e as Error).message;\n status = \"errored\";\n }\n\n const completedAt = new Date();\n const runLog = {\n id: runId,\n workflow: workflowName,\n status,\n started_at: startedAt.toISOString(),\n completed_at: completedAt.toISOString(),\n duration_ms: completedAt.getTime() - startedAt.getTime(),\n params: JSON.stringify(event.payload),\n result: result != null ? JSON.stringify(result) : undefined,\n error,\n steps: ctx.stepLogs,\n };\n\n await (step as any).do(\"ops-log\", async () => {\n const res = await fetch(`https://api.mug.work/ops/${workspace}/log-run`, {\n method: \"POST\",\n body: JSON.stringify(runLog),\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-Mug-Internal\": this.env.MUG_INTERNAL_SECRET,\n },\n });\n if (!res.ok) console.error(`[ops] Log failed: ${res.status}`);\n });\n\n if (error) throw new Error(error);\n return result;\n }\n}\n\nexport default {\n async fetch(request: Request, env: MugWorkflowEnv): Promise<Response> {\n const url = new URL(request.url);\n if (url.pathname === \"/create-workflow\" && request.method === \"POST\") {\n const body = await request.json() as Record<string, unknown>;\n const workflowName = body.workflow as string;\n const workspace = env.WORKSPACE_ID;\n const instanceId = `${workspace}-${workflowName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n\n const wf = env.WORKFLOWS as any;\n const instance = await wf.create({\n id: instanceId,\n params: { workspace, workflow: workflowName, ...body },\n });\n\n return Response.json({\n status: \"created\",\n instanceId: await instance.id,\n workspace,\n workflow: workflowName,\n });\n }\n return Response.json({ error: \"Not found\" }, { status: 404 });\n },\n};\n",
|
|
8
|
+
"sync-runtime.ts": "import type {\n SourceDef,\n SourceContext,\n TableDef,\n PaginationConfig,\n RateLimitConfig,\n ErrorRetryConfig,\n} from \"./source.js\";\n\nexport interface SyncResult {\n table: string;\n rows: Record<string, unknown>[];\n syncedAt: string;\n incremental: boolean;\n pages?: number;\n}\n\nexport async function syncTable(\n def: SourceDef,\n table: TableDef,\n ctx: SourceContext,\n): Promise<SyncResult> {\n const syncedAt = new Date().toISOString();\n\n if (table.endpoint && table.pagination) {\n const rows = await fetchWithPagination(def, table, ctx);\n return { table: table.name, rows, syncedAt, incremental: !!table.sync?.filterParam && !!ctx.lastSync };\n }\n\n const rows = await fetchWithRetry(def, table, ctx);\n return { table: table.name, rows, syncedAt, incremental: false };\n}\n\nexport async function syncAll(def: SourceDef, ctx: SourceContext): Promise<SyncResult[]> {\n const results: SyncResult[] = [];\n for (const table of def.tables) {\n results.push(await syncTable(def, table, ctx));\n await rateLimitDelay(def.rateLimits);\n }\n return results;\n}\n\nasync function fetchWithPagination(\n def: SourceDef,\n table: TableDef,\n ctx: SourceContext,\n): Promise<Record<string, unknown>[]> {\n const baseUrl = def.baseUrl ?? \"\";\n const endpoint = table.endpoint ?? \"\";\n const pagination = table.pagination!;\n const extract = table.extractItems ?? defaultExtractItems;\n\n let url: string | null = buildSyncUrl(baseUrl + endpoint, table, ctx);\n const all: Record<string, unknown>[] = [];\n let pages = 0;\n\n while (url) {\n const body = await authedFetchJson(url, ctx, def.errorRetry);\n all.push(...extract(body));\n pages++;\n url = getNextPageUrl(body, url, pagination);\n if (url) await rateLimitDelay(def.rateLimits);\n }\n\n return all;\n}\n\nasync function fetchWithRetry(\n def: SourceDef,\n table: TableDef,\n ctx: SourceContext,\n): Promise<Record<string, unknown>[]> {\n const config = def.errorRetry ?? { maxRetries: 3, retryOn5xx: true, retryOn429: true, backoffMs: 1000 };\n const maxRetries = config.maxRetries ?? 3;\n let attempt = 0;\n\n while (true) {\n try {\n return await table.fetch(ctx);\n } catch (err) {\n attempt++;\n if (attempt >= maxRetries) throw err;\n if (!isRetryable(err, config)) throw err;\n await sleep((config.backoffMs ?? 1000) * Math.pow(2, attempt - 1));\n }\n }\n}\n\nasync function authedFetchJson(\n url: string,\n ctx: SourceContext,\n retryConfig?: ErrorRetryConfig,\n): Promise<unknown> {\n const config = retryConfig ?? { maxRetries: 3, retryOn5xx: true, retryOn429: true, backoffMs: 1000 };\n const maxRetries = config.maxRetries ?? 3;\n\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n const token = await ctx.credential(\"token\");\n const res = await fetch(url, {\n headers: { Authorization: `Bearer ${token}` },\n });\n\n if (res.ok) return res.json();\n\n if (res.status === 429 && config.retryOn429 !== false) {\n const retryAfter = parseRetryAfter(res.headers.get(\"retry-after\"));\n await sleep(retryAfter ?? (config.backoffMs ?? 1000) * Math.pow(2, attempt));\n continue;\n }\n\n if (res.status >= 500 && config.retryOn5xx !== false && attempt < maxRetries) {\n await sleep((config.backoffMs ?? 1000) * Math.pow(2, attempt));\n continue;\n }\n\n throw new Error(`${res.status} ${res.statusText}: ${url}`);\n }\n\n throw new Error(`Max retries exceeded: ${url}`);\n}\n\nfunction buildSyncUrl(base: string, table: TableDef, ctx: SourceContext): string {\n const sync = table.sync;\n if (!sync?.filterParam || !ctx.lastSync) return base;\n\n const sep = base.includes(\"?\") ? \"&\" : \"?\";\n let value = ctx.lastSync;\n if (sync.filterFormat === \"unix\") {\n value = String(Math.floor(new Date(ctx.lastSync).getTime() / 1000));\n } else if (sync.filterFormat === \"epoch_ms\") {\n value = String(new Date(ctx.lastSync).getTime());\n }\n return `${base}${sep}${sync.filterParam}=${encodeURIComponent(value)}`;\n}\n\nfunction getNextPageUrl(body: unknown, currentUrl: string, pagination: PaginationConfig): string | null {\n if (typeof body !== \"object\" || body === null) return null;\n const obj = body as Record<string, unknown>;\n\n switch (pagination.style) {\n case \"cursor\": {\n const path = pagination.cursorPath ?? \"next_cursor\";\n const cursor = deepGet(obj, path);\n if (!cursor) return null;\n const u = new URL(currentUrl);\n u.searchParams.set(pagination.cursorParam ?? \"cursor\", String(cursor));\n return u.toString();\n }\n case \"offset\": {\n const items = defaultExtractItems(body);\n const pageSize = pagination.defaultPageSize ?? 100;\n if (items.length < pageSize) return null;\n const u = new URL(currentUrl);\n const current = parseInt(u.searchParams.get(pagination.offsetParam ?? \"offset\") ?? \"0\");\n u.searchParams.set(pagination.offsetParam ?? \"offset\", String(current + pageSize));\n return u.toString();\n }\n case \"page\": {\n const u = new URL(currentUrl);\n const current = parseInt(u.searchParams.get(pagination.pageParam ?? \"page\") ?? \"1\");\n const total = (obj.total_pages ?? obj.totalPages) as number | undefined;\n if (total && current >= total) return null;\n const items = defaultExtractItems(body);\n if (items.length === 0) return null;\n u.searchParams.set(pagination.pageParam ?? \"page\", String(current + 1));\n return u.toString();\n }\n case \"link-header\":\n return null;\n }\n}\n\nfunction defaultExtractItems(body: unknown): Record<string, unknown>[] {\n if (Array.isArray(body)) return body;\n if (typeof body !== \"object\" || body === null) return [];\n const obj = body as Record<string, unknown>;\n for (const key of [\"data\", \"results\", \"records\", \"items\", \"entries\"]) {\n if (Array.isArray(obj[key])) return obj[key] as Record<string, unknown>[];\n }\n return [];\n}\n\nfunction deepGet(obj: Record<string, unknown>, path: string): unknown {\n const parts = path.split(\".\");\n let current: unknown = obj;\n for (const part of parts) {\n if (current === null || current === undefined || typeof current !== \"object\") return undefined;\n current = (current as Record<string, unknown>)[part];\n }\n return current;\n}\n\nasync function rateLimitDelay(config?: RateLimitConfig): Promise<void> {\n if (!config) return;\n const delay = config.requestsPerSecond\n ? Math.ceil(1000 / config.requestsPerSecond)\n : config.requestsPerMinute\n ? Math.ceil(60000 / config.requestsPerMinute)\n : 0;\n if (delay > 0) await sleep(delay);\n}\n\nfunction parseRetryAfter(header: string | null): number | null {\n if (!header) return null;\n const num = parseInt(header);\n if (!isNaN(num)) {\n return num > 1_000_000_000 ? (num * 1000 - Date.now()) : num * 1000;\n }\n const date = new Date(header);\n if (!isNaN(date.getTime())) return Math.max(0, date.getTime() - Date.now());\n return null;\n}\n\nfunction isRetryable(err: unknown, config: ErrorRetryConfig): boolean {\n if (err instanceof Error && err.message.includes(\"429\") && config.retryOn429 !== false) return true;\n if (err instanceof Error && /^5\\d\\d/.test(err.message) && config.retryOn5xx !== false) return true;\n return false;\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((r) => setTimeout(r, ms));\n}\n",
|
|
9
|
+
"do/workspace-database.ts": "import { DurableObject } from \"cloudflare:workers\";\nimport type { Env } from \"../types.js\";\n\ninterface QueryRequest {\n sql: string;\n params?: (string | number | null)[];\n}\n\ninterface SyncRequest {\n table: string;\n primaryKey: string;\n rows: Record<string, unknown>[];\n}\n\ntype SqlParam = string | number | null;\n\nfunction stringify(v: unknown): SqlParam {\n if (v === null || v === undefined) return null;\n if (typeof v === \"object\") return JSON.stringify(v);\n return String(v);\n}\n\nexport class WorkspaceDatabase extends DurableObject<Env> {\n async fetch(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const action = url.pathname.split(\"/\").pop();\n\n try {\n if (action === \"sync\") {\n const body = (await request.json()) as SyncRequest;\n const result = this.handleSync(body);\n return Response.json(result);\n }\n\n const body = (await request.json()) as QueryRequest;\n const sql = this.ctx.storage.sql;\n\n if (action === \"query\") {\n const cursor = sql.exec(body.sql, ...(body.params ?? []));\n return Response.json({ rows: cursor.toArray() });\n }\n\n if (action === \"exec\") {\n const cursor = sql.exec(body.sql, ...(body.params ?? []));\n return Response.json({ changes: cursor.rowsWritten });\n }\n\n if (action === \"seed\") {\n const seedBody = (await request.clone().json()) as {\n tables: Record<string, { ddl: string; rows: Record<string, unknown>[] }>;\n };\n const result = this.handleSeed(seedBody.tables);\n return Response.json(result);\n }\n\n if (action === \"export\") {\n const result = this.handleExport();\n return Response.json(result);\n }\n\n return Response.json({ error: \"Unknown action\" }, { status: 400 });\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n return Response.json({ error: msg }, { status: 500 });\n }\n }\n\n private handleSync(req: SyncRequest): { upserted: number; deleted: number; deletedPks: string[] } {\n const sql = this.ctx.storage.sql;\n const { table, primaryKey, rows } = req;\n\n if (rows.length === 0) {\n return { upserted: 0, deleted: 0, deletedPks: [] };\n }\n\n const columns = Object.keys(rows[0]);\n const allColumns = [...columns, \"_mug_synced_at\", \"_mug_deleted_at\"];\n\n sql.exec(`\n CREATE TABLE IF NOT EXISTS \"${table}\" (\n ${allColumns.map((c) => `\"${c}\" TEXT`).join(\", \")},\n PRIMARY KEY (\"${primaryKey}\")\n )\n `);\n\n const existing = sql.exec(`PRAGMA table_info(\"${table}\")`).toArray() as { name: string }[];\n const existingNames = new Set(existing.map((r) => r.name));\n for (const col of allColumns) {\n if (!existingNames.has(col)) {\n sql.exec(`ALTER TABLE \"${table}\" ADD COLUMN \"${col}\" TEXT`);\n }\n }\n\n const now = new Date().toISOString();\n const incomingPks = new Set<string>();\n\n const placeholders = allColumns.map(() => \"?\").join(\", \");\n const updateSet = allColumns\n .filter((c) => c !== primaryKey)\n .map((c) => `\"${c}\" = excluded.\"${c}\"`)\n .join(\", \");\n const upsertSql = `INSERT INTO \"${table}\" (${allColumns.map((c) => `\"${c}\"`).join(\", \")})\n VALUES (${placeholders})\n ON CONFLICT (\"${primaryKey}\") DO UPDATE SET ${updateSet}`;\n\n for (const row of rows) {\n const pk = String(row[primaryKey]);\n incomingPks.add(pk);\n\n const values: SqlParam[] = columns.map((c) => stringify(row[c]));\n values.push(now);\n values.push(null);\n\n sql.exec(upsertSql, ...values);\n }\n\n // Soft-delete rows no longer in source (decision #56)\n const allPks = sql\n .exec(`SELECT \"${primaryKey}\" FROM \"${table}\" WHERE _mug_deleted_at IS NULL`)\n .toArray() as Record<string, unknown>[];\n\n let deleted = 0;\n const deletedPks: string[] = [];\n for (const row of allPks) {\n const pk = String(row[primaryKey]);\n if (!incomingPks.has(pk)) {\n sql.exec(\n `UPDATE \"${table}\" SET _mug_deleted_at = ? WHERE \"${primaryKey}\" = ?`,\n now,\n pk\n );\n deleted++;\n deletedPks.push(pk);\n }\n }\n\n this.ensureFTS5(table, primaryKey);\n\n return { upserted: rows.length, deleted, deletedPks };\n }\n\n private ensureFTS5(table: string, primaryKey: string): void {\n const sql = this.ctx.storage.sql;\n\n const cols = sql.exec(`PRAGMA table_info(\"${table}\")`).toArray() as { name: string }[];\n const skipCols = new Set([primaryKey, \"_mug_synced_at\", \"_mug_deleted_at\"]);\n const textCols = cols.map((c) => c.name).filter((c) => !skipCols.has(c));\n\n if (textCols.length === 0) return;\n\n const ftsTable = `${table}_fts`;\n const colList = textCols.map((c) => `\"${c}\"`).join(\", \");\n\n const existing = sql\n .exec(\"SELECT sql FROM sqlite_master WHERE type='table' AND name=?\", ftsTable)\n .toArray() as { sql: string }[];\n\n if (existing.length > 0) {\n const needsRebuild = textCols.some((c) => !existing[0].sql.includes(`\"${c}\"`));\n if (!needsRebuild) return;\n\n sql.exec(`DROP TABLE IF EXISTS \"${ftsTable}\"`);\n sql.exec(`DROP TRIGGER IF EXISTS \"${table}_fts_insert\"`);\n sql.exec(`DROP TRIGGER IF EXISTS \"${table}_fts_update\"`);\n sql.exec(`DROP TRIGGER IF EXISTS \"${table}_fts_delete\"`);\n }\n\n sql.exec(\n `CREATE VIRTUAL TABLE \"${ftsTable}\" USING fts5(${colList}, content=\"${table}\", content_rowid=rowid)`\n );\n\n const newColList = textCols.map((c) => `new.\"${c}\"`).join(\", \");\n const oldColList = textCols.map((c) => `old.\"${c}\"`).join(\", \");\n const ftsDelete = `INSERT INTO \"${ftsTable}\" (\"${ftsTable}\", rowid, ${colList})`;\n\n sql.exec(`\n CREATE TRIGGER \"${table}_fts_insert\" AFTER INSERT ON \"${table}\"\n WHEN new._mug_deleted_at IS NULL\n BEGIN\n INSERT INTO \"${ftsTable}\" (rowid, ${colList}) VALUES (new.rowid, ${newColList});\n END\n `);\n\n sql.exec(`\n CREATE TRIGGER \"${table}_fts_delete\" AFTER DELETE ON \"${table}\"\n BEGIN\n ${ftsDelete} VALUES ('delete', old.rowid, ${oldColList});\n END\n `);\n\n sql.exec(`\n CREATE TRIGGER \"${table}_fts_update\" AFTER UPDATE ON \"${table}\"\n BEGIN\n ${ftsDelete} VALUES ('delete', old.rowid, ${oldColList});\n INSERT INTO \"${ftsTable}\" (rowid, ${colList})\n SELECT new.rowid, ${newColList} WHERE new._mug_deleted_at IS NULL;\n END\n `);\n\n sql.exec(\n `INSERT INTO \"${ftsTable}\" (rowid, ${colList}) SELECT rowid, ${colList} FROM \"${table}\" WHERE _mug_deleted_at IS NULL`\n );\n }\n\n private handleSeed(tables: Record<string, { ddl: string; rows: Record<string, unknown>[] }>): { tables_created: number; rows_inserted: number } {\n const sql = this.ctx.storage.sql;\n let tablesCreated = 0;\n let rowsInserted = 0;\n\n for (const [, tableData] of Object.entries(tables)) {\n const tableName = tableData.ddl.match(/CREATE TABLE\\s+\"?([^\"\\s(]+)\"?/i)?.[1];\n if (!tableName) continue;\n\n sql.exec(`DROP TABLE IF EXISTS \"${tableName}\"`);\n sql.exec(tableData.ddl);\n tablesCreated++;\n\n if (tableData.rows.length === 0) continue;\n\n const columns = Object.keys(tableData.rows[0]);\n const placeholders = columns.map(() => \"?\").join(\", \");\n const insertSql = `INSERT INTO \"${tableName}\" (${columns.map((c) => `\"${c}\"`).join(\", \")}) VALUES (${placeholders})`;\n\n for (const row of tableData.rows) {\n const values: SqlParam[] = columns.map((c) => stringify(row[c]));\n sql.exec(insertSql, ...values);\n rowsInserted++;\n }\n }\n\n return { tables_created: tablesCreated, rows_inserted: rowsInserted };\n }\n\n private handleExport(): { tables: Record<string, { ddl: string; rows: Record<string, unknown>[] }> } {\n const sql = this.ctx.storage.sql;\n const tableRows = sql.exec(\n \"SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'\"\n ).toArray() as { name: string; sql: string }[];\n\n const tables: Record<string, { ddl: string; rows: Record<string, unknown>[] }> = {};\n for (const { name, sql: ddl } of tableRows) {\n const rows = sql.exec(`SELECT * FROM \"${name}\"`).toArray() as Record<string, unknown>[];\n tables[name] = { ddl, rows };\n }\n\n return { tables };\n }\n}\n",
|
|
10
|
+
"types.ts": "export interface Env {\n WORKSPACE_ID: string;\n ENVIRONMENT: string;\n\n // Production: service bindings to shared Mug Workers\n MUG_DATA: Fetcher;\n MUG_AI: Fetcher;\n MUG_NOTIFY: Fetcher;\n MUG_DISPATCH: Fetcher;\n MUG_INTERNAL_SECRET: string;\n\n MUG_AGENT: DurableObjectNamespace;\n\n MUG_SOURCES?: string;\n MUG_BRANDING?: string;\n MUG_AI_ROUTING?: string;\n MUG_AI_BILLING?: string;\n SLACK_BOT_TOKEN?: string;\n\n // Vectorize: per-workspace semantic search index (production only)\n VECTORIZE?: VectorizeIndex;\n\n // Local dev: direct DO binding (present when running via mug dev)\n WORKSPACE_DB?: DurableObjectNamespace;\n}\n",
|
|
11
|
+
"form-types.ts": "export interface Condition {\n field: string;\n op: \"eq\" | \"neq\" | \"in\" | \"gt\" | \"lt\" | \"filled\" | \"empty\";\n value?: string | number | string[];\n}\n\nexport type FieldPrefill =\n | { source: \"auth\"; column: string }\n | { source: \"url\"; param: string }\n | { source: \"db\"; table: string; column: string; match: { column: string; field?: string; param?: string } };\n\nexport interface ValidationRule {\n rule: \"min\" | \"max\" | \"minLength\" | \"maxLength\" | \"pattern\";\n value: number | string;\n message: string;\n}\n\nexport interface BaseField {\n name: string;\n label: string;\n required?: boolean;\n placeholder?: string;\n showWhen?: Condition[];\n default?: string | number | boolean;\n prefill?: FieldPrefill;\n locked?: boolean;\n helpText?: string;\n validate?: ValidationRule[];\n}\n\nexport interface TextField extends BaseField {\n type: \"text\" | \"email\" | \"phone\";\n pattern?: string;\n}\n\nexport interface NumberField extends BaseField {\n type: \"number\";\n min?: number;\n max?: number;\n step?: number;\n}\n\nexport interface SelectField extends BaseField {\n type: \"select\" | \"multiselect\";\n options: { label: string; value: string }[];\n}\n\nexport interface DateField extends BaseField {\n type: \"date\";\n min?: string;\n max?: string;\n}\n\nexport interface TextareaField extends BaseField {\n type: \"textarea\";\n rows?: number;\n maxLength?: number;\n}\n\nexport interface FileField extends BaseField {\n type: \"file\";\n accept?: string;\n maxSizeMb?: number;\n}\n\nexport interface CalculatedField {\n name: string;\n type: \"calculated\";\n label: string;\n expression: string;\n format?: \"number\" | \"currency\" | \"percent\";\n showWhen?: Condition[];\n}\n\nexport interface HiddenField {\n name: string;\n type: \"hidden\";\n default?: string | number | boolean;\n prefill?: FieldPrefill;\n locked?: boolean;\n}\n\nexport type FormField = TextField | NumberField | SelectField | DateField | TextareaField | FileField | CalculatedField | HiddenField;\n\nexport interface PageBranch {\n when: Condition[];\n goto: string;\n}\n\nexport interface FormPage {\n id: string;\n title?: string;\n description?: string;\n fields: FormField[];\n showWhen?: Condition[];\n nextPage?: string | { conditions: PageBranch[]; default: string };\n}\n\nexport interface EditMode {\n table: string;\n recordParam: string;\n matchColumn: string;\n}\n\nexport interface FormAccessPublic {\n mode: \"public\";\n}\n\nexport interface FormAccessIdentify {\n mode: \"identify\";\n method: \"email\" | \"phone\";\n sessionDuration: string;\n}\n\nexport interface FormAccessAuth {\n mode: \"auth\";\n method: \"email\" | \"phone\";\n table: string;\n matchColumn: string;\n sessionDuration: string;\n query?: string;\n}\n\nexport type FormAccess = FormAccessPublic | FormAccessIdentify | FormAccessAuth;\n\nexport interface FormSchema {\n title: string;\n description?: string;\n submitText?: string;\n pages: FormPage[];\n access: FormAccess;\n editMode?: EditMode;\n workflow: string;\n}\n\nexport interface CollectOptions {\n id?: string;\n title: string;\n description?: string;\n submitText?: string;\n fields?: FormField[];\n pages?: FormPage[];\n access?: FormAccess;\n editMode?: EditMode;\n workflow: string;\n}\n",
|
|
12
|
+
"ai-router.ts": "export type Tier = \"fast\" | \"balanced\" | \"powerful\";\n\nexport interface RoutingConfig {\n fast?: string;\n balanced?: string;\n powerful?: string;\n}\n\nexport interface RoutingResult {\n tier: Tier;\n model: string;\n provider: string;\n reason: string;\n}\n\nexport interface ParsedModel {\n provider: string;\n model: string;\n}\n\nconst LEGACY_ALIASES: Record<string, ParsedModel> = {\n haiku: { provider: \"anthropic\", model: \"claude-haiku-4-5\" },\n sonnet: { provider: \"anthropic\", model: \"claude-sonnet-4-6\" },\n opus: { provider: \"anthropic\", model: \"claude-opus-4-6\" },\n};\n\nconst PLATFORM_DEFAULTS: Record<Tier, string> = {\n fast: \"openai/gpt-5.4-nano\",\n balanced: \"@cf/moonshotai/kimi-k2.6\",\n powerful: \"anthropic/claude-sonnet-4-6\",\n};\n\nconst FAST_KEYWORDS = /\\b(classify|extract|list|define|format|translate)\\b|yes or no|one word|true or false/i;\nconst POWERFUL_KEYWORDS = /\\b(analyze|compare|step by step|evaluate|design|debug|refactor|explain why|critique|synthesize)\\b/i;\nconst CODE_MARKERS = /```|(?:^|\\n)\\s*(?:function |class |def |import |const |let |var |export )/;\n\nconst TIER_ORDER: Tier[] = [\"fast\", \"balanced\", \"powerful\"];\n\nfunction tierIndex(tier: Tier): number {\n return TIER_ORDER.indexOf(tier);\n}\n\nfunction scoreToTier(score: number): Tier {\n if (score < 0.30) return \"fast\";\n if (score < 0.60) return \"balanced\";\n return \"powerful\";\n}\n\nexport function parseModelSpec(spec: string): ParsedModel {\n if (LEGACY_ALIASES[spec]) return LEGACY_ALIASES[spec];\n\n if (spec.startsWith(\"@cf/\")) {\n return { provider: \"workers-ai\", model: spec };\n }\n\n const slashIndex = spec.indexOf(\"/\");\n if (slashIndex > 0) {\n return {\n provider: spec.slice(0, slashIndex),\n model: spec.slice(slashIndex + 1),\n };\n }\n\n return { provider: \"anthropic\", model: spec };\n}\n\nexport function scoreComplexity(\n prompt: string,\n options?: { system?: string; maxTokens?: number }\n): { tier: Tier; reason: string } {\n const maxTokens = options?.maxTokens;\n const parts: string[] = [];\n\n const fullText = options?.system ? `${options.system}\\n${prompt}` : prompt;\n const estimatedTokens = Math.ceil(fullText.length / 4);\n parts.push(`tokens:${estimatedTokens}`);\n\n let score: number;\n if (estimatedTokens < 200) score = 0.10;\n else if (estimatedTokens < 800) score = 0.25;\n else if (estimatedTokens < 2000) score = 0.40;\n else if (estimatedTokens < 8000) score = 0.55;\n else score = 0.70;\n\n const fastMatches = fullText.match(FAST_KEYWORDS);\n const powerfulMatches = fullText.match(POWERFUL_KEYWORDS);\n const hasCode = CODE_MARKERS.test(fullText);\n\n let keywordMod = 0;\n const keywordParts: string[] = [];\n\n if (powerfulMatches) {\n keywordMod += 0.20;\n keywordParts.push(powerfulMatches[0].trim().toLowerCase());\n }\n if (hasCode) {\n keywordMod += 0.15;\n keywordParts.push(\"code\");\n }\n if (fastMatches && !powerfulMatches && !hasCode) {\n keywordMod -= 0.10;\n keywordParts.push(fastMatches[0].trim().toLowerCase());\n }\n\n keywordMod = Math.max(-0.15, Math.min(0.25, keywordMod));\n if (keywordParts.length > 0) {\n parts.push(`keywords:${keywordParts.join(\"+\")}`);\n }\n score += keywordMod;\n\n if (maxTokens !== undefined) {\n if (maxTokens <= 50) {\n score -= 0.05;\n parts.push(\"maxTokens:≤50\");\n } else if (maxTokens > 2000) {\n score += 0.10;\n parts.push(`maxTokens:${maxTokens}`);\n }\n }\n\n score = Math.max(0, Math.min(1, score));\n\n let tier = scoreToTier(score);\n\n if (hasCode && tierIndex(tier) < tierIndex(\"balanced\")) {\n tier = \"balanced\";\n }\n\n return { tier, reason: parts.join(\", \") };\n}\n\nexport function resolveModel(\n tier: Tier,\n perCall?: RoutingConfig,\n workspace?: RoutingConfig,\n): ParsedModel {\n const spec =\n perCall?.[tier] ??\n workspace?.[tier] ??\n PLATFORM_DEFAULTS[tier];\n\n return parseModelSpec(spec);\n}\n\nexport interface BillingConfig {\n default?: string;\n fast?: string;\n balanced?: string;\n powerful?: string;\n}\n\nexport function resolveBilling(\n tier: Tier | null,\n perCall?: string,\n perWorkflow?: string,\n workspace?: BillingConfig,\n): string {\n if (perCall) return perCall;\n if (perWorkflow) return perWorkflow;\n if (tier && workspace?.[tier]) return workspace[tier];\n if (workspace?.default) return workspace.default;\n return \"mug-metered\";\n}\n",
|
|
13
|
+
"agent-types.ts": "export type AgentModel =\n | \"claude-sonnet\"\n | \"claude-haiku\"\n | \"claude-opus\"\n | \"gpt-4o\"\n | \"gpt-4o-mini\"\n | \"gpt-4.1-nano\"\n | \"gpt-4.1-mini\"\n | \"gpt-4.1\"\n | (string & {});\n\nexport interface AgentTierRouting {\n fast?: string;\n balanced?: string;\n powerful?: string;\n}\n\nexport interface AgentMemory {\n entities?: boolean;\n outcomes?: boolean;\n struggles?: boolean;\n}\n\nexport type AgentToolGrant =\n | \"query\"\n | \"search\"\n | \"ask\"\n | \"notify\"\n | \"http\"\n | \"workspace\"\n | \"ai\"\n | (string & {});\n\nexport interface AgentCaps {\n maxTurns?: number;\n maxCredits?: number;\n maxDuration?: number;\n}\n\nexport interface AgentConfig {\n name: string;\n model: AgentModel | AgentTierRouting;\n instructions?: string;\n tools?: AgentToolGrant[];\n memory?: AgentMemory;\n caps?: AgentCaps;\n requireApproval?: string[];\n}\n\nexport function agent(config: AgentConfig): AgentConfig {\n return config;\n}\n",
|
|
14
|
+
"chunker.ts": "export interface Chunk {\n text: string;\n index: number;\n total: number;\n}\n\nconst SINGLE_CHUNK_LIMIT = 512;\nconst WINDOW_SIZE = 400;\nconst OVERLAP = 100;\n\nfunction estimateTokens(text: string): number {\n return Math.ceil(text.split(/\\s+/).length * 1.3);\n}\n\nexport function chunkText(text: string): Chunk[] {\n const trimmed = text.trim();\n if (!trimmed) return [];\n\n if (estimateTokens(trimmed) <= SINGLE_CHUNK_LIMIT) {\n return [{ text: trimmed, index: 0, total: 1 }];\n }\n\n const words = trimmed.split(/\\s+/);\n const wordsPerChunk = Math.floor(WINDOW_SIZE / 1.3);\n const overlapWords = Math.floor(OVERLAP / 1.3);\n const step = wordsPerChunk - overlapWords;\n\n const chunks: Chunk[] = [];\n for (let i = 0; i < words.length; i += step) {\n const slice = words.slice(i, i + wordsPerChunk);\n if (slice.length === 0) break;\n chunks.push({ text: slice.join(\" \"), index: chunks.length, total: 0 });\n if (i + wordsPerChunk >= words.length) break;\n }\n\n for (const c of chunks) c.total = chunks.length;\n return chunks;\n}\n"
|
|
15
|
+
};
|
|
16
|
+
export const skillTemplates = {
|
|
17
|
+
"connector/SKILL.md": "---\nname: connector\ndescription: Build a connector for an external API. Walks through the full pipeline — research, discover, gather, verify, scaffold — and wires it into the workspace as a source.\nargument-hint: \"<product name>\"\n---\n\n# Build a Connector\n\nBuild a connector that syncs data from an external API into this workspace's local database.\n\n## Input\n\nProduct name: `$ARGUMENTS`\n\nIf no product name provided, ask the user what product or service they want to connect to.\n\nFor full API reference (all pagination styles, rate limit config, sync config, error retry), see `.mug/docs/sources.md`. For `ctx.credential()` resolution chain, see `.mug/docs/api.md`.\n\n## Quick path — write a source directly\n\nFor simple APIs where you already know the endpoints and auth, skip the pipeline and write the source file directly. See `.mug/docs/sources.md` for the `SourceDef` and `SourceContext` interfaces. The pattern:\n\n```typescript\n// connectors/<name>.ts\nimport { source } from \"@mugwork/mug\";\n\nsource({\n name: \"<name>\",\n database: \"<name>\",\n tables: [{\n name: \"<table>\",\n primaryKey: \"id\",\n async fetch(ctx) {\n // ctx.credential() resolves via mug.json source auth.value → env var → literal\n // Defaults to source name if no arg; see .mug/docs/api.md for full resolution chain\n const token = await ctx.credential();\n const res = await fetch(\"https://api.example.com/items\", {\n headers: { Authorization: `Bearer ${token}` },\n });\n const data = await res.json();\n return data.items; // return array of records\n },\n }],\n});\n```\n\nConnectors in `connectors/` are auto-discovered by `mug deploy` — no import needed. Store credentials with `mug secret set`, sync with `curl -X POST http://localhost:8787/sync/<name>`.\n\nIf the API is complex (pagination, rate limits, many endpoints), use the full pipeline below.\n\n## Full pipeline\n\n## Step 0 — Check the community catalog\n\nBefore researching from scratch, check if a verified connector already exists:\n\n```\nmug connector search \"<product>\"\n```\n\nIf a match is found (especially with `[verified]` quality), pull it and skip to Step 4 (credentials):\n\n```\nmug connector pull --slug <name>\n```\n\nThen run `mug connector verify` against your own API credentials to confirm it works. If no match is found, continue with the full pipeline below.\n\n## Step 1 — Research the API\n\nBefore running any CLI commands, research the product's API:\n\n1. Search for `<product> API documentation`, `<product> developer portal`, `<product> developer docs`\n2. Search for `<product> openapi spec` or `<product> swagger spec`\n3. Check if Zapier, Make, or n8n have integrations (implies an API exists)\n4. Determine:\n - **Does it have an API?** (yes/no)\n - **API type?** (rest, graphql, etc.)\n - **Docs URL?** (developer portal or API reference)\n - **OpenAPI spec URL?** (direct link to .json or .yaml spec file, if one exists)\n - **Auth method?** (bearer, api-key, oauth2, basic)\n - **Tier:** 1 = has an OpenAPI spec, 2 = has docs but no spec, 3 = no docs\n\nPresent your findings to the user and confirm before proceeding.\n\n## Step 2 — Discover\n\nRun `mug connector discover` with your research findings:\n\n```\nmug connector discover \"<product>\" \\\n --tier <1|2|3> \\\n --has-api \\\n --api-type <type> \\\n --docs-url \"<url>\" \\\n --spec-url \"<url>\" \\\n --auth-type <type> \\\n [--zapier] [--make] [--n8n] \\\n [--notes \"<anything notable>\"]\n```\n\nOmit flags you don't have data for. Use `--no-api` instead of `--has-api` if no API exists (pipeline stops here).\n\n## Step 3 — Gather the spec\n\nThe discover output tells you which gather path to use.\n\n**Tier 1 (has spec URL):**\n```\nmug connector gather --slug <name> --from-spec \"<spec-url>\"\n```\n\n**Tier 2 (has docs, no spec):**\nRead the API documentation. Write an OpenAPI 3.x spec covering the key list endpoints, auth scheme, and parameters. Save it to a `.yaml` file, then:\n```\nmug connector gather --slug <name> --from-file <your-spec-file>\n```\n\n**Tier 3 (no docs):**\nAsk the user to capture browser traffic as a HAR file while using the product, then:\n```\nmug connector gather --slug <name> --from-har <har-file>\n```\n\nCheck the gather output — it reports endpoint count and quality score. If quality is low, review the spec and fix issues before proceeding.\n\n## Step 4 — Set up credentials\n\n**Ask the user for their API credentials.** Never guess, generate, or skip this step.\n\nOnce you have the credentials:\n\n1. Store the credential securely:\n```bash\nmug secret set <SLUG>_API_KEY=<the credential>\n```\n\n2. Add the source config to `mug.json` (references the credential, doesn't store it):\n```json\n\"sources\": {\n \"<slug>\": {\n \"auth\": {\n \"type\": \"<bearer|api-key|basic|oauth2>\",\n \"value\": \"<the credential>\"\n },\n \"baseUrl\": \"<API base URL>\",\n \"syncs\": {}\n }\n}\n```\n\nFor `api-key` auth, also include `\"header\": \"<header-name>\"` in the auth object.\n\nNote: credentials in `mug.json` sources are used for local dev and sync. For production, `mug deploy` sends secrets from `.mug/secrets` automatically.\n\n## Step 5 — Verify against the live API\n\n```\nmug connector verify --slug <name> --source <name>\n```\n\nThis runs 7 probes against the live API and enriches the spec with `x-mug-*` annotations that the scaffold step uses. **Do not skip this step** — without it, the generated source won't have pagination, rate limit, or sync config.\n\nReview the probe results. If auth fails, fix the source config and re-run. If endpoints are unreachable, check the base URL.\n\n## Step 6 — Scaffold the source\n\n```\nmug connector scaffold --slug <name>\n```\n\nThis generates `connectors/<slug>.ts` using the enriched spec.\n\n## Step 7 — Review the generated source\n\nConnectors in `connectors/` are auto-discovered by `mug deploy` — no import needed.\n\n1. Read the generated source file. Review it for:\n - Correct base URL\n - Correct auth credential name\n - Sensible table names and primary keys\n - Appropriate pagination config\n - Any endpoints that should be excluded\n\n2. Make adjustments as needed. The generated code is a starting point — customize the table definitions, add filtering, rename tables, or adjust the `extractItems` function.\n\n3. Tell the user what was built: which tables, what sync strategy, and any manual steps remaining.\n\n## Step 8 — Sync and verify data\n\n1. Start the dev server if it isn't running:\n ```\n mug dev\n ```\n (Run in background or a separate terminal.)\n\n2. Trigger the first sync:\n ```\n curl -s -X POST http://localhost:8787/sync/<slug>\n ```\n Note: `mug dev` starts on port 8787 by default, but auto-increments (8788, 8789, ...) if that port is busy. The CLI commands (`mug run`, `mug query`, etc.) auto-discover the active port.\n\n3. Verify data landed:\n ```\n mug query <slug> \"SELECT name FROM sqlite_master WHERE type='table'\"\n mug query <slug> \"SELECT count(*) FROM <first-table>\"\n ```\n If count is 0 or the query fails, check the source's `fetch` function and the sync response for errors.\n\n## Step 9 — Show the user their data\n\nRun a sample query across the synced tables and present the results to the user:\n\n```\nmug query <slug> \"SELECT * FROM <table> LIMIT 5\"\n```\n\nIf there are multiple tables with a relationship (e.g., a foreign key), demonstrate a cross-table JOIN:\n\n```\nmug query <slug> \"SELECT a.name, b.name FROM <table_a> a JOIN <table_b> b ON a.<fk> = b.id\"\n```\n\nShow the user what's now queryable. This is the payoff — their external data is local and joinable.\n\n## Step 10 — Configure sync schedule (optional)\n\nAsk the user if they want automatic syncing. If yes, add a syncs entry to the source in `mug.json`:\n\n```json\n\"sources\": {\n \"<slug>\": {\n \"auth\": { ... },\n \"baseUrl\": \"...\",\n \"syncs\": {\n \"<slug>\": {\n \"database\": \"<slug>\",\n \"schedule\": \"*/15 * * * *\"\n }\n }\n }\n}\n```\n\nCommon schedules: `*/15 * * * *` (every 15 min), `0 * * * *` (hourly), `0 */6 * * *` (every 6 hours), `0 0 * * *` (daily at midnight).\n",
|
|
18
|
+
"connector/connector.mdc": "---\ndescription: Build a connector for an external API using the mug connector pipeline — research, discover, gather, verify, scaffold\nglobs: [\"connectors/**\", \"mug.json\"]\n---\n\n# Building Connectors\n\nBuild a connector that syncs data from an external API into this workspace's local database.\n\nFull API reference (all pagination styles, rate limit config, sync config, error retry): `.mug/docs/sources.md`\n\n## Quick path — write a source directly\n\nFor simple APIs where you already know the endpoints and auth, skip the pipeline:\n\n```typescript\n// connectors/<name>.ts\nimport { source } from \"@mugwork/mug\";\n\nsource({\n name: \"<name>\",\n database: \"<name>\",\n tables: [{\n name: \"<table>\",\n primaryKey: \"id\",\n async fetch(ctx) {\n // ctx.credential() resolves via mug.json source auth.value → env var → literal\n const token = await ctx.credential();\n const res = await fetch(\"https://api.example.com/items\", {\n headers: { Authorization: `Bearer ${token}` },\n });\n const data = await res.json();\n return data.items;\n },\n }],\n});\n```\n\nConnectors in `connectors/` are auto-discovered by `mug deploy` — no import needed. Store credentials with `mug secret set`, sync with `curl -X POST http://localhost:8787/sync/<name>`.\n\n## Full pipeline: search catalog → discover → gather → verify → scaffold\n\n### 0. Check the community catalog\n\nBefore researching from scratch, check `mug connector search \"<product>\"`. If a verified connector exists, pull it with `mug connector pull --slug <name>` and skip to step 4 (credentials). Still run verify against your own API credentials.\n\n### 1. Research the API\n\nSearch for the product's API docs, developer portal, OpenAPI spec, and Zapier/Make/n8n integrations. Determine:\n- Has API (yes/no), API type (rest/graphql/etc.)\n- Docs URL, spec URL\n- Auth method (bearer, api-key, oauth2, basic)\n- Tier: 1 = has OpenAPI spec, 2 = has docs but no spec, 3 = no docs\n\n### 2. Discover\n\n```bash\nmug connector discover \"<product>\" \\\n --tier <1|2|3> \\\n --has-api \\\n --api-type <type> \\\n --docs-url \"<url>\" \\\n --spec-url \"<url>\" \\\n --auth-type <type> \\\n [--zapier] [--make] [--n8n]\n```\n\nOmit flags you don't have data for. Use `--no-api` if no API exists (pipeline stops).\n\n### 3. Gather\n\n- **Tier 1 (has spec URL):** `mug connector gather --slug <name> --from-spec \"<spec-url>\"`\n- **Tier 2 (has docs, no spec):** Write an OpenAPI 3.x spec from docs, then `mug connector gather --slug <name> --from-file <your-spec-file>`\n- **Tier 3 (no docs):** Ask user for HAR file, then `mug connector gather --slug <name> --from-har <file>`\n\nCheck the output — it reports endpoint count and quality score.\n\n### 4. Credentials\n\n**Ask the user for API credentials.** Never guess or skip this step.\n\n```bash\nmug secret set <SLUG>_API_KEY=<the credential>\n```\n\nAdd source config to `mug.json`:\n```json\n\"sources\": {\n \"<slug>\": {\n \"auth\": {\n \"type\": \"<bearer|api-key|basic|oauth2>\",\n \"value\": \"<the credential>\"\n },\n \"baseUrl\": \"<API base URL>\",\n \"syncs\": {}\n }\n}\n```\n\nFor `api-key` auth, also include `\"header\": \"<header-name>\"` in the auth object.\n\nCredentials in `mug.json` are for local dev. For production, `mug deploy` sends secrets from `.mug/secrets` automatically.\n\n### 5. Verify (DO NOT SKIP)\n\n```bash\nmug connector verify --slug <name> --source <name>\n```\n\nRuns 7 probes against the live API, enriches spec with `x-mug-*` annotations for pagination, rate limits, and sync config. Without this step, the generated source won't have these features.\n\nIf auth fails, fix the source config and re-run. If endpoints are unreachable, check the base URL.\n\n### 6. Scaffold\n\n```bash\nmug connector scaffold --slug <name>\n```\n\nGenerates `connectors/<slug>.ts` using the enriched spec.\n\n### 7. Review the generated source\n\nConnectors in `connectors/` are auto-discovered by `mug deploy` — no import needed.\n\nReview the generated source for:\n- Correct base URL and auth credential name\n- Sensible table names and primary keys\n- Appropriate pagination config\n- Endpoints that should be excluded\n\nThe generated code is a starting point — customize table definitions, add filtering, rename tables, or adjust the `extractItems` function.\n\n### 8. Sync and verify data\n\n```bash\nmug dev # start dev server\ncurl -s -X POST http://localhost:8787/sync/<slug> # trigger first sync\nmug query <slug> \"SELECT name FROM sqlite_master WHERE type='table'\" # check tables\nmug query <slug> \"SELECT count(*) FROM <first-table>\" # check row count\n```\n\nIf count is 0, check the source's `fetch` function and the sync response for errors.\n\n### 9. Show the data\n\n```bash\nmug query <slug> \"SELECT * FROM <table> LIMIT 5\"\n```\n\nIf multiple related tables exist, demonstrate a cross-table JOIN:\n```bash\nmug query <slug> \"SELECT a.name, b.name FROM <table_a> a JOIN <table_b> b ON a.<fk> = b.id\"\n```\n\n### 10. Sync schedule (optional)\n\nAdd syncs to the source in `mug.json` for automatic syncing:\n```json\n\"sources\": {\n \"<slug>\": {\n \"auth\": { ... },\n \"baseUrl\": \"...\",\n \"syncs\": {\n \"<slug>\": {\n \"database\": \"<slug>\",\n \"schedule\": \"*/15 * * * *\"\n }\n }\n }\n}\n```\n\nCommon schedules: `*/15 * * * *` (every 15 min), `0 * * * *` (hourly), `0 */6 * * *` (every 6 hours), `0 0 * * *` (daily).\n",
|
|
19
|
+
"workflow/SKILL.md": "---\nname: workflow\ndescription: Create a new workflow — multi-step automation with data queries, AI classification, and notifications. Scaffolds the file and tests locally.\nargument-hint: \"<workflow name or description>\"\n---\n\n# Create a Workflow\n\nBuild a multi-step workflow that queries data, applies AI logic, and takes action (notify, update records, etc.).\n\nFor full API reference (all ctx methods, WorkflowResult, ops database schema, production execution), see `.mug/docs/workflows.md`. For cross-cutting API reference (ctx.params shape, error handling, data patterns), see `.mug/docs/api.md`.\n\n## Input\n\nWorkflow name or description: `$ARGUMENTS`\n\nIf no argument provided, ask the user what the workflow should do. Clarify:\n- What data sources does it need? (which databases/tables)\n- What logic or AI classification should it apply?\n- What actions should it take? (SMS, email, Slack, update records)\n- Should it run on a schedule or only manually?\n\n## Step 1 — Name and plan\n\nPick a kebab-case name for the workflow (e.g., `invoice-followup`, `lead-scoring`, `daily-report`).\n\nPresent a brief plan:\n- Data queries needed\n- AI classification steps (if any)\n- Actions/notifications\n- Estimated step count\n\nWait for user confirmation.\n\n## Step 2 — Write the workflow\n\nCreate the file at `workflows/<name>.ts`:\n\n```typescript\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"<name>\", async (ctx) => {\n // Fetch all open items from the database\n const rows = await ctx.query(\"<database>\", \"SELECT ...\");\n\n // Classify each item using AI with smart routing\n for (const row of rows) {\n const result = await ctx.ai(\"fast\", {\n prompt: `<classification prompt using ${row.field}>`,\n system: \"Reply with exactly one word.\",\n maxTokens: 10,\n });\n }\n\n // Notify the relevant person about the results\n await ctx.notify.sms({ to: \"...\", message: \"...\" });\n\n // Return a summary of what happened\n return { summary: \"...\" };\n}, {\n description: \"<Plain English description of what this workflow does>\",\n // webhook: true, // or { auth: \"hmac\", secret: \"WEBHOOK_SECRET\" }\n // inbound: \"sms\", // or \"email\" or \"slack\"\n // trigger: { source: \"quickbooks\", table: \"invoices\", on: \"insert\" },\n});\n```\n\n### Descriptions\n\nEvery workflow must have a `description` in the options object — a plain English sentence explaining what the workflow does and why.\n\nEvery `ctx.*` call and `return` statement must have a `//` comment on the line above describing what it does. These comments appear in the workspace explorer as human-readable step descriptions.\n\n```typescript\n// Check for overdue invoices in QuickBooks\nconst overdue = await ctx.query(\"quickbooks\", \"SELECT * FROM invoices WHERE ...\");\n\n// Send a reminder SMS to the customer\nawait ctx.notify.sms({ to: inv.phone, message: \"...\" });\n\n// No overdue invoices found, nothing to do\nreturn { skipped: true };\n```\n\n### ctx API reference\n\n- `ctx.query(database, sql, params?)` — returns `Record<string, unknown>[]`\n- `ctx.exec(database, sql, params?)` — returns number of changes\n- `ctx.ai(model, { prompt, system, maxTokens?, routing?, billing? })` — returns `{ text, model, usage, routing? }`\n - Use tier names: `\"fast\"` (cheap), `\"balanced\"` (mid), `\"powerful\"` (best). For multi-provider config and BYOK, see the `/ai` skill.\n- `ctx.notify.email({ to, message, subject?, fromName?, cta? })` — send styled email with optional CTA button. For templates, surface links, and BYOK, use the `/notify` skill.\n- `ctx.notify.sms({ to, message })` — send SMS\n- `ctx.notify.slack({ to, message })` — send Slack message\n- `ctx.surfaceUrl(surfaceId, path?)` — generate URL to a surface (dev/prod-aware). Use in notification CTAs.\n- `ctx.file(path)` — read a file from `files/` as `ArrayBuffer` (local in dev, R2 in production)\n- `ctx.fileText(path)` — read a file as UTF-8 string. Use for templates, CSV data, JSON configs.\n- `ctx.collect(options)` — create a form that collects data from users. Returns the form URL. For the full form schema walkthrough (field types, conditionals, pages, access modes), use the `/form` skill.\n- `ctx.secret(name)` — read a workspace secret by name (from `.mug/secrets`). Throws if not found. Use for external API keys, tokens, or credentials that aren't tied to a source.\n- `ctx.waitFor(eventName, { timeout?, message? })` — pause workflow until external event. Returns `{ payload, type, timedOut }`. See Step 5.\n- `ctx.waitForUrl(eventName)` — generate a one-time callback URL for embedding in notifications. See Step 5.\n- `ctx.agent(name, { goal, context?, sessionKey?, caps? })` — invoke a custom AI agent. See the `/agents` skill.\n- `ctx.http(url, options?)` — outbound HTTP request. Returns `{ status, headers, body, json, ok }`. Throws on non-2xx by default. Options: `{ method?, headers?, body?, throwOnError?: false, retry?: { attempts? } | false, timeout?, sign?: { secret, header? } }`. Auto-retries connection errors and 429 with exponential backoff.\n- `ctx.respond(body, status?)` — set a custom HTTP response for webhook-triggered workflows. First call wins. Use for Slack URL verification, Twilio TwiML, etc.\n- `ctx.search(query, { source?, limit? })` — semantic search across synced data. Returns `{ score, table, primaryKey, row }[]`. Requires deployed workspace.\n- `ctx.ask(question, { source?, limit?, model?, system? })` — full RAG: searches data, feeds to LLM, returns `{ answer, sources, usage }`. Requires deployed workspace.\n\nDrop files in the `files/` directory and run `mug sync` to upload them to production. Check `files/.remote` for what's available remotely.\n\nEvery `ctx.*` call is automatically logged with timing, input/output, and token usage.\n\n## Step 3 — Test locally\n\n```bash\nmug dev # start the dev server (if not already running)\nmug run <name> # execute the workflow\n```\n\nWorkflows in `workflows/` are auto-discovered by `mug deploy` — no import needed.\n\nCheck the output:\n- `[+]` = success, `[x]` = error\n- Each step shows name, duration, and any errors\n- AI steps show token usage\n\nIf the workflow errors, read the error message and fix. Common issues:\n- Table doesn't exist yet → add `ctx.exec()` to create it, or sync the source first\n- AI prompt returns unexpected format → adjust the prompt or add parsing\n- `No credentials for \"<name>\"` → check `mug secret set` and `mug.json` connection config\n- `AI not configured: missing MUG_AI service binding` → workspace needs to be deployed first, or check `mug dev` is running\n\nAll `ctx.*` methods throw on failure. Use try/catch when you want to handle errors gracefully (e.g., continue processing other items if one notification fails). See `.mug/docs/api.md` for the full error handling reference.\n\nWorkflows can also be triggered by webhooks — see `.mug/docs/api.md` for webhook config.\n\n## Step 4 — (Optional) Pause for external events\n\nUse `ctx.waitFor()` to pause a workflow until an external event arrives (approval, payment confirmation, reply). Zero cost while waiting.\n\n```typescript\n// Send an approval request with a callback URL\nconst callbackUrl = await ctx.waitForUrl(\"approval\");\nawait ctx.notify.email({\n to: manager,\n message: \"New request needs approval.\",\n cta: { label: \"Approve\", url: `${callbackUrl}?action=approve` },\n});\n\n// Pause until the manager clicks or 48 hours pass\nconst event = await ctx.waitFor<{ action: string }>(\"approval\", { timeout: \"48 hours\" });\n\nif (event.timedOut) {\n await ctx.notify.sms({ to: submitter, message: \"Your request timed out.\" });\n} else if (event.payload?.action === \"approve\") {\n await ctx.exec(\"main\", \"UPDATE requests SET status = ? WHERE id = ?\", [\"approved\", requestId]);\n}\n```\n\n- `ctx.waitForUrl(eventName)` — generates a one-time callback URL. When visited (GET or POST), it delivers the event.\n- `ctx.waitFor(eventName, { timeout?, message? })` — pauses until the event arrives or timeout. Returns `{ payload, type, timedOut }`.\n- Callback URLs expire after 7 days.\n- In local dev, `waitFor` resolves immediately with an empty payload for testing.\n\nFor full signatures and options, see `.mug/docs/api.md`.\n\n## Step 5 — View execution log\n\n```bash\nmug logs <name> # dev server or auto-fallback to production\nmug logs <name> --production # force production logs\n```\n\nShows every run with per-step details: timing, inputs, outputs, token counts. If no dev server is running, automatically fetches production logs.\n\n## Step 6 — (Optional) Add a schedule\n\nTo run the workflow automatically, add it to `mug.json`:\n\n```json\n{\n \"workflows\": {\n \"<name>\": {\n \"schedule\": \"0 9 * * 1-5\",\n \"file\": \"workflows/<name>.ts\"\n }\n }\n}\n```\n\nCommon schedules:\n- `\"*/15 * * * *\"` — every 15 minutes\n- `\"0 9 * * 1-5\"` — weekdays at 9am\n- `\"0 0 * * *\"` — daily at midnight\n- `\"0 9 * * 1\"` — Mondays at 9am\n\n## Step 7 — Deploy\n\n```bash\nmug deploy\n```\n\nThis bundles the workflow code and uploads it. Scheduled workflows will run automatically in production.\n\nTo trigger manually in production:\n```bash\nmug run <name> --production\nmug status <name> <instanceId>\n```\n\n## Inbound message routing\n\nReceive SMS replies, inbound emails, and Slack interactions by routing them to a workflow. Configure in `mug.json`:\n\n```json\n\"inbound\": {\n \"sms\": \"handle-sms\",\n \"email\": \"handle-email\",\n \"slack\": \"handle-slack\"\n}\n```\n\nEach inbound channel delivers different `ctx.params`:\n- **SMS**: `{ from: \"+1234567890\", body: \"Yes, approved\" }`\n- **Email**: `{ from: \"user@example.com\", subject: \"Re: Invoice #42\", body: \"Looks good\" }`\n- **Slack**: `{ userId: \"U12345\", actionId: \"approve_btn\", actionValue: \"yes\" }`\n\nWebhook URLs are shown after `mug deploy`: `https://api.mug.work/inbound/sms/<workspace>`, etc.\n\n**Send-and-wait pattern**: Workflow 1 sends a notification and sets a status field in the database. Workflow 2 (the inbound handler) catches the reply and checks the status field to determine what to do.\n\nFor full inbound routing reference, see `.mug/docs/api.md`.\n\n---\n\nFor collecting data from users (forms with submissions), see the `/form` skill.\nFor displaying data and approval UIs (list+detail pages with action buttons), see the `/portal` skill.\nFor email/SMS notifications with CTA buttons and surface links, see the `/notify` skill.\n",
|
|
20
|
+
"workflow/workflow.mdc": "---\ndescription: Creating Mug workflows — multi-step automation with data queries, AI classification, notifications, and forms\nglobs: [\"workflows/**\"]\n---\n\n# Mug Workflows\n\nBuild multi-step workflows that query data, apply AI logic, and take action.\n\nFull API reference (all ctx methods, WorkflowResult, ops database schema, production execution): `.mug/docs/workflows.md`\n\n## Creating a workflow\n\n1. Create `workflows/<name>.ts`\n2. Import `workflow` from `\"@mugwork/mug\"`\n3. Register with `workflow(\"<name>\", async (ctx) => { ... })`\n\nWorkflows in `workflows/` are auto-discovered by `mug deploy` — no import needed.\n\n```typescript\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"<name>\", async (ctx) => {\n // Fetch all open items from the database\n const rows = await ctx.query(\"<database>\", \"SELECT ...\");\n\n // Classify each item using AI with smart routing\n for (const row of rows) {\n const result = await ctx.ai(\"fast\", {\n prompt: `<classification prompt using ${row.field}>`,\n system: \"Reply with exactly one word.\",\n maxTokens: 10,\n });\n }\n\n // Notify the manager about the results\n await ctx.notify.email({\n to: \"manager@company.com\",\n subject: \"New request\",\n message: \"A new request was submitted.\",\n cta: { label: \"Review\", url: ctx.surfaceUrl(\"approvals\") },\n });\n\n // Return a summary of what happened\n return { summary: \"...\" };\n}, { description: \"<Plain English description of what this workflow does>\" });\n```\n\n## Descriptions\n\nEvery workflow must have a `description` in the options object — a plain English sentence explaining what the workflow does and why.\n\nEvery `ctx.*` call and `return` statement must have a `//` comment on the line above describing what it does. These comments appear in the workspace explorer as human-readable step descriptions.\n\n```typescript\n// Check for overdue invoices in QuickBooks\nconst overdue = await ctx.query(\"quickbooks\", \"SELECT * FROM invoices WHERE ...\");\n\n// Send a reminder SMS to the customer\nawait ctx.notify.sms({ to: inv.phone, message: \"...\" });\n\n// No overdue invoices found, nothing to do\nreturn { skipped: true };\n```\n\n## ctx API\n\n- `ctx.query(database, sql, params?)` — read rows, returns `Record<string, unknown>[]`\n- `ctx.exec(database, sql, params?)` — write/update, returns number of changes\n- `ctx.ai(model, { prompt, system, maxTokens?, routing?, billing? })` — returns `{ text, model, usage, routing? }`\n - Use tier names: `\"fast\"` (cheap), `\"balanced\"` (mid), `\"powerful\"` (best). See `/ai` skill for multi-provider and BYOK.\n- `ctx.notify.email({ to, message, subject?, fromName?, cta? })` — styled email with optional CTA button. See `/notify` skill.\n- `ctx.notify.sms({ to, message })` — send SMS\n- `ctx.notify.slack({ to, message })` — send Slack message\n- `ctx.surfaceUrl(surfaceId, path?)` — generate URL to a surface (dev/prod-aware). Use in notification CTAs.\n- `ctx.file(path)` — read a file from `files/` as ArrayBuffer (local in dev, R2 in production)\n- `ctx.fileText(path)` — read a file as UTF-8 string (templates, CSV data, JSON configs)\n- `ctx.collect(options)` — create a form that collects data from users, returns the form URL. See `/form` skill for full schema: field types, conditionals, pages, access modes.\n- `ctx.secret(name)` — read a workspace secret by name (from `.mug/secrets`). Throws if not found. For external API keys and tokens not tied to a source.\n- `ctx.waitFor(eventName, { timeout?, message? })` — pause until external event. Returns `{ payload, type, timedOut }`.\n- `ctx.waitForUrl(eventName)` — one-time callback URL for embedding in notifications.\n- `ctx.http(url, options?)` — outbound HTTP. Returns `{ status, headers, body, json, ok }`. Throws on non-2xx. Options: `{ method?, headers?, body?, throwOnError?, retry?, timeout?, sign?: { secret, header? } }`.\n- `ctx.respond(body, status?)` — custom webhook response. First call wins. For Slack challenge, Twilio TwiML.\n- `ctx.agent(name, { goal, context?, sessionKey?, caps? })` — invoke a custom AI agent. See `/agents` skill.\n- `ctx.search(query, { source?, limit? })` — semantic search across synced data. Returns ranked `{ score, table, primaryKey, row }[]`. Requires deployed workspace.\n- `ctx.ask(question, { source?, limit?, model?, system? })` — full RAG: searches data + LLM answer. Returns `{ answer, sources, usage }`. Requires deployed workspace.\n\nDrop files in `files/` and run `mug sync` to upload. Check `files/.remote` for remote files.\n\nEvery ctx call is auto-logged with timing, input/output, and token usage.\n\n## Pause for external events (waitFor)\n\nPause a workflow until an external event arrives (approval click, payment webhook, reply). Zero cost while waiting.\n\n```typescript\nconst callbackUrl = await ctx.waitForUrl(\"approval\");\nawait ctx.notify.email({ to: mgr, message: \"Approve?\", cta: { label: \"Approve\", url: `${callbackUrl}?action=approve` } });\nconst event = await ctx.waitFor<{ action: string }>(\"approval\", { timeout: \"48 hours\" });\nif (event.timedOut) { /* handle timeout */ }\n```\n\n- `ctx.waitForUrl(eventName)` — one-time callback URL (7-day expiry)\n- `ctx.waitFor(eventName, { timeout?, message? })` — pauses until event or timeout. Returns `{ payload, type, timedOut }`\n- In local dev, waitFor resolves immediately with empty payload\n\n## Inbound message routing\n\nReceive SMS replies, emails, and Slack interactions. Configure in `mug.json`:\n\n```json\n\"inbound\": { \"sms\": \"handle-sms\", \"email\": \"handle-email\", \"slack\": \"handle-slack\" }\n```\n\nInbound params by channel:\n- **SMS**: `{ from: \"+1...\", body: \"reply text\" }`\n- **Email**: `{ from: \"user@example.com\", subject: \"Re: ...\", body: \"reply text\" }`\n- **Slack**: `{ userId: \"U...\", actionId: \"approve_btn\", actionValue: \"yes\" }`\n\n**Send-and-wait pattern**: Workflow 1 sends notification + sets status field in DB. Workflow 2 (inbound handler) catches the reply and checks the status field to determine what to do.\n\n## Testing\n\n```bash\nmug dev # start dev server\nmug run <name> # execute workflow\nmug logs <name> # view step-by-step execution log (dev or production)\nmug logs <name> --production # force production logs\n```\n\nCommon issues:\n- Table doesn't exist → add `ctx.exec()` to create it, or sync the source first\n- AI prompt returns unexpected format → adjust the prompt or add parsing\n\n## Scheduling (mug.json)\n\n```json\n\"workflows\": {\n \"<name>\": { \"schedule\": \"0 9 * * 1-5\", \"file\": \"workflows/<name>.ts\" }\n}\n```\n\nCommon schedules:\n- `\"*/15 * * * *\"` — every 15 minutes\n- `\"0 9 * * 1-5\"` — weekdays at 9am\n- `\"0 0 * * *\"` — daily at midnight\n- `\"0 9 * * 1\"` — Mondays at 9am\n\n## Deploy\n\n```bash\nmug deploy # bundle and deploy\nmug run <name> --production # trigger in production (CF Workflows)\nmug status <name> <instanceId> # check production status\n```\n\nFor collecting data from users, see the `/form` skill. For data display and approval UIs, see the `/portal` skill. For email/SMS notifications with CTA buttons and surface links, see the `/notify` skill.\n",
|
|
21
|
+
"form/SKILL.md": "---\nname: form\ndescription: Create a form that collects data from users — field types, conditionals, multi-page, access modes, file uploads, calculated fields. Scaffolds the config, wires the handler, and deploys.\nargument-hint: \"<form name or description>\"\n---\n\n# Create a Form\n\nBuild a form that collects data from users. Forms are JSON config files in `surfaces/` that deploy as live web pages at `https://<workspace>.mug.work/<form-id>`. Submissions trigger a handler workflow.\n\nFor full API reference (all field types, condition operators, page branching, access modes, edit mode), see `.mug/docs/forms.md`.\n\n## Input\n\nForm name or description: `$ARGUMENTS`\n\nIf no argument provided, ask the user what the form should collect. Clarify:\n- What information do they need? (fields and their types)\n- Who fills it out? (public, verified identity, or restricted access)\n- What should happen when someone submits? (email notification, AI processing, store in database)\n\n## Step 1 — Name and plan\n\nPick a kebab-case name for the form (e.g., `service-request`, `intake`, `feedback`).\n\nPresent a brief plan:\n- Fields needed (with types from the catalog below)\n- Access mode (public, identify, or auth)\n- Post-submission actions\n- Single page or multi-page\n\nWait for user confirmation.\n\n## Step 2 — Create the form config\n\nRun `mug form init <name>` to scaffold a starter form, then edit the config. The form config lives at `surfaces/<name>.json`:\n\n```json\n{\n \"type\": \"form\",\n \"title\": \"Service Request\",\n \"description\": \"Submit a new service request\",\n \"submitText\": \"Submit Request\",\n \"workflow\": \"handle-service-request\",\n \"access\": { \"mode\": \"public\" },\n \"fields\": [\n { \"name\": \"name\", \"type\": \"text\", \"label\": \"Your Name\", \"required\": true },\n { \"name\": \"email\", \"type\": \"email\", \"label\": \"Email\", \"required\": true },\n { \"name\": \"message\", \"type\": \"textarea\", \"label\": \"Message\", \"rows\": 4 }\n ]\n}\n```\n\n### Form config fields\n\n- `type` — must be `\"form\"`\n- `title` — form heading (required)\n- `description` — subtitle text below heading\n- `submitText` — submit button label (default: \"Submit\")\n- `workflow` — name of the handler workflow that processes submissions (required)\n- `access` — who can access the form (see Access Modes below)\n- `fields` — shorthand for a single-page form (array of fields)\n- `pages` — multi-page form (array of page objects, overrides `fields`)\n- `editMode` — load and update existing records (see Edit Mode below)\n\n## Step 3 — Write the submission handler workflow\n\nWhen someone submits the form, a handler workflow runs. Submission data arrives as `ctx.params`. Create `workflows/handle-<name>.ts`:\n\n```typescript\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"handle-<name>\", async (ctx) => {\n const params = ctx.params as Record<string, string>;\n const name = params.name;\n const email = params._verified_email ?? params.email;\n\n // Store in database\n await ctx.exec(\"main\", `INSERT INTO submissions (name, email, message, created_at)\n VALUES (?, ?, ?, datetime('now'))`, [name, email, params.message]);\n\n // Send notification\n await ctx.notify.email({\n to: \"owner@example.com\",\n subject: `New submission from ${name}`,\n message: `Name: ${name}\\nEmail: ${email}\\nMessage: ${params.message}`,\n });\n\n return { processed: true };\n});\n```\n\n### Submission data in ctx.params\n\n- All field values are available by field name: `params.name`, `params.email`, etc.\n- **File fields** contain R2 URL strings (e.g., `\"https://r2.mug.work/workspace/uploads/abc123.jpg\"`). Use `fetch()` in a workflow to download and process uploaded files.\n- `params._verified_email` — present when access mode is `identify` or `auth` with email\n- `params._verified_phone` — present when access mode is `identify` or `auth` with phone\n- `params._surface` — the form's surface ID\n- `params._workspace` — workspace name\n- `params._edit` — `true` when the submission is an edit of an existing record\n- `params._editRecord` — original record data (edit mode only)\n\nFor the full `ctx.params` shape across all trigger types (forms, portals, webhooks), see `.mug/docs/api.md`.\n\n## Step 4 — Test\n\n```bash\nmug dev # start the dev server — form is live immediately\n```\n\nOpen `http://localhost:8787/<name>` in a browser to test. No need to run a workflow first — the form config is read directly from `surfaces/`.\n\nFor production:\n```bash\nmug deploy # deploy workspace — surfaces upload automatically\n```\n\nThe production form lives at `https://<workspace>.mug.work/<name>`.\n\nWorkflows in `workflows/` are auto-discovered by `mug deploy` — no import needed.\n\n## Step 5 — Validate (optional)\n\n```bash\nmug form validate # check all form configs for errors\nmug form list # show forms and URLs\n```\n\n---\n\n## Form Feature Catalog\n\nReference for all form capabilities. Use these when designing forms based on user requirements.\n\n### Field Types\n\n**Text fields** — single-line input:\n```json\n{ \"name\": \"company\", \"type\": \"text\", \"label\": \"Company Name\", \"required\": true, \"placeholder\": \"Acme Inc.\" }\n{ \"name\": \"email\", \"type\": \"email\", \"label\": \"Email Address\", \"required\": true }\n{ \"name\": \"phone\", \"type\": \"phone\", \"label\": \"Phone\", \"placeholder\": \"+1 555 123 4567\" }\n```\nAdd `pattern` for regex validation: `{ \"type\": \"text\", \"pattern\": \"^[A-Z]{2}\\\\d{4}$\" }`\n\n**Number field** — numeric input with constraints:\n```json\n{ \"name\": \"quantity\", \"type\": \"number\", \"label\": \"Quantity\", \"min\": 1, \"max\": 100, \"step\": 1 }\n```\nRenders numeric keyboard on mobile. `min`, `max`, `step` are all optional.\n\n**Select field** — dropdown:\n```json\n{\n \"name\": \"urgency\", \"type\": \"select\", \"label\": \"Priority\", \"required\": true,\n \"options\": [\n { \"label\": \"Low\", \"value\": \"low\" },\n { \"label\": \"Medium\", \"value\": \"medium\" },\n { \"label\": \"High\", \"value\": \"high\" }\n ]\n}\n```\n\n**Multiselect field** — checkboxes (submitted as array):\n```json\n{\n \"name\": \"services\", \"type\": \"multiselect\", \"label\": \"Services Needed\",\n \"options\": [\n { \"label\": \"Cleaning\", \"value\": \"cleaning\" },\n { \"label\": \"Repair\", \"value\": \"repair\" },\n { \"label\": \"Inspection\", \"value\": \"inspection\" }\n ]\n}\n```\n\n**Date field** — date picker:\n```json\n{ \"name\": \"preferred_date\", \"type\": \"date\", \"label\": \"Preferred Date\", \"min\": \"2026-01-01\", \"max\": \"2026-12-31\" }\n```\n\n**Textarea field** — multi-line text:\n```json\n{ \"name\": \"notes\", \"type\": \"textarea\", \"label\": \"Additional Notes\", \"rows\": 4, \"maxLength\": 2000 }\n```\n`rows` defaults to 3. `maxLength` is optional.\n\n**File field** — file upload (stored in R2):\n```json\n{ \"name\": \"photo\", \"type\": \"file\", \"label\": \"Upload Photo\", \"accept\": \"image/*\", \"maxSizeMb\": 5 }\n```\n`accept` uses standard MIME types (`image/*`, `.pdf`, `application/pdf`). `maxSizeMb` defaults to 10. The submitted value is the R2 URL of the uploaded file.\n\n**Calculated field** — auto-computed from other fields (read-only display):\n```json\n{ \"name\": \"total\", \"type\": \"calculated\", \"label\": \"Estimated Total\", \"expression\": \"hours * rate\", \"format\": \"currency\" }\n```\n`expression` references other field names. Supports `+`, `-`, `*`, `/`, `%`. Recalculates on every input change.\n`format`: `\"number\"` (default), `\"currency\"` ($X.XX), `\"percent\"` (X.X%).\n\n**Hidden field** — invisible, always submitted:\n```json\n{ \"name\": \"form_version\", \"type\": \"hidden\", \"default\": \"v2\" }\n{ \"name\": \"employee_id\", \"type\": \"hidden\", \"prefill\": { \"source\": \"auth\", \"column\": \"id\" } }\n```\nNever rendered. Use for tracking params, form IDs, or auth-prefilled values the user shouldn't see.\n\n### Default Values\n\nAny field can have a `default` — pre-populated on load, lowest priority in the fill chain:\n```json\n{ \"name\": \"status\", \"type\": \"hidden\", \"default\": \"pending\" }\n{ \"name\": \"priority\", \"type\": \"select\", \"label\": \"Priority\", \"default\": \"medium\", \"options\": [...] }\n```\n\n### Prefill\n\nAuto-fill from three sources — auth row, URL params, or database:\n\n**From auth row** (requires `auth` access mode — auto-fills from the user's table row):\n```json\n{ \"name\": \"employee_name\", \"type\": \"text\", \"label\": \"Your Name\",\n \"prefill\": { \"source\": \"auth\", \"column\": \"name\" }, \"locked\": true }\n```\n\n**From URL parameter:**\n```json\n{ \"name\": \"department\", \"type\": \"text\", \"label\": \"Department\",\n \"prefill\": { \"source\": \"url\", \"param\": \"dept\" } }\n```\n\n**From database record:**\n```json\n{ \"name\": \"manager\", \"type\": \"text\", \"label\": \"Manager\",\n \"prefill\": { \"source\": \"db\", \"table\": \"departments\", \"column\": \"manager_name\",\n \"match\": { \"column\": \"id\", \"param\": \"dept_id\" } }, \"locked\": true }\n```\n\n### Help Text\n\nDisplay a hint below the field label. Supports `{{column}}` templates resolved from the auth row:\n```json\n{ \"name\": \"hours\", \"type\": \"number\", \"label\": \"Hours Requested\",\n \"helpText\": \"You have {{available_pto_hours}} hours available\" }\n```\n\n### Field Name Uniqueness\n\n**Field names must be unique across the entire form** — including fields on different pages and conditional (`showWhen`) fields. Duplicate names cause silent data loss at submission. `mug form validate` and `mug dev` will error on duplicates.\n\n### Validation Rules\n\nCustom validation with user-facing error messages. Rules: `min`, `max`, `minLength`, `maxLength`, `pattern`. Values support `{{column}}` templates from the auth row:\n```json\n{ \"name\": \"hours\", \"type\": \"number\", \"label\": \"Hours Requested\", \"required\": true,\n \"helpText\": \"You have {{available_pto_hours}} hours available\",\n \"validate\": [\n { \"rule\": \"min\", \"value\": 0.5, \"message\": \"Must request at least 30 minutes\" },\n { \"rule\": \"max\", \"value\": \"{{available_pto_hours}}\", \"message\": \"Cannot exceed your {{available_pto_hours}} available hours\" }\n ]\n}\n```\nTemplate values that resolve to numbers are auto-coerced for `min`/`max`. Validation runs client-side per-page.\n\n### Locked Fields\n\n`\"locked\": true` makes a field read-only + server-enforced. The submitted value is always the known source (auth row, default, etc.) regardless of HTML tampering:\n```json\n{ \"name\": \"name\", \"type\": \"text\", \"label\": \"Your Name\",\n \"prefill\": { \"source\": \"auth\", \"column\": \"name\" }, \"locked\": true }\n```\n\n### Conditional Fields\n\nShow or hide any field based on other field values. Add `showWhen` to the field:\n\n```json\n{\n \"name\": \"emergency_details\", \"type\": \"textarea\", \"label\": \"Describe the emergency\",\n \"required\": true,\n \"showWhen\": [{ \"field\": \"service_type\", \"op\": \"eq\", \"value\": \"emergency\" }]\n}\n```\n\n**Operators:** `eq`, `neq`, `in`, `gt`, `lt`, `filled`, `empty`\n\n```json\nshowWhen: [{ \"field\": \"category\", \"op\": \"in\", \"value\": [\"repair\", \"emergency\"] }]\nshowWhen: [{ \"field\": \"quantity\", \"op\": \"gt\", \"value\": 10 }]\nshowWhen: [{ \"field\": \"photo\", \"op\": \"filled\" }]\n```\n\nMultiple conditions = AND logic (all must be true). Hidden fields are excluded from validation.\n\n### Multi-Page Forms\n\nUse the `pages` array instead of `fields` for multi-page forms:\n\n```json\n{\n \"type\": \"form\",\n \"title\": \"Client Onboarding\",\n \"workflow\": \"handle-onboarding\",\n \"pages\": [\n {\n \"id\": \"contact\",\n \"title\": \"Contact Information\",\n \"fields\": [\n { \"name\": \"name\", \"type\": \"text\", \"label\": \"Name\", \"required\": true },\n { \"name\": \"email\", \"type\": \"email\", \"label\": \"Email\", \"required\": true }\n ]\n },\n {\n \"id\": \"details\",\n \"title\": \"Request Details\",\n \"fields\": [\n { \"name\": \"service_type\", \"type\": \"select\", \"label\": \"Service\", \"required\": true, \"options\": [] },\n { \"name\": \"notes\", \"type\": \"textarea\", \"label\": \"Notes\" }\n ]\n }\n ]\n}\n```\n\nThe renderer auto-generates back/next buttons and a progress bar. Enter key advances pages. Validation runs per-page.\n\n**Conditional pages** — show or hide entire pages:\n```json\n{ \"id\": \"emergency-info\", \"title\": \"Emergency Details\", \"showWhen\": [{ \"field\": \"service_type\", \"op\": \"eq\", \"value\": \"emergency\" }], \"fields\": [] }\n```\n\n### Page Branching\n\nRoute users to different pages based on their answers:\n\n```json\n{\n \"id\": \"triage\",\n \"title\": \"What do you need?\",\n \"fields\": [\n { \"name\": \"request_type\", \"type\": \"select\", \"label\": \"Request Type\", \"required\": true,\n \"options\": [\n { \"label\": \"New Service\", \"value\": \"new\" },\n { \"label\": \"Emergency\", \"value\": \"emergency\" }\n ]\n }\n ],\n \"nextPage\": {\n \"conditions\": [\n { \"when\": [{ \"field\": \"request_type\", \"op\": \"eq\", \"value\": \"emergency\" }], \"goto\": \"urgent\" }\n ],\n \"default\": \"standard\"\n }\n}\n```\n\nSimple routing: `\"nextPage\": \"details\"` (string).\n\n### Access Modes\n\nControl who can access and submit the form.\n\n**Choosing the right mode:**\n- **`public`** — open form anyone can submit (contact us, feedback, public intake)\n- **`identify`** — anyone can access after verifying email (self-service requests where you need to know who submitted)\n- **`auth`** — only users in a database table can access (internal tools — employee forms, client-only portals)\n\n**Rule of thumb:** if you have a table of who should access this form, use `auth`. If anyone with a valid email can submit, use `identify`.\n\n**Public** — anyone can see and submit. No identity captured:\n```json\n\"access\": { \"mode\": \"public\" }\n```\n\n**Identify** — anyone can access after verifying email/phone:\n```json\n\"access\": { \"mode\": \"identify\", \"method\": \"email\", \"sessionDuration\": \"7d\" }\n\"access\": { \"mode\": \"identify\", \"method\": \"phone\", \"sessionDuration\": \"24h\" }\n```\nSession duration format: `30m`, `24h`, `7d`. After verification, the user stays verified for this duration.\n\n**Auth** — only users in a specific table can access the form:\n```json\n\"access\": {\n \"mode\": \"auth\",\n \"method\": \"email\",\n \"table\": \"employees\",\n \"matchColumn\": \"email\",\n \"sessionDuration\": \"7d\"\n}\n```\n\n**Auth with computed columns** — use `query` to enrich the auth row with JOINs or calculations instead of a plain `SELECT *`. Use `:identity` as the placeholder for the authenticated user's email/phone:\n```json\n\"access\": {\n \"mode\": \"auth\",\n \"method\": \"email\",\n \"table\": \"employees\",\n \"matchColumn\": \"email\",\n \"query\": \"SELECT e.*, r.hours_per_year - COALESCE(u.used, 0) AS available_pto FROM employees e LEFT JOIN pto_rules r ON r.employee_id = e.id LEFT JOIN (SELECT employee_id, SUM(hours) AS used FROM pto_requests WHERE status != 'denied' GROUP BY employee_id) u ON u.employee_id = e.id WHERE e.email = :identity\",\n \"sessionDuration\": \"7d\"\n}\n```\nComputed columns from `query` are available everywhere the auth row is used: `{{templates}}` in help text and validation, `prefill: { source: \"auth\" }`, and `ctx.params._auth_row` in workflows. Values are always fresh — no denormalized state to maintain. `table` and `matchColumn` are still required (used for dev-mode user dropdown).\n\nIn dev mode (`mug dev`), `auth` surfaces show a dropdown of registered users. `identify` surfaces show a freeform email input. Both bypass verification.\n\n### Edit Mode\n\nLoad an existing record into the form for editing:\n```json\n\"editMode\": {\n \"table\": \"service_requests\",\n \"recordParam\": \"id\",\n \"matchColumn\": \"id\"\n}\n```\nEdit URL: `https://<workspace>.mug.work/<form-id>?id=123`\n\n### Prefill from URL Parameters\n\nAny URL parameter that matches a field name pre-fills that field:\n```\nhttps://<workspace>.mug.work/<form-id>?name=John&email=john@example.com\n```\n\n### Breadcrumbs (cross-surface navigation)\n\nWhen linking to a form from another surface (e.g., a portal), add `?from=` and `?fromLabel=` query params to show a \"Back\" breadcrumb above the form header:\n```\n/request-form?from=/employee-portal&fromLabel=Back to Portal\n```\nWhen the form is opened directly (no `?from=`), no breadcrumb appears. `fromLabel` is optional — defaults to \"Back\".\n\n---\n\n## Advanced: Dynamic Forms (ctx.collect)\n\nFor forms that need to be generated programmatically at runtime — when fields depend on database state, forms are generated per-user, or you're A/B testing form variations — use `ctx.collect()` inside a workflow instead of a static JSON config.\n\n```typescript\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"create-dynamic-form\", async (ctx) => {\n const url = await ctx.collect({\n id: \"dynamic-intake\",\n title: \"Intake Form\",\n workflow: \"handle-intake\",\n access: { mode: \"public\" },\n fields: [\n { name: \"name\", type: \"text\", label: \"Name\", required: true },\n ],\n });\n return { formUrl: url };\n});\n```\n\n**Important:** Dynamic forms created via `ctx.collect()` require running the creation workflow before the form URL works. In production, run `mug run <workflow> --production` after deploying. In local dev, `ctx.collect()` does not persist the form config — use static JSON configs for local development.\n\n---\n\n## Complete Example\n\nEmployee time-off request with auth prefill — name auto-fills and locks from the employees table:\n\n**Form config** (`surfaces/request-timeoff.json`):\n```json\n{\n \"type\": \"form\",\n \"title\": \"Time-Off Request\",\n \"description\": \"Submit a time-off request for manager approval.\",\n \"submitText\": \"Submit Request\",\n \"workflow\": \"handle-timeoff\",\n \"access\": {\n \"mode\": \"auth\",\n \"method\": \"email\",\n \"table\": \"employees\",\n \"matchColumn\": \"email\",\n \"sessionDuration\": \"7d\"\n },\n \"fields\": [\n { \"name\": \"employee_id\", \"type\": \"hidden\", \"prefill\": { \"source\": \"auth\", \"column\": \"id\" } },\n { \"name\": \"employee_name\", \"type\": \"text\", \"label\": \"Your Name\",\n \"prefill\": { \"source\": \"auth\", \"column\": \"name\" }, \"locked\": true },\n { \"name\": \"start_date\", \"type\": \"date\", \"label\": \"Start Date\", \"required\": true },\n { \"name\": \"end_date\", \"type\": \"date\", \"label\": \"End Date\", \"required\": true },\n {\n \"name\": \"type\", \"type\": \"select\", \"label\": \"Type\", \"required\": true,\n \"options\": [\n { \"label\": \"PTO\", \"value\": \"PTO\" },\n { \"label\": \"Sick\", \"value\": \"Sick\" },\n { \"label\": \"Personal\", \"value\": \"Personal\" }\n ]\n },\n { \"name\": \"hours\", \"type\": \"number\", \"label\": \"Hours\", \"required\": true,\n \"helpText\": \"You have {{available_pto_hours}} hours available\",\n \"validate\": [\n { \"rule\": \"min\", \"value\": 0.5, \"message\": \"Must request at least 30 minutes\" },\n { \"rule\": \"max\", \"value\": \"{{available_pto_hours}}\", \"message\": \"Cannot exceed your {{available_pto_hours}} available hours\" }\n ]\n },\n { \"name\": \"reason\", \"type\": \"textarea\", \"label\": \"Reason\", \"rows\": 3 }\n ]\n}\n```\n\n**Submission handler** (`workflows/handle-timeoff.ts`):\n```typescript\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"handle-timeoff\", async (ctx) => {\n const params = ctx.params as Record<string, string>;\n const email = params._verified_email;\n\n await ctx.exec(\"main\", `CREATE TABLE IF NOT EXISTS time_off_requests (\n id INTEGER PRIMARY KEY AUTOINCREMENT, employee_id INTEGER, employee_email TEXT,\n employee_name TEXT, start_date TEXT, end_date TEXT, type TEXT, hours REAL,\n reason TEXT, status TEXT DEFAULT 'pending', created_at TEXT DEFAULT (datetime('now'))\n )`);\n\n await ctx.exec(\"main\", `INSERT INTO time_off_requests\n (employee_id, employee_email, employee_name, start_date, end_date, type, hours, reason)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\n [params.employee_id, email, params.employee_name, params.start_date,\n params.end_date, params.type, params.hours, params.reason ?? \"\"]);\n\n await ctx.notify.email({\n to: \"manager@example.com\",\n subject: `Time-off request from ${params.employee_name}`,\n message: `${params.employee_name} requested ${params.hours}h of ${params.type} from ${params.start_date} to ${params.end_date}.`,\n cta: { label: \"Review Requests\", url: ctx.surfaceUrl(\"approvals\") },\n });\n\n return { submitted: true };\n});\n```\n\n**Test:**\n```bash\nmug dev\nopen http://localhost:8787/request-timeoff\n```\n\nFor displaying submitted data back to users (dashboards, status trackers, approval inboxes), see the `/portal` skill.\nFor custom branding (logo and accent color on forms), add a `branding` section to `mug.json` — see `.mug/docs/forms.md`. Individual forms can override workspace branding with `\"branding\": { \"logoSquare\": \"files/logo.png\", \"accentColor\": \"#hex\" }` in the form JSON.\n",
|
|
22
|
+
"form/form.mdc": "---\ndescription: Creating Mug forms — collect data from users with field types, conditionals, multi-page, access modes, file uploads, calculated fields, hidden fields, defaults, prefill from auth/URL/DB, locked fields, validation rules with custom error messages, and help text with {{column}} template interpolation\nglobs: [\"surfaces/**\", \"workflows/**\"]\n---\n\n# Mug Forms\n\nForms are JSON config files in `surfaces/<name>.json`. They deploy as live web pages. Submissions trigger a handler workflow.\n\nFull API reference (all field types, condition operators, page branching, access modes, edit mode): `.mug/docs/forms.md`\n\n## Creating a Form\n\n**Form config** (`surfaces/<name>.json`):\n```json\n{\n \"type\": \"form\",\n \"title\": \"Service Request\",\n \"description\": \"Submit a new service request\",\n \"submitText\": \"Submit Request\",\n \"workflow\": \"handle-service-request\",\n \"access\": { \"mode\": \"public\" },\n \"fields\": [\n { \"name\": \"name\", \"type\": \"text\", \"label\": \"Name\", \"required\": true },\n { \"name\": \"email\", \"type\": \"email\", \"label\": \"Email\", \"required\": true },\n { \"name\": \"message\", \"type\": \"textarea\", \"label\": \"Message\", \"rows\": 4 }\n ]\n}\n```\n\n**Handler workflow** (`workflows/handle-<name>.ts`) — processes form data via `ctx.params`:\n```typescript\nworkflow(\"handle-<name>\", async (ctx) => {\n const params = ctx.params as Record<string, string>;\n const email = params._verified_email ?? params.email;\n await ctx.notify.email({ to: \"owner@example.com\", subject: \"New submission\", message: `From: ${params.name}` });\n return { processed: true };\n});\n```\n\nWorkflows in `workflows/` are auto-discovered by `mug deploy` — no import needed.\n\n## Form Config Fields\n\n- `type` — must be `\"form\"`\n- `title` — form heading (required)\n- `description` — subtitle below heading\n- `submitText` — submit button label (default: \"Submit\")\n- `workflow` — handler workflow name (required)\n- `access` — who can access (see Access Modes)\n- `fields` — shorthand for single-page form\n- `pages` — multi-page form (overrides `fields`)\n- `editMode` — load and update existing records\n\n## Submission Data (ctx.params)\n\n- Field values by name: `params.name`, `params.email`\n- `params._verified_email` — present with identify/auth access (email)\n- `params._verified_phone` — present with identify/auth access (phone)\n- `params._auth_row` — full row from auth table (auth access mode only)\n- `params._edit` — `\"true\"` for edit-mode submissions\n- File fields contain the R2 URL of the uploaded file — use `fetch()` in a workflow to download and process\n- Locked field values are enforced server-side — submitted values are overwritten with the known source\n\n## Field Types\n\n**Text fields** — single-line input:\n```json\n{ \"name\": \"company\", \"type\": \"text\", \"label\": \"Company Name\", \"required\": true, \"placeholder\": \"Acme Inc.\" }\n{ \"name\": \"email\", \"type\": \"email\", \"label\": \"Email Address\", \"required\": true }\n{ \"name\": \"phone\", \"type\": \"phone\", \"label\": \"Phone\", \"placeholder\": \"+1 555 123 4567\" }\n```\nAdd `pattern` for regex validation: `{ \"type\": \"text\", \"pattern\": \"^[A-Z]{2}\\\\d{4}$\" }`\n\n**Number field** — numeric input with constraints:\n```json\n{ \"name\": \"quantity\", \"type\": \"number\", \"label\": \"Quantity\", \"min\": 1, \"max\": 100, \"step\": 1 }\n```\n\n**Select field** — dropdown:\n```json\n{\n \"name\": \"urgency\", \"type\": \"select\", \"label\": \"Priority\", \"required\": true,\n \"options\": [\n { \"label\": \"Low\", \"value\": \"low\" },\n { \"label\": \"Medium\", \"value\": \"medium\" },\n { \"label\": \"High\", \"value\": \"high\" }\n ]\n}\n```\n\n**Multiselect field** — checkboxes (submitted as array):\n```json\n{\n \"name\": \"services\", \"type\": \"multiselect\", \"label\": \"Services Needed\",\n \"options\": [\n { \"label\": \"Cleaning\", \"value\": \"cleaning\" },\n { \"label\": \"Repair\", \"value\": \"repair\" }\n ]\n}\n```\n\n**Date field** — date picker:\n```json\n{ \"name\": \"preferred_date\", \"type\": \"date\", \"label\": \"Preferred Date\", \"min\": \"2026-01-01\", \"max\": \"2026-12-31\" }\n```\n\n**Textarea field** — multi-line text:\n```json\n{ \"name\": \"notes\", \"type\": \"textarea\", \"label\": \"Notes\", \"rows\": 4, \"maxLength\": 2000 }\n```\n\n**File field** — upload to R2:\n```json\n{ \"name\": \"photo\", \"type\": \"file\", \"label\": \"Upload Photo\", \"accept\": \"image/*\", \"maxSizeMb\": 5 }\n```\n\n**Calculated field** — auto-computed (read-only):\n```json\n{ \"name\": \"total\", \"type\": \"calculated\", \"label\": \"Total\", \"expression\": \"hours * rate\", \"format\": \"currency\" }\n```\nFormats: `\"number\"` (default), `\"currency\"` ($X.XX), `\"percent\"` (X.X%). Expression supports `+`, `-`, `*`, `/`, `%`.\n\n**Hidden field** — invisible, always submitted:\n```json\n{ \"name\": \"form_version\", \"type\": \"hidden\", \"default\": \"v2\" }\n{ \"name\": \"employee_id\", \"type\": \"hidden\", \"prefill\": { \"source\": \"auth\", \"column\": \"id\" } }\n```\n\n## Default Values\n\nAny field: `\"default\": \"pending\"`. Lowest priority in fill chain (prefill > editRecord > URL param > default).\n\n## Prefill\n\nAuto-fill from auth row, URL params, or database:\n```json\n{ \"name\": \"name\", \"prefill\": { \"source\": \"auth\", \"column\": \"name\" }, \"locked\": true }\n{ \"name\": \"dept\", \"prefill\": { \"source\": \"url\", \"param\": \"department\" } }\n{ \"name\": \"manager\", \"prefill\": { \"source\": \"db\", \"table\": \"departments\", \"column\": \"manager_name\",\n \"match\": { \"column\": \"id\", \"param\": \"dept_id\" } }, \"locked\": true }\n```\nAuth prefill requires `auth` access mode. The runtime fetches the auth row (or runs the `query` if configured). Computed columns from `query` are available as prefill sources.\n\n## Help Text\n\nDisplay a hint below the field label. Supports `{{column}}` templates resolved from the auth row:\n```json\n{ \"name\": \"hours\", \"type\": \"number\", \"label\": \"Hours Requested\",\n \"helpText\": \"You have {{available_pto_hours}} hours available\" }\n```\n\n## Field Name Uniqueness\n\n**Field names must be unique across the entire form** — including fields on different pages and conditional (`showWhen`) fields. Duplicate names cause silent data loss at submission. `mug form validate` and `mug dev` will error on duplicates.\n\n## Validation Rules\n\nCustom validation with user-facing error messages. Rules: `min`, `max`, `minLength`, `maxLength`, `pattern`. Values support `{{column}}` templates:\n```json\n{ \"name\": \"hours\", \"type\": \"number\", \"label\": \"Hours Requested\", \"required\": true,\n \"helpText\": \"You have {{available_pto_hours}} hours available\",\n \"validate\": [\n { \"rule\": \"min\", \"value\": 0.5, \"message\": \"Must request at least 30 minutes\" },\n { \"rule\": \"max\", \"value\": \"{{available_pto_hours}}\", \"message\": \"Cannot exceed your {{available_pto_hours}} available hours\" }\n ]\n}\n```\nTemplate values that resolve to numbers are auto-coerced for `min`/`max`. Validation runs client-side per-page.\n\n## Locked Fields\n\n`\"locked\": true` — read-only UI + server-enforced. Submitted value is always the known source:\n```json\n{ \"name\": \"name\", \"type\": \"text\", \"label\": \"Your Name\",\n \"prefill\": { \"source\": \"auth\", \"column\": \"name\" }, \"locked\": true }\n```\n\n## Conditional Fields\n\nShow/hide fields based on other field values:\n```json\n{\n \"name\": \"emergency_details\", \"type\": \"textarea\", \"label\": \"Describe the emergency\",\n \"showWhen\": [{ \"field\": \"service_type\", \"op\": \"eq\", \"value\": \"emergency\" }]\n}\n```\n\n**Operators:** `eq`, `neq`, `in`, `gt`, `lt`, `filled`, `empty`\n\nMultiple conditions = AND logic. Hidden fields skip validation.\n\n## Multi-Page Forms\n\n```json\n{\n \"type\": \"form\",\n \"title\": \"Client Onboarding\",\n \"workflow\": \"handle-onboarding\",\n \"pages\": [\n {\n \"id\": \"contact\",\n \"title\": \"Contact Info\",\n \"fields\": [\n { \"name\": \"name\", \"type\": \"text\", \"label\": \"Name\", \"required\": true },\n { \"name\": \"email\", \"type\": \"email\", \"label\": \"Email\", \"required\": true }\n ]\n },\n {\n \"id\": \"details\",\n \"title\": \"Details\",\n \"fields\": []\n }\n ]\n}\n```\n\nAuto-generates back/next buttons, progress bar. Validates per-page.\n\n**Conditional pages:** `\"showWhen\": [{ \"field\": \"type\", \"op\": \"eq\", \"value\": \"emergency\" }]` on the page object.\n\n**Page branching** — route to different pages based on answers:\n```json\n{\n \"id\": \"triage\",\n \"fields\": [],\n \"nextPage\": {\n \"conditions\": [\n { \"when\": [{ \"field\": \"request_type\", \"op\": \"eq\", \"value\": \"emergency\" }], \"goto\": \"urgent\" }\n ],\n \"default\": \"standard\"\n }\n}\n```\n\nSimple routing: `\"nextPage\": \"details\"` (string).\n\n## Access Modes\n\n**Choosing the right mode:**\n- **`public`** — open form (contact us, feedback, public intake)\n- **`identify`** — anyone can access after verifying email (self-service requests)\n- **`auth`** — only users in a database table (internal tools — employee forms, client portals)\n\n**Rule of thumb:** if you have a table of who should access this form, use `auth`.\n\n**Public** — anyone can submit:\n```json\n\"access\": { \"mode\": \"public\" }\n```\n\n**Identify** — anyone can access after verifying email/phone:\n```json\n\"access\": { \"mode\": \"identify\", \"method\": \"email\", \"sessionDuration\": \"7d\" }\n```\n\n**Auth** — only users in a specific table can access:\n```json\n\"access\": { \"mode\": \"auth\", \"method\": \"email\", \"table\": \"employees\", \"matchColumn\": \"email\", \"sessionDuration\": \"7d\" }\n```\n\n**Auth with computed columns** — use `query` to enrich the auth row with JOINs or calculations. The query replaces the default `SELECT *` lookup. Use `:identity` as the placeholder for the authenticated user's email/phone:\n```json\n\"access\": {\n \"mode\": \"auth\", \"method\": \"email\", \"table\": \"employees\", \"matchColumn\": \"email\",\n \"query\": \"SELECT e.*, r.hours_per_year - COALESCE(u.used, 0) AS available_pto FROM employees e LEFT JOIN pto_rules r ON r.employee_id = e.id LEFT JOIN (SELECT employee_id, SUM(hours) AS used FROM pto_requests WHERE status != 'denied' GROUP BY employee_id) u ON u.employee_id = e.id WHERE e.email = :identity\",\n \"sessionDuration\": \"7d\"\n}\n```\nThe computed columns are available in `{{templates}}`, prefill, validation rules, and `_auth_row` in workflows — always fresh, no denormalized state to maintain. `table` and `matchColumn` are still required (used for dev-mode user dropdown).\n\nSession duration format: `30m`, `24h`, `7d`. In dev mode, `auth` shows a user dropdown; `identify` shows freeform input. Both bypass verification.\n\n## Edit Mode\n\nLoad existing record, update on submit:\n```json\n\"editMode\": { \"table\": \"service_requests\", \"recordParam\": \"id\", \"matchColumn\": \"id\" }\n```\nURL: `https://<workspace>.mug.work/<form-id>?id=123`\n\n## Prefill from URL Parameters\n\nAny URL param matching a field name pre-fills it:\n```\nhttps://<workspace>.mug.work/<form-id>?name=John&email=john@example.com\n```\n\n## Breadcrumbs\n\nWhen linking to this form from another surface (portal, etc.), add `?from=` and `?fromLabel=` to show a back link:\n```\n/request-form?from=/portal&fromLabel=Back to Portal\n```\nNo `?from=` = no breadcrumb. `fromLabel` defaults to \"Back\".\n\n## CLI Commands\n\n```bash\nmug form init <name> # scaffold form config + handler workflow\nmug form validate # check form configs for errors\nmug form list # show forms and URLs\nmug dev # start dev server — forms are live immediately\nmug deploy # deploy to production\n```\n\n## Advanced: Dynamic Forms (ctx.collect)\n\nFor forms generated programmatically at runtime (fields depend on DB state, per-user forms), use `ctx.collect()` in a workflow. Dynamic forms require running the workflow before the URL works. In local dev, `ctx.collect()` does not persist — use static JSON configs instead.\n\nFor displaying submitted data back to users (dashboards, approval inboxes), see the `/portal` skill.\nFor custom branding (logo and accent color on forms), add a `branding` section to `mug.json` — see `.mug/docs/forms.md`. Individual forms can override workspace branding with `\"branding\": { \"logoSquare\": \"files/logo.png\", \"accentColor\": \"#hex\" }` in the form JSON.\n",
|
|
23
|
+
"portal/SKILL.md": "---\nname: portal\ndescription: Create a portal surface — tabbed, section-based pages that query workspace data. Sections include tables (with detail pages + actions), stats cards, progress bars, charts, galleries, text blocks, and accordions. Used for dashboards, employee portals, approval inboxes, status trackers.\nargument-hint: \"<portal name or description>\"\n---\n\n# Create a Portal\n\nBuild a portal that displays data from the workspace database. Portals are config-driven JSON files with tabs and typed sections. They render at `https://<workspace>.mug.work/<portal-id>`.\n\nFull API reference (all config fields, section types, formats, badge colors, action conditions): `.mug/docs/portals.md`\n\n## Input\n\nPortal name or description: `$ARGUMENTS`\n\nIf no argument provided, ask the user what they need. Clarify:\n- What data should it show? (tables, metrics, charts, text)\n- Who sees it? (public, verified identity, or restricted)\n- Multiple concerns? (suggest tabs — e.g., \"Time Off\" + \"Company\")\n- Any action buttons? (e.g., approve/deny — trigger workflows)\n\n## Step 1 — Name and plan\n\nPick a kebab-case name (e.g., `employee-portal`, `approvals`, `inventory`).\n\nPresent a brief plan:\n- Tab structure (single tab = no tab bar shown)\n- Section layout per tab (stats at top, then table, etc.)\n- Data sources (tables, queries)\n- Access mode\n- Action buttons (if any)\n\nWait for user confirmation.\n\n## Step 2 — Scaffold the portal config\n\n```bash\nmug portal init <name>\n```\n\nCreates `surfaces/<name>.json` with a template config. Then edit to match the plan.\n\n## Step 3 — Write the portal config\n\nPortal configs are JSON files in `surfaces/`. The structure is:\n\n```json\n{\n \"type\": \"portal\",\n \"title\": \"Employee Portal\",\n \"access\": { \"mode\": \"auth\", \"method\": \"email\", \"table\": \"employees\", \"matchColumn\": \"email\", \"sessionDuration\": \"7d\" },\n \"sections\": [\n { \"type\": \"stats\", \"query\": \"...\", \"items\": [...] }\n ],\n \"tabs\": [\n {\n \"id\": \"main\",\n \"label\": \"Dashboard\",\n \"color\": \"#3b82f6\",\n \"countQuery\": \"SELECT count(*) FROM items WHERE owner = :user\",\n \"sections\": [\n { \"type\": \"table\", \"query\": \"...\", \"columns\": [...], \"detail\": {...}, \"actions\": [...] }\n ],\n \"links\": [{ \"label\": \"Submit Request\", \"href\": \"/request-form?from=/request-form-portal&fromLabel=Back to Portal\" }]\n }\n ]\n}\n```\n\n- **Top-level `sections`** render above the tab bar and stay visible across all tabs. Use for summary stats or announcements that apply globally.\n- **Tab `color`** sets the tab's text + underline color (e.g., amber for \"Pending\", green for \"Approved\").\n- **Tab `countQuery`** shows a dynamic count badge: `\"Pending (3)\"`. Query must return one row, one numeric column.\n\n### Section type catalog\n\n| Type | Query returns | Renders as |\n|------|-------------|------------|\n| `table` | rows | Paginated table → clickable rows → detail pages → action buttons |\n| `stats` | single row | Row of metric cards (label + big number) |\n| `progress` | multiple rows | Row of progress bars (label + bar + subtitle) |\n| `text` | none | Markdown block (headings, bold, italic, lists) |\n| `chart` | rows | Inline SVG bar or donut chart |\n| `gallery` | rows | Image card grid with title + metadata |\n| `accordion` | none | Collapsible container wrapping child sections |\n\n### Stats section\n\n```json\n{\n \"type\": \"stats\",\n \"query\": \"SELECT count(*) as total, sum(case when status='pending' then 1 else 0 end) as pending FROM requests WHERE email = :user\",\n \"items\": [\n { \"label\": \"Total\", \"column\": \"total\", \"valueColor\": \"neutral\" },\n { \"label\": \"Pending\", \"column\": \"pending\", \"format\": \"number\", \"color\": \"#f59e0b\", \"href\": \"?tab=pending\" }\n ]\n}\n```\n\nFormats: `number` (comma-separated), `currency` ($X,XXX.XX), `currency-whole` ($X,XXX — no cents), `currency-short` ($12.5K / $1.2M), `percent` (XX%). Stats default to whole dollars for `currency`. Values that overflow stat cards auto-abbreviate to K/M.\n\n`color` sets the top border; `valueColor` controls the number color: `\"match\"` (inherit border color — default when `color` is set), `\"neutral\"` (dark text), or a hex string. Omitting both uses workspace accentColor.\n\n`href` makes the stat card a clickable link — use `\"?tab=<id>\"` to navigate to a tab, or any URL.\n\n### Progress section\n\n```json\n{\n \"type\": \"progress\",\n \"query\": \"SELECT type, used, annual, (annual - used) as remaining FROM pto_balances WHERE email = :user\",\n \"labelColumn\": \"type\",\n \"valueColumn\": \"used\",\n \"maxColumn\": \"annual\",\n \"subtitleTemplate\": \"{{remaining}} hrs remaining\"\n}\n```\n\n### Text section\n\n```json\n{\n \"type\": \"text\",\n \"content\": \"## Welcome\\nLatest updates from the team.\\n\\n- **Policy update**: new PTO rules effective June 1\\n- Holiday schedule posted\"\n}\n```\n\nSupports: `# ## ### headings`, `**bold**`, `*italic*`, `- lists`, blank-line paragraph breaks.\n\n### Chart section\n\n```json\n{\n \"type\": \"chart\",\n \"query\": \"SELECT status as label, count(*) as value FROM runs GROUP BY status\",\n \"chartType\": \"donut\",\n \"title\": \"Status Breakdown\",\n \"colors\": { \"complete\": \"#16a34a\", \"error\": \"#ef4444\" }\n}\n```\n\n`chartType`: `\"bar\"` (horizontal bars, max 20) or `\"donut\"` (circle segments, max 8). `labelColumn` and `valueColumn` default to `\"label\"` and `\"value\"` — alias your SQL columns to match. `colors` maps label values to hex colors (unmatched labels use the default palette).\n\n### Gallery section\n\n```json\n{\n \"type\": \"gallery\",\n \"query\": \"SELECT id, photo_url, name, role FROM team ORDER BY name\",\n \"imageColumn\": \"photo_url\",\n \"titleColumn\": \"name\",\n \"fields\": [{ \"key\": \"role\", \"label\": \"Role\" }],\n \"columns\": 4\n}\n```\n\n### Accordion section\n\n```json\n{\n \"type\": \"accordion\",\n \"label\": \"Historical Requests\",\n \"open\": false,\n \"sections\": [\n { \"type\": \"table\", \"query\": \"...\", \"columns\": [...] }\n ]\n}\n```\n\nWraps child sections in a collapsible `<details>` element. Sections inside accordions work identically — they can be any type including nested accordions.\n\n### Table section (full)\n\n```json\n{\n \"type\": \"table\",\n \"query\": \"SELECT id, title, status, created_at FROM requests WHERE email = :user ORDER BY created_at DESC\",\n \"primaryKey\": \"id\",\n \"pageSize\": 25,\n \"columns\": [\n { \"key\": \"title\", \"label\": \"Title\" },\n { \"key\": \"status\", \"label\": \"Status\", \"badge\": true },\n { \"key\": \"created_at\", \"label\": \"Created\", \"format\": \"datetime\", \"dateFormat\": \"short\" }\n ],\n \"detail\": {\n \"title\": \"{{title}}\",\n \"fields\": [\n { \"key\": \"title\", \"label\": \"Title\" },\n { \"key\": \"status\", \"label\": \"Status\", \"badge\": true },\n { \"key\": \"created_at\", \"label\": \"Submitted\", \"format\": \"datetime\" }\n ]\n },\n \"actions\": [\n {\n \"name\": \"approve\",\n \"label\": \"Approve\",\n \"workflow\": \"handle-approval\",\n \"style\": \"success\",\n \"showWhen\": { \"field\": \"status\", \"op\": \"eq\", \"value\": \"pending\" },\n \"afterMessage\": \"Approved on {{timestamp}}\",\n \"afterActions\": [\n { \"label\": \"Revoke\", \"action\": \"revoke\", \"style\": \"danger\" }\n ]\n },\n {\n \"name\": \"deny\",\n \"label\": \"Deny\",\n \"workflow\": \"handle-approval\",\n \"style\": \"danger\",\n \"showWhen\": { \"field\": \"status\", \"op\": \"eq\", \"value\": \"pending\" },\n \"confirm\": \"Are you sure you want to deny this request?\"\n }\n ],\n \"emptyMessage\": \"No records found.\"\n}\n```\n\n**Action button styles**: `\"success\"` (green), `\"danger\"` (red), `\"warning\"` (amber), `\"primary\"` (accent), `\"default\"` (gray). Add `\"color\": \"#hex\"` for a custom color with working hover. Use `\"success\"`/`\"danger\"` for approve/deny instead of `\"primary\"` to avoid both buttons inheriting accentColor.\n\n**After-action behavior**: Actions are optimistic — the button is replaced immediately with a status message. Configure with:\n- `\"afterMessage\"` — custom status text. Use `{{timestamp}}` for the date/time. Default: \"✓ {label} · {timestamp}\".\n- `\"afterColor\"` — color for the status message text (hex). Default: workspace accent color. Use `\"#16a34a\"` for approve, `\"#dc2626\"` for deny.\n- `\"afterActions\"` — follow-up buttons shown after the action fires. Each has `label`, `action` (sent as `ctx.params.action`), and optional `style`. Fires the same workflow. Omit for no follow-up buttons.\n- `\"confirm\"` — shows a confirmation dialog before firing. Good for destructive actions.\n\n**Badge colors**: `\"badge\": true` uses built-in status colors (pending=yellow, approved=green, etc.). For non-status values, add `\"badgeColors\": { \"PTO\": \"#3b82f6\", \"Sick\": \"#ef4444\" }` — custom map checked first, then built-in fallback.\n\n**Timeline playback**: Action buttons can trigger scripted visual sequences instead of workflows — use `\"timeline\"` (array) instead of `\"workflow\"` (string). Purely client-side, no server round-trips. Great for client demos and presentations.\n\n```json\n{\n \"name\": \"send-reminder\",\n \"label\": \"Send Reminder\",\n \"style\": \"primary\",\n \"timeline\": [\n { \"delay\": 1, \"event\": \"toast\", \"message\": \"AI drafting reminder email...\" },\n { \"delay\": 3, \"event\": \"preview\", \"channel\": \"email\", \"to\": \"Mr. Davis\", \"subject\": \"Payment Reminder\", \"body\": \"Hi Mr. Davis,\\n\\nThis is a friendly reminder...\" },\n { \"delay\": 5, \"event\": \"toast\", \"message\": \"Email sent to Mr. Davis\" },\n { \"delay\": 7, \"event\": \"update\", \"row\": \"inv-001\", \"field\": \"status\", \"value\": \"reminded\" }\n ]\n}\n```\n\nTimeline event types:\n- `toast` — notification popup (top-right, auto-dismiss 4s, stacks). Fields: `message`.\n- `preview` — email/SMS preview card (slides in from right). Fields: `channel` (email/sms), `to`, `subject`, `body`.\n- `stream` — character-by-character text reveal into a target element. Fields: `target` (CSS selector), `text`.\n- `update` — change a table cell value with highlight flash. Fields: `row` (data-row-id), `field` (column header text), `value`.\n- `highlight` — pulse-highlight a table row or action button with a badge. Fields: `row` (data-row-id) for rows, `action` (action name) for buttons, `message` (badge text). Row highlights use gold; button highlights inherit the button's color. Commonly used with `autoplay` to guide users on page load.\n\nEach event has `delay` in seconds from the trigger moment (not cumulative). Button is disabled during playback.\n\n**Autoplay**: Add `\"autoplay\"` to the portal config (same event array) — fires once on page load. Use for AI summary streams on dashboards:\n```json\n{\n \"type\": \"portal\",\n \"title\": \"Weekly Report\",\n \"autoplay\": [\n { \"delay\": 1, \"event\": \"stream\", \"target\": \"#ai-summary\", \"text\": \"This week: revenue up 12%...\" },\n { \"delay\": 10, \"event\": \"toast\", \"message\": \"Report auto-delivered Monday at 7am\" }\n ],\n \"tabs\": [...]\n}\n```\n\n**Per-surface branding**: Add `\"branding\": { \"logoSquare\": \"files/logo.png\", \"accentColor\": \"#hex\" }` to override workspace branding for this portal. Surface values take precedence; workspace branding from `mug.json` is the fallback.\n\n**Embed mode**: Append `?embed=true` to any surface URL to strip header chrome (logo, session info, logout, breadcrumbs) for iframe embedding. Content (title, tabs, sections, actions) remains. CSP allows framing from `mug.work` and `*.mug.work`.\n\n## Step 4 — Write action handler workflows (if needed)\n\nActions trigger workflows. The workflow receives `{ action, ...rowData, _verified_email, _surface, _workspace }`.\n\n```typescript\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"handle-approval\", async (ctx) => {\n const params = ctx.params as Record<string, string>;\n const status = params.action === \"approve\" ? \"approved\" : \"denied\";\n await ctx.exec(\"main\", \"UPDATE requests SET status = ? WHERE id = ?\", [status, params.id]);\n return { id: params.id, status };\n});\n```\n\nWorkflows in `workflows/` are auto-discovered by `mug deploy` — no import needed.\n\n## Step 5 — Test locally\n\n```bash\nmug dev\nopen http://localhost:8787/<portal-name>\n```\n\nUse the \"View As\" banner to test as different users. Tab switching works via `?tab=<tabId>`.\n\n## Step 6 — Validate\n\n```bash\nmug portal list # show portal surfaces and URLs\n```\n\n---\n\n## Design guidance\n\n**Tab structure**: Use tabs when a portal serves multiple concerns (time off + pay + company news). Single-concern portals use one tab (tab bar hidden).\n\n**Section ordering**: Put summary sections (stats, progress) above detail sections (table). Text sections work as headers or announcements. Charts provide visual context.\n\n**Accordion usage**: Wrap historical or secondary data in accordions to keep the main view clean. Good for \"past requests\" below \"current requests\".\n\n**Access modes**:\n- `public` — anyone can view (dashboards, leaderboards)\n- `identify` — anyone can access after verifying email (customer self-service)\n- `auth` — only users in a database table (employee portals, manager tools)\n\n**Query binding**: use `:user` for the session email/phone. With `auth` mode, use `:auth.column` for any column from the user's auth table row (e.g., `:auth.department`, `:auth.id`, `:auth.role`).\n\n**Breadcrumbs**: When portal links point to other surfaces (forms, other portals), add `?from=` and `?fromLabel=` query params so the target surface shows a \"Back\" breadcrumb link. Example: `\"/request-form?from=/employee-portal&fromLabel=Back to Portal\"`. When the target surface is opened directly (no `?from=`), no breadcrumb appears.\n\n**Home screen**: The workspace home screen (`subdomain.mug.work/`) is configured via `surfaces/_home.json` — not a portal, but uses a similar JSON config with groups, buttons, and cards. See `.mug/docs/api.md` for the full schema.\n\nFor form creation, see the `/form` skill. For complex workflows, see the `/workflow` skill. For email notifications, see the `/notify` skill.\n",
|
|
24
|
+
"portal/portal.mdc": "---\ndescription: Creating Mug portals — tabbed, section-based surfaces that query workspace data. Section types: table (list+detail+actions), stats, progress, text, chart, gallery, accordion. Used for dashboards, employee portals, approval inboxes.\nglobs: [\"surfaces/**\"]\n---\n\n# Mug Portals\n\nPortals are config-driven JSON files in `surfaces/` that render as tabbed pages with typed sections.\n\nFull API reference: `.mug/docs/portals.md`\n\n## Config structure\n\n```json\n{\n \"type\": \"portal\",\n \"title\": \"Employee Portal\",\n \"access\": { \"mode\": \"auth\", \"method\": \"email\", \"table\": \"employees\", \"matchColumn\": \"email\", \"sessionDuration\": \"7d\" },\n \"sections\": [\n { \"type\": \"stats\", \"query\": \"...\", \"items\": [{ \"label\": \"Pending\", \"column\": \"pending\", \"color\": \"#f59e0b\", \"href\": \"?tab=pending\" }] }\n ],\n \"tabs\": [\n {\n \"id\": \"main\",\n \"label\": \"Dashboard\",\n \"color\": \"#3b82f6\",\n \"countQuery\": \"SELECT count(*) FROM items WHERE owner = :user\",\n \"sections\": [\n { \"type\": \"table\", \"query\": \"...\", \"columns\": [...], \"detail\": {...} }\n ],\n \"links\": [{ \"label\": \"Submit\", \"href\": \"/form?from=/portal&fromLabel=Back to Portal\" }]\n }\n ]\n}\n```\n\n- **Top-level `sections`**: rendered above tab bar, visible on all tabs. Use for summary stats or announcements.\n- **Tab `color`**: sets tab text + underline color. Use for status-meaning tabs (amber=pending, green=approved).\n- **Tab `countQuery`**: shows dynamic count badge — `\"Pending (3)\"`. Query returns one row, one number.\n\nSingle-tab portals: tab bar hidden. Multi-tab: `?tab=<id>` switches tabs (SSR, full reload).\n\n## Section types\n\n**table** — paginated rows → detail pages → action buttons:\n```json\n{ \"type\": \"table\", \"query\": \"SELECT ...\", \"columns\": [...], \"detail\": { \"title\": \"{{col}}\", \"fields\": [...] }, \"actions\": [...], \"pageSize\": 25, \"primaryKey\": \"id\" }\n```\n\n**stats** — metric cards from single-row query. `color` sets border; `valueColor` controls number color (`\"match\"` = inherit border, `\"neutral\"` = dark text, hex = explicit). `href` makes the card clickable (use `\"?tab=id\"` for tab nav). When `color` is set, `valueColor` defaults to `\"match\"`:\n```json\n{ \"type\": \"stats\", \"query\": \"SELECT count(*) as n FROM t\", \"items\": [{ \"label\": \"Total\", \"column\": \"n\", \"valueColor\": \"neutral\" }, { \"label\": \"Pending\", \"column\": \"p\", \"color\": \"#f59e0b\", \"href\": \"?tab=pending\" }] }\n```\n\n**progress** — progress bars, one per row. Optional `colorThresholds` for automatic coloring by percentage, or `colorColumn` to read color from query:\n```json\n{ \"type\": \"progress\", \"query\": \"SELECT type, used, max FROM balances WHERE email = :user\", \"labelColumn\": \"type\", \"valueColumn\": \"used\", \"maxColumn\": \"max\", \"subtitleTemplate\": \"{{remaining}} left\", \"colorThresholds\": [{\"percent\":50,\"color\":\"#10b981\"},{\"percent\":80,\"color\":\"#f59e0b\"},{\"percent\":100,\"color\":\"#ef4444\"}] }\n```\n\n**text** — static markdown (# headings, **bold**, *italic*, - lists):\n```json\n{ \"type\": \"text\", \"content\": \"## Announcements\\nWelcome back.\" }\n```\n\n**chart** — inline SVG bar or donut. `labelColumn`/`valueColumn` default to `\"label\"`/`\"value\"`. `colors` maps label values to hex:\n```json\n{ \"type\": \"chart\", \"query\": \"SELECT status as label, count(*) as value FROM runs GROUP BY status\", \"chartType\": \"donut\", \"title\": \"Status\", \"colors\": { \"complete\": \"#16a34a\", \"error\": \"#ef4444\" } }\n```\n\n**gallery** — image card grid:\n```json\n{ \"type\": \"gallery\", \"query\": \"SELECT photo, name FROM team\", \"imageColumn\": \"photo\", \"titleColumn\": \"name\", \"columns\": 3 }\n```\n\n**accordion** — collapsible container wrapping child sections:\n```json\n{ \"type\": \"accordion\", \"label\": \"History\", \"open\": false, \"sections\": [...] }\n```\n\n## Column/field formats\n\n`\"date\"`, `\"datetime\"`, `\"currency\"` ($X,XXX.XX), `\"currency-whole\"` ($X,XXX — no cents), `\"currency-short\"` ($12.5K/$1.2M), `\"number\"` (comma-separated), `\"percent\"` (XX%), `\"multiline\"` (fields only). Add `\"dateFormat\": \"short\"` for compact dates. Stats default to whole dollars for `currency`; values that overflow auto-abbreviate to K/M. `\"badge\": true` for colored status badges (green=approved/active, yellow=pending, red=denied, gray=cancelled). `\"badgeColors\": { \"PTO\": \"#3b82f6\" }` for custom non-status values — checked first, then built-in fallback.\n\n## `:user` and `:auth.column` parameters\n\n`:user` in SQL = verified session email/phone. Only use with `identify` or `auth` mode. In `public` mode, resolves to empty string.\n\n`:auth.column` in SQL = any column from the user's auth table row. Requires `auth` access mode. Example: `WHERE department = :auth.department` filters by the user's department. All column names from the auth table work: `:auth.id`, `:auth.name`, `:auth.role`, etc.\n\n## Action buttons\n\nOn table detail pages. Trigger workflows with `{ action, ...rowData, _verified_email, _surface, _workspace }`.\n\nStyles: `\"success\"` (green), `\"danger\"` (red), `\"warning\"` (amber), `\"primary\"` (accent), `\"default\"` (gray). Add `\"color\": \"#hex\"` for custom override with working hover. Use `\"success\"`/`\"danger\"` for approve/deny — avoids both buttons inheriting accentColor.\n\n```json\n{\n \"name\": \"approve\", \"label\": \"Approve\", \"workflow\": \"handle-approval\", \"style\": \"success\",\n \"showWhen\": { \"field\": \"status\", \"op\": \"eq\", \"value\": \"pending\" },\n \"afterMessage\": \"Approved on {{timestamp}}\", \"afterColor\": \"#16a34a\",\n \"afterActions\": [{ \"label\": \"Revoke\", \"action\": \"revoke\", \"style\": \"danger\" }]\n}\n```\n\nActions are **optimistic** — button replaced immediately with status message. On backend error, original buttons restore.\n- `afterMessage` — custom status text. `{{timestamp}}` = local date/time. Default: \"✓ {label} · {timestamp}\".\n- `afterColor` — color for the status message text (hex). Default: accent color. Match to action meaning: `\"#16a34a\"` for approve, `\"#dc2626\"` for deny.\n- `afterActions` — follow-up buttons rendered after action fires. Each has `label`, `action`, optional `style`. Fires same workflow. Omit for no follow-up.\n- `confirm` — confirmation dialog before firing.\n\n## Timeline playback\n\nAction buttons can trigger scripted visual sequences instead of workflows — set `\"timeline\"` (array) instead of `\"workflow\"` (string). Purely client-side, no server round-trips. Great for client demos and presentations.\n\n```json\n{\n \"name\": \"send-reminder\", \"label\": \"Send Reminder\", \"style\": \"primary\",\n \"timeline\": [\n { \"delay\": 1, \"event\": \"toast\", \"message\": \"AI drafting reminder...\" },\n { \"delay\": 3, \"event\": \"preview\", \"channel\": \"email\", \"to\": \"Mr. Davis\", \"body\": \"Hi Mr. Davis...\" },\n { \"delay\": 5, \"event\": \"toast\", \"message\": \"Email sent\" },\n { \"delay\": 7, \"event\": \"update\", \"row\": \"inv-001\", \"field\": \"status\", \"value\": \"reminded\" }\n ]\n}\n```\n\nEvent types: `toast` (popup, auto-dismiss 4s), `preview` (email/SMS card), `stream` (char-by-char text into `target` selector), `update` (swap cell by `row`+`field`), `highlight` (pulse-highlight a row via `row` or button via `action`, with optional `message` badge). `delay` = seconds from trigger.\n\n**Autoplay**: `\"autoplay\": [...]` on the portal config fires events on page load. Use for guided demos (`highlight` with `delay: 0`) or AI summary streams on dashboards.\n\n## Per-surface branding\n\nAdd `\"branding\": { \"logoSquare\": \"files/logo.png\", \"accentColor\": \"#hex\" }` to override workspace branding for this portal. Surface values take precedence; workspace branding from `mug.json` is the fallback.\n\n## Embed mode\n\nAppend `?embed=true` to any surface URL to strip header chrome (logo, session, logout, breadcrumbs) for iframe embedding. CSP allows framing from `mug.work` and `*.mug.work`.\n\n## Breadcrumbs\n\nWhen portal links point to other surfaces, add `?from=` and `?fromLabel=` to the href so the target shows a back link:\n```json\n{ \"label\": \"Submit\", \"href\": \"/form?from=/portal&fromLabel=Back to Portal\" }\n```\nNo `?from=` = no breadcrumb. Works on both form and portal surfaces.\n\n## Access modes\n\n- `\"public\"` — anyone\n- `\"identify\"` — verify email, anyone can access\n- `\"auth\"` — must exist in database table\n\n## CLI\n\n```bash\nmug portal init <name> # scaffold in surfaces/\nmug portal list # list portals with URLs\nmug dev # serve locally with View As banner\n```\n\n**Home screen**: `surfaces/_home.json` configures the workspace root URL layout (groups, buttons, cards). Not a portal but similar config. See `.mug/docs/api.md`.\n",
|
|
25
|
+
"notify/SKILL.md": "---\nname: notify\ndescription: Send email or SMS notifications from workflows — styled templates with CTA buttons, surface links, workspace branding. Covers ctx.notify.email(), ctx.surfaceUrl(), and local dev delivery.\nargument-hint: \"<notification type or description>\"\n---\n\n# Send Notifications\n\nSend email or SMS notifications from workflows. Mug handles delivery (Resend for email, Twilio for SMS), styled HTML templates, and surface link generation.\n\nFor full API reference (all options, template rendering, BYOK configuration), see `.mug/docs/notifications.md`.\n\n## Input\n\nNotification description: `$ARGUMENTS`\n\nIf no argument provided, ask the user what they need to send. Clarify:\n- What triggers the notification? (form submission, workflow step, schedule)\n- Who receives it? (employee, manager, customer — how do you know their email/phone?)\n- What should the message say?\n- Should it link back to a surface? (portal, form, approval inbox)\n\n## Step 1 — Plan the notification\n\nIdentify:\n- Which workflow sends it\n- What data is available at send time (from query results, form params, etc.)\n- Whether it needs a CTA button linking to a surface\n- Email or SMS (or both)\n\nPresent a brief plan and wait for confirmation.\n\n## Step 2 — Write the notification code\n\nAdd `ctx.notify.email()` or `ctx.notify.sms()` to the workflow:\n\n```typescript\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"handle-request\", async (ctx) => {\n const params = ctx.params as Record<string, string>;\n\n // Insert the record\n await ctx.exec(\"main\", \"INSERT INTO requests (...) VALUES (...)\", [...]);\n\n // Notify the manager with a link to the approval surface\n await ctx.notify.email({\n to: \"manager@company.com\",\n subject: `New request from ${params.employee_name}`,\n message: `${params.employee_name} submitted a time-off request for ${params.start_date} to ${params.end_date}.`,\n cta: {\n label: \"Review Request\",\n url: ctx.surfaceUrl(\"approvals\"),\n },\n });\n\n return { notified: true };\n});\n```\n\n### ctx.notify.email(options)\n\n```typescript\nawait ctx.notify.email({\n to: string; // recipient email address\n message: string; // supports: **bold**, *italic*, - lists, 1. lists, [links](url). No headers/code/tables/images.\n subject?: string; // email subject line (default: \"Notification from Mug\")\n fromName?: string; // sender name (default: workspace name, e.g. \"Narvick Construction\")\n cta?: {\n label: string; // button text, e.g. \"Review Request\"\n url: string; // button URL — use ctx.surfaceUrl() for surface links\n };\n});\n```\n\n### ctx.notify.sms(options)\n\n```typescript\nawait ctx.notify.sms({\n to: string; // phone number (E.164 format: +1234567890)\n message: string; // message body (plain text, no markdown)\n});\n```\n\n### ctx.surfaceUrl(surfaceId, path?)\n\nGenerate a URL to a surface. Returns the correct URL for dev (localhost) and production (workspace subdomain).\n\n```typescript\nctx.surfaceUrl(\"approvals\")\n// dev: \"http://localhost:8787/approvals\"\n// prod: \"https://my-workspace.mug.work/approvals\"\n\nctx.surfaceUrl(\"portal\", `/row/${requestId}`)\n// dev: \"http://localhost:8787/portal/row/42\"\n// prod: \"https://my-workspace.mug.work/portal/row/42\"\n```\n\nAlways use `ctx.surfaceUrl()` instead of hardcoding URLs — it handles dev/prod automatically.\n\n### Email templates from files\n\nFor complex email bodies, store HTML templates in `files/` and load them at runtime:\n\n```typescript\nconst template = await ctx.fileText(\"templates/weekly-report.html\");\nconst body = template.replace(\"{{name}}\", customer.name).replace(\"{{total}}\", total);\nawait ctx.notify.email({ to: customer.email, message: body, subject: \"Weekly Report\" });\n```\n\nDrop templates in `files/templates/` and run `mug sync` to upload them to production.\n\n## Step 3 — Test locally\n\n```bash\nmug dev # start the dev server\nmug run <workflow> # trigger the workflow\n```\n\n**Dev email redirect:** `dev.email` in `mug.json` is auto-set to the logged-in user's email by `mug init`/`mug create`/`mug sync`/`mug dev`. All email notifications redirect to this address in dev mode.\n\n```json\n{\n \"dev\": {\n \"email\": \"developer@example.com\"\n }\n}\n```\n\nAll emails redirect to this address. The subject shows the original recipient: `[DEV → manager@company.com] New request`. The console logs redirects:\n```\n[email] Redirecting: manager@company.com → developer@example.com\n```\n\nSMS sends via Twilio in dev too. Check the console for delivery status.\n\n## Step 4 — Verify the email\n\nCheck the received email:\n- Subject line is correct\n- Message body renders with proper formatting\n- CTA button links to the right surface URL\n- Sender name shows the workspace name (not \"Mug\")\n\n---\n\n## Notification Patterns\n\nCommon patterns for notifications in workflows.\n\n### Form submission notification\n\nNotify someone when a form is submitted. The handler workflow receives form data in `ctx.params`:\n\n```typescript\nworkflow(\"handle-intake\", async (ctx) => {\n const { name, email, issue } = ctx.params as Record<string, string>;\n\n await ctx.exec(\"main\", \"INSERT INTO tickets (name, email, issue, status) VALUES (?, ?, ?, 'open')\",\n [name, email, issue]);\n\n await ctx.notify.email({\n to: \"support@company.com\",\n subject: `New ticket from ${name}`,\n message: `**${name}** (${email}) submitted a support ticket:\\n\\n${issue}`,\n cta: { label: \"View Tickets\", url: ctx.surfaceUrl(\"tickets\") },\n });\n});\n```\n\n### Approval result notification\n\nNotify the requester when their request is approved/denied:\n\n```typescript\nworkflow(\"handle-approval\", async (ctx) => {\n const params = ctx.params as Record<string, string>;\n const status = params.action === \"approve\" ? \"approved\" : \"denied\";\n\n await ctx.exec(\"main\", \"UPDATE requests SET status = ? WHERE id = ?\", [status, params.id]);\n\n await ctx.notify.email({\n to: params.employee_email,\n subject: `Your request was ${status}`,\n message: `Your time-off request for ${params.start_date} to ${params.end_date} has been ${status}.`,\n cta: { label: \"View Details\", url: ctx.surfaceUrl(\"portal\", `/row/${params.id}`) },\n });\n});\n```\n\n### Scheduled report notification\n\nSend a summary on a schedule:\n\n```typescript\nworkflow(\"weekly-summary\", async (ctx) => {\n const openTickets = await ctx.query(\"main\", \"SELECT COUNT(*) as count FROM tickets WHERE status = 'open'\");\n const resolved = await ctx.query(\"main\", \"SELECT COUNT(*) as count FROM tickets WHERE status = 'resolved' AND resolved_at > date('now', '-7 days')\");\n\n await ctx.notify.email({\n to: \"owner@company.com\",\n subject: `Weekly Summary — ${new Date().toLocaleDateString()}`,\n message: `**This week:**\\n- ${resolved[0].count} tickets resolved\\n- ${openTickets[0].count} tickets still open`,\n cta: { label: \"View Dashboard\", url: ctx.surfaceUrl(\"dashboard\") },\n });\n});\n```\n\n### SMS notification\n\nSMS is best for urgent, time-sensitive notifications. Keep messages short.\n\n```typescript\nawait ctx.notify.sms({\n to: \"+1234567890\",\n message: `New job assigned: ${job.address}. Reply ACCEPT or DECLINE.`,\n});\n```\n\n### BYOK — bring your own keys\n\nTo send from your own email domain or Twilio account, set your own API keys:\n\n```bash\nmug secret set RESEND_API_KEY=re_xxxxx # your Resend key\nmug secret set TWILIO_ACCOUNT_SID=AC_xxxxx # your Twilio account\nmug secret set TWILIO_AUTH_TOKEN=xxxxx\nmug secret set TWILIO_PHONE_NUMBER=+1xxxxx\n```\n\nBYOK sends bypass Mug's notification metering — unlimited sends, your own deliverability reputation.\n\nAI also supports BYOK — `mug secret set ai.anthropic=<key>` for unlimited AI calls. See the `/ai` skill for setup.\n\n---\n\n## Complete Example\n\nFull approval flow: form submission triggers manager notification, approval triggers employee notification.\n\n```typescript\n// workflows/handle-request.ts\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"handle-request\", async (ctx) => {\n const p = ctx.params as Record<string, string>;\n\n await ctx.exec(\"main\", `INSERT INTO requests (id, employee_name, employee_email, type, start_date, end_date, hours, reason, approver_email, status, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'))`,\n [crypto.randomUUID(), p.employee_name, p.employee_email, p.type, p.start_date, p.end_date, p.hours, p.reason, \"manager@company.com\"]);\n\n await ctx.notify.email({\n to: \"manager@company.com\",\n subject: `Time-off request from ${p.employee_name}`,\n message: `**${p.employee_name}** is requesting ${p.type} from ${p.start_date} to ${p.end_date} (${p.hours} hours).\\n\\nReason: ${p.reason}`,\n cta: { label: \"Review Request\", url: ctx.surfaceUrl(\"approvals\") },\n });\n\n return { status: \"pending\", notified: \"manager@company.com\" };\n});\n\n// workflows/handle-approval.ts\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"handle-approval\", async (ctx) => {\n const p = ctx.params as Record<string, string>;\n const status = p.action === \"approve\" ? \"approved\" : \"denied\";\n\n await ctx.exec(\"main\", \"UPDATE requests SET status = ?, reviewed_at = datetime('now') WHERE id = ?\", [status, p.id]);\n\n await ctx.notify.email({\n to: p.employee_email,\n subject: `Your time-off request was ${status}`,\n message: `Your ${p.type} request for ${p.start_date} to ${p.end_date} has been **${status}**.`,\n cta: { label: \"View My Requests\", url: ctx.surfaceUrl(\"portal\") },\n });\n\n return { id: p.id, status };\n});\n```\n\nFor form creation (the request form), see the `/form` skill.\nFor portals (the approval inbox and employee portal), see the `/portal` skill.\nFor complex workflow logic (AI classification, multi-source queries), see the `/workflow` skill.\nFor custom branding on email notifications (logo in header, accent color on CTA buttons), add a `branding` section to `mug.json` — see `.mug/docs/notifications.md`.\n",
|
|
26
|
+
"notify/notify.mdc": "---\ndescription: Sending email and SMS notifications from Mug workflows — ctx.notify.email() with CTA buttons, ctx.surfaceUrl() for surface links, fromName, local dev delivery, and BYOK configuration.\nglobs: [\"workflows/**\"]\n---\n\n# Mug Notifications\n\nSend email or SMS notifications from workflows. Mug handles delivery (Resend for email, Twilio for SMS), styled HTML templates, and surface link generation.\n\nFull API reference (all options, template rendering, BYOK configuration): `.mug/docs/notifications.md`\n\n## ctx.notify.email(options)\n\n```typescript\nawait ctx.notify.email({\n to: string; // recipient email address\n message: string; // email body (supports basic markdown: **bold**, *italic*, lists)\n subject?: string; // email subject line (default: \"Notification from Mug\")\n fromName?: string; // sender name (default: workspace name, e.g. \"Narvick Construction\")\n cta?: {\n label: string; // button text, e.g. \"Review Request\"\n url: string; // button URL — use ctx.surfaceUrl() for surface links\n };\n});\n```\n\n## ctx.notify.sms(options)\n\n```typescript\nawait ctx.notify.sms({\n to: string; // phone number (E.164 format: +1234567890)\n message: string; // message body (plain text, no markdown)\n});\n```\n\n## ctx.surfaceUrl(surfaceId, path?)\n\nGenerate a URL to a surface. Returns the correct URL for dev (localhost) and production (workspace subdomain).\n\n```typescript\nctx.surfaceUrl(\"approvals\")\n// dev: \"http://localhost:8787/approvals\"\n// prod: \"https://my-workspace.mug.work/approvals\"\n\nctx.surfaceUrl(\"portal\", `/row/${requestId}`)\n// dev: \"http://localhost:8787/portal/row/42\"\n// prod: \"https://my-workspace.mug.work/portal/row/42\"\n```\n\nAlways use `ctx.surfaceUrl()` instead of hardcoding URLs.\n\n## Email templates from files\n\nStore HTML templates in `files/` and load at runtime:\n```typescript\nconst template = await ctx.fileText(\"templates/weekly-report.html\");\nconst body = template.replace(\"{{name}}\", name);\nawait ctx.notify.email({ to, message: body, subject: \"Report\" });\n```\nDrop templates in `files/templates/`, run `mug sync` to upload.\n\n## Local dev\n\n`dev.email` in `mug.json` is auto-set to the logged-in user's email. All dev emails redirect there:\n```json\n{ \"dev\": { \"email\": \"developer@example.com\" } }\n```\n\nSubject shows original recipient: `[DEV → manager@company.com] New request`. Override by editing `mug.json`.\n\n## Notification Patterns\n\n### Form submission notification\n\n```typescript\nworkflow(\"handle-intake\", async (ctx) => {\n const { name, email, issue } = ctx.params as Record<string, string>;\n await ctx.exec(\"main\", \"INSERT INTO tickets (...) VALUES (...)\", [name, email, issue]);\n\n await ctx.notify.email({\n to: \"support@company.com\",\n subject: `New ticket from ${name}`,\n message: `**${name}** (${email}) submitted a support ticket:\\n\\n${issue}`,\n cta: { label: \"View Tickets\", url: ctx.surfaceUrl(\"tickets\") },\n });\n});\n```\n\n### Approval result notification\n\n```typescript\nworkflow(\"handle-approval\", async (ctx) => {\n const params = ctx.params as Record<string, string>;\n const status = params.action === \"approve\" ? \"approved\" : \"denied\";\n await ctx.exec(\"main\", \"UPDATE requests SET status = ? WHERE id = ?\", [status, params.id]);\n\n await ctx.notify.email({\n to: params.employee_email,\n subject: `Your request was ${status}`,\n message: `Your request for ${params.start_date} to ${params.end_date} has been ${status}.`,\n cta: { label: \"View Details\", url: ctx.surfaceUrl(\"portal\", `/row/${params.id}`) },\n });\n});\n```\n\n### Scheduled report\n\n```typescript\nworkflow(\"weekly-summary\", async (ctx) => {\n const open = await ctx.query(\"main\", \"SELECT COUNT(*) as count FROM tickets WHERE status = 'open'\");\n await ctx.notify.email({\n to: \"owner@company.com\",\n subject: `Weekly Summary — ${new Date().toLocaleDateString()}`,\n message: `**Open tickets:** ${open[0].count}`,\n cta: { label: \"View Dashboard\", url: ctx.surfaceUrl(\"dashboard\") },\n });\n});\n```\n\n### SMS (urgent/time-sensitive)\n\n```typescript\nawait ctx.notify.sms({\n to: \"+1234567890\",\n message: `New job assigned: ${job.address}. Reply ACCEPT or DECLINE.`,\n});\n```\n\n### BYOK — bring your own keys\n\n```bash\nmug secret set RESEND_API_KEY=re_xxxxx\nmug secret set TWILIO_ACCOUNT_SID=AC_xxxxx\nmug secret set TWILIO_AUTH_TOKEN=xxxxx\nmug secret set TWILIO_PHONE_NUMBER=+1xxxxx\n```\n\nBYOK sends bypass Mug's notification metering.\n\nAI also supports BYOK — `mug secret set ai.anthropic=<key>` for unlimited AI. See `.mug/docs/ai.md`.\n\n## Complete Example\n\n```typescript\n// workflows/handle-request.ts\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"handle-request\", async (ctx) => {\n const p = ctx.params as Record<string, string>;\n await ctx.exec(\"main\", `INSERT INTO requests (id, employee_name, employee_email, type, start_date, end_date, hours, reason, approver_email, status, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'))`,\n [crypto.randomUUID(), p.employee_name, p.employee_email, p.type, p.start_date, p.end_date, p.hours, p.reason, \"manager@company.com\"]);\n\n await ctx.notify.email({\n to: \"manager@company.com\",\n subject: `Time-off request from ${p.employee_name}`,\n message: `**${p.employee_name}** is requesting ${p.type} from ${p.start_date} to ${p.end_date} (${p.hours} hours).\\n\\nReason: ${p.reason}`,\n cta: { label: \"Review Request\", url: ctx.surfaceUrl(\"approvals\") },\n });\n return { status: \"pending\" };\n});\n\n// workflows/handle-approval.ts\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"handle-approval\", async (ctx) => {\n const p = ctx.params as Record<string, string>;\n const status = p.action === \"approve\" ? \"approved\" : \"denied\";\n await ctx.exec(\"main\", \"UPDATE requests SET status = ?, reviewed_at = datetime('now') WHERE id = ?\", [status, p.id]);\n\n await ctx.notify.email({\n to: p.employee_email,\n subject: `Your time-off request was ${status}`,\n message: `Your ${p.type} request for ${p.start_date} to ${p.end_date} has been **${status}**.`,\n cta: { label: \"View My Requests\", url: ctx.surfaceUrl(\"portal\") },\n });\n return { id: p.id, status };\n});\n```\n\nFor form creation, see the `/form` skill.\nFor portals and approval inboxes, see the `/portal` skill.\nFor workflow logic, see the `/workflow` skill.\nFor custom branding on email notifications (logo in header, accent color on CTA buttons), add a `branding` section to `mug.json` — see `.mug/docs/notifications.md`.\n",
|
|
27
|
+
"ai/SKILL.md": "---\nname: ai\ndescription: Add AI to a workflow — smart model routing, multi-provider support, BYOK billing. Helps pick the right model tier and billing config.\nargument-hint: \"<what the AI step should do>\"\n---\n\n# Add AI to a Workflow\n\nAdd an AI-powered step to a workflow using `ctx.ai()`. Smart routing automatically picks the cheapest model that handles the task well. Multi-provider support lets you route to OpenAI, Anthropic, Workers AI, or any Cloudflare AI Gateway provider. BYOK lets you bring your own API key for unlimited AI.\n\nFor full API reference (all parameters, architecture, model catalog, error handling), see `.mug/docs/ai.md`. For workflow setup, see the `/workflow` skill.\n\n## Input\n\nDescription of what the AI step should do: `$ARGUMENTS`\n\nIf no argument provided, ask the user:\n- What task? (classify, extract, summarize, analyze, generate text, complex reasoning)\n- What data does it work with? (tickets, invoices, emails, reports)\n- How precise does the output need to be? (exact category vs open-ended text)\n- Volume? (1 call per run vs hundreds — affects cost optimization)\n\n## Step 1 — Pick the approach\n\nBased on the task, recommend the right tier. **You set the `prompt` (user message) and `system` (system prompt) — these control what the AI does.**\n\n| Task | Model tier | Why |\n|------|------------|-----|\n| Classify into categories | `\"fast\"` + `maxTokens: 10` | Cheapest, constrained output |\n| Extract structured data | `\"fast\"` | Cheap, pair with a JSON system prompt |\n| Summarize long text | `\"balanced\"` | Mid-tier handles comprehension |\n| Analyze and compare | `\"balanced\"` or `\"powerful\"` | Depends on complexity |\n| Generate text/content | `\"balanced\"` | Mid-tier for most generation |\n| Complex reasoning | `\"powerful\"` | Strongest model |\n\nPresent the recommendation and wait for user confirmation.\n\n## Step 2 — Write the AI step\n\nAdd the `ctx.ai()` call to the workflow. Follow these patterns:\n\n### Classification\n\n```typescript\nconst result = await ctx.ai(\"fast\", {\n prompt: `Classify this ${itemType}:\\n\\n${item.text}`,\n system: \"Reply with exactly one word: billing, technical, or general.\",\n maxTokens: 10,\n});\nconst category = result.text.trim().toLowerCase();\n```\n\n### Extraction\n\n```typescript\nconst result = await ctx.ai(\"fast\", {\n prompt: `Extract the following from this email:\\n\\n${email.body}\\n\\nReturn JSON: { \"name\": \"\", \"email\": \"\", \"issue\": \"\" }`,\n system: \"Return valid JSON only. No other text.\",\n maxTokens: 200,\n});\nconst extracted = JSON.parse(result.text);\n```\n\n### Summarization\n\n```typescript\nconst result = await ctx.ai(\"balanced\", {\n prompt: `Summarize this report in 2-3 sentences:\\n\\n${report.body}`,\n system: \"Be concise. Focus on key findings and actions.\",\n maxTokens: 200,\n});\n```\n\n### Analysis\n\n```typescript\nconst result = await ctx.ai(\"balanced\", {\n prompt: `Analyze these metrics and identify the top 3 issues:\\n\\n${JSON.stringify(metrics)}`,\n system: \"Be specific. Reference actual numbers. Prioritize by impact.\",\n maxTokens: 500,\n});\n```\n\n### Complex reasoning\n\n```typescript\nconst result = await ctx.ai(\"powerful\", {\n prompt: `Given this context:\\n${context}\\n\\nEvaluate whether we should ${decision}. Consider risks, costs, and timeline.`,\n system: \"Think step by step. Weigh pros and cons. End with a clear recommendation.\",\n maxTokens: 1000,\n});\n```\n\n### Decision making (structured)\n\n```typescript\nconst result = await ctx.ai(\"fast\", {\n prompt: `Should this expense be approved?\\n\\nAmount: $${expense.amount}\\nCategory: ${expense.category}\\nPolicy limit: $${policy.limit}\\nBudget remaining: $${budget.remaining}`,\n system: \"Reply with JSON: { \\\"approved\\\": true/false, \\\"reason\\\": \\\"one sentence\\\" }\",\n maxTokens: 50,\n});\nconst decision = JSON.parse(result.text);\n```\n\n## Step 3 — Optimize cost\n\n**Set `maxTokens` tight.** Default is 1024. If you expect a one-word answer, set it to 10. This affects routing — low maxTokens biases toward the fast tier.\n\n**Use tier names directly** — `\"fast\"` for classification/extraction, `\"balanced\"` for summarization/analysis, `\"powerful\"` for complex reasoning. More predictable than `\"auto\"`.\n\n**Check `result.routing`** during development to verify the tier selection:\n```typescript\nconst result = await ctx.ai(\"fast\", { prompt, system: \"Reply with one word.\", maxTokens: 10 });\nconsole.log(result.routing);\n// { tier: \"fast\", model: \"gpt-5.4-nano\", provider: \"openai\", reason: \"tier:fast\" }\n```\n\n## Step 4 — Configure BYOK (optional)\n\nFor unlimited AI without consuming Mug credits, bring your own API key:\n\n```bash\nmug secret set ai.anthropic=sk-ant-xxx\n```\n\nThen configure which tiers use your key in `mug.json`:\n\n```json\n{\n \"ai\": {\n \"billing\": {\n \"fast\": \"mug-metered\",\n \"balanced\": \"mug-metered\",\n \"powerful\": \"ai.anthropic\"\n }\n }\n}\n```\n\nOr set billing per-workflow:\n```typescript\nworkflow(\"expensive-analysis\", handler, { billing: \"ai.anthropic\" });\n```\n\nOr per-call:\n```typescript\nawait ctx.ai(\"powerful\", { prompt, system: \"...\", billing: \"ai.anthropic\" });\n```\n\n## Feature Catalog\n\n### Tier names (`\"fast\"`, `\"balanced\"`, `\"powerful\"`)\nPick the tier directly — uses your workspace's configured model for that tier. `\"fast\"` = cheapest (default: gpt-5.4-nano). `\"balanced\"` = mid-tier (default: kimi-k2.6). `\"powerful\"` = strongest (default: claude-sonnet-4-6). **Recommended approach.** Response includes `routing: { tier, model, provider, reason }`.\n\n### Auto routing (`\"auto\"`)\nMug picks the tier based on prompt complexity. Uses token count, keyword markers, and maxTokens. Less predictable than choosing the tier yourself — prefer explicit tier names.\n\n### Multi-provider models\nAny Cloudflare AI Gateway model: `\"openai/gpt-5.4-nano\"`, `\"anthropic/claude-sonnet-4-6\"`, `\"@cf/moonshotai/kimi-k2.6\"`. Configure defaults per tier in `mug.json` `ai.routing`. Override per-call with `routing: { fast: \"...\", balanced: \"...\" }`.\n\n### BYOK billing\nStore your key with `mug secret set ai.<provider>=<key>`. Reference in `mug.json` `ai.billing` per tier, or override per-call/per-workflow with `billing: \"ai.anthropic\"`. Zero Mug credit consumption.\n\n### Legacy model aliases\n`\"haiku\"`, `\"sonnet\"`, `\"opus\"` still work — mapped to Anthropic models. Prefer tier names for new code.\n\n### Per-call routing overrides\n```typescript\nawait ctx.ai(\"powerful\", {\n prompt,\n system: \"...\",\n routing: { powerful: \"anthropic/claude-opus-4-7\" },\n});\n```\n### Direct provider call\nSkip smart routing entirely:\n```typescript\nawait ctx.ai(\"openai/gpt-4.1\", { prompt, system });\n```\n\n## Complete Example\n\n```typescript\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"lead-scoring\", async (ctx) => {\n const leads = await ctx.query(\"crm\", `\n SELECT id, name, email, company, notes, source\n FROM leads WHERE scored_at IS NULL LIMIT 50\n `);\n\n let hot = 0, warm = 0, cold = 0;\n\n for (const lead of leads) {\n const score = await ctx.ai(\"fast\", {\n prompt: `Score this lead as hot, warm, or cold:\\n\\nName: ${lead.name}\\nCompany: ${lead.company}\\nSource: ${lead.source}\\nNotes: ${lead.notes}`,\n system: \"Reply with exactly one word: hot, warm, or cold. Hot = ready to buy. Warm = interested. Cold = unlikely.\",\n maxTokens: 5,\n });\n\n const tier = score.text.trim().toLowerCase();\n if (tier === \"hot\") hot++;\n else if (tier === \"warm\") warm++;\n else cold++;\n\n await ctx.exec(\"crm\", \"UPDATE leads SET score = ?, scored_at = datetime('now') WHERE id = ?\",\n [tier, lead.id as number]);\n\n // Hot leads get a personalized outreach draft\n if (tier === \"hot\") {\n const draft = await ctx.ai(\"balanced\", {\n prompt: `Write a brief, personalized outreach email for:\\n\\nName: ${lead.name}\\nCompany: ${lead.company}\\nNotes: ${lead.notes}`,\n system: \"Keep it under 100 words. Professional but warm. Reference something specific from their notes.\",\n maxTokens: 200,\n });\n\n await ctx.notify.email({\n to: \"sales@company.com\",\n subject: `Hot lead: ${lead.name} (${lead.company})`,\n message: `**Score:** ${tier}\\n\\n**Draft outreach:**\\n\\n${draft.text}`,\n });\n }\n }\n\n return { scored: leads.length, hot, warm, cold };\n});\n```\n",
|
|
28
|
+
"ai/ai.mdc": "---\ndescription: AI in Mug workflows — smart model routing, multi-provider support, BYOK billing\nglobs: [\"workflows/**\", \"connectors/**\"]\n---\n\n# Mug AI\n\nAdd AI to workflows with `ctx.ai()`. Smart routing picks the cheapest model that handles the task.\n\nFull API reference: `.mug/docs/ai.md`\n\n## ctx.ai() basics\n\n**You control the prompt and system message.** `prompt` = user message, `system` = system prompt. Both are passed directly to the model.\n\n```typescript\n// Pick a tier — uses your workspace's configured model\nconst result = await ctx.ai(\"fast\", {\n prompt: \"Classify this as billing, technical, or general\",\n system: \"Reply with exactly one word.\",\n maxTokens: 10,\n});\n// result.text = \"billing\"\n// result.routing = { tier: \"fast\", model: \"gpt-5.4-nano\", provider: \"openai\", reason: \"tier:fast\" }\n\n// Auto — Mug picks the tier based on prompt complexity\nawait ctx.ai(\"auto\", { prompt, system });\n\n// Direct provider/model\nawait ctx.ai(\"openai/gpt-4.1\", { prompt, system });\n```\n\n## Tiers\n\n| Tier | Default model | Best for |\n|------|---------------|----------|\n| `\"fast\"` | openai/gpt-5.4-nano | Classification, extraction, formatting |\n| `\"balanced\"` | @cf/moonshotai/kimi-k2.6 | Summarization, analysis, general-purpose |\n| `\"powerful\"` | anthropic/claude-sonnet-4-6 | Complex reasoning, nuanced judgment |\n\n## Patterns by use case\n\n### Classification\n```typescript\nconst result = await ctx.ai(\"fast\", {\n prompt: `Classify this ticket:\\n\\n${ticket.body}`,\n system: \"Reply with exactly one word: billing, technical, or general.\",\n maxTokens: 10,\n});\n```\n\n### Extraction\n```typescript\nconst result = await ctx.ai(\"fast\", {\n prompt: `Extract from this email:\\n\\n${email.body}\\n\\nReturn JSON: { \"name\": \"\", \"email\": \"\", \"issue\": \"\" }`,\n system: \"Return valid JSON only.\",\n maxTokens: 200,\n});\nconst data = JSON.parse(result.text);\n```\n\n### Summarization\n```typescript\nconst result = await ctx.ai(\"balanced\", {\n prompt: `Summarize in 2-3 sentences:\\n\\n${report.body}`,\n system: \"Be concise. Focus on key findings and actions.\",\n maxTokens: 200,\n});\n```\n\n### Analysis\n```typescript\nconst result = await ctx.ai(\"balanced\", {\n prompt: `Identify top 3 issues:\\n\\n${JSON.stringify(metrics)}`,\n system: \"Be specific. Reference actual numbers.\",\n maxTokens: 500,\n});\n```\n\n### Complex reasoning\n```typescript\nconst result = await ctx.ai(\"powerful\", {\n prompt: `Evaluate whether we should ${decision}. Consider risks, costs, timeline.`,\n system: \"Think step by step. End with a clear recommendation.\",\n maxTokens: 1000,\n});\n```\n\n## Multi-provider config (mug.json)\n\n```json\n{\n \"ai\": {\n \"routing\": {\n \"fast\": \"openai/gpt-5.4-nano\",\n \"balanced\": \"@cf/moonshotai/kimi-k2.6\",\n \"powerful\": \"anthropic/claude-sonnet-4-6\"\n }\n }\n}\n```\n\nPer-call model override:\n```typescript\nawait ctx.ai(\"powerful\", {\n prompt,\n system,\n routing: { powerful: \"anthropic/claude-opus-4-7\" },\n});\n```\n## BYOK (Bring Your Own Key)\n\nStore your key for unlimited AI at zero Mug credit cost:\n\n```bash\nmug secret set ai.anthropic=sk-ant-xxx\n```\n\nConfigure per-tier in mug.json:\n```json\n{\n \"ai\": {\n \"billing\": {\n \"fast\": \"mug-metered\",\n \"balanced\": \"mug-metered\",\n \"powerful\": \"ai.anthropic\"\n }\n }\n}\n```\n\nPer-workflow: `workflow(\"name\", handler, { billing: \"ai.anthropic\" })`\n\nPer-call: `await ctx.ai(\"auto\", { prompt, billing: \"ai.anthropic\" })`\n\n## Cost optimization\n\n- **Use tier names directly** — `\"fast\"` for classification/extraction, `\"balanced\"` for summarization, `\"powerful\"` for reasoning\n- **Set `maxTokens` tight** — if you expect one word, use `maxTokens: 10`\n- **BYOK the powerful tier** — most expensive calls on your own key, cheap tiers on Mug credits\n- **Check `result.routing`** during dev — verify the router picks the right tier\n\n## CLI commands\n\n```bash\nmug secret set ai.anthropic=<key> # Store BYOK key\nmug secret set ai.openai=<key> # Store BYOK key\nmug secret list # Show configured keys\nmug secret remove ai.anthropic # Remove a BYOK key\n```\n",
|
|
29
|
+
"demo/SKILL.md": "---\nname: demo\ndescription: Share deployed surfaces with stakeholders — pre-authenticated links, notification routing, workflow control. Covers mug demo enable/disable/status and ctx.isDemo.\nargument-hint: \"<surface name or demo question>\"\n---\n\n# Demo Mode\n\nShare deployed auth'd surfaces with stakeholders without requiring them to verify. Demo mode creates pre-authenticated links with configurable notification routing and optional workflow suppression.\n\nFor full API reference (all flags, notification modes, KV record format), see `.mug/docs/demo.md`.\n\n## Input\n\nSurface name or question: `$ARGUMENTS`\n\nIf no argument provided, ask the user what they need:\n- Which surface to demo? (must be auth-gated — not public). Use `_home` for the workspace home screen.\n- Who should view it as? (email or phone identity from their auth table)\n- Should notifications fire? If so, where should they go?\n- Should workflows run on form submission?\n\n## Step 1 — Prerequisites\n\nVerify:\n1. Surface exists in `surfaces/` and has `access.mode` set to `\"identify\"` or `\"auth\"` (demo mode is not needed for public surfaces). **Exception: `_home` (workspace home screen) always requires auth — it is never public.**\n2. If using `--as` with an email from the auth table, confirm the identity exists in the table so the surface shows real data\n3. Surface is deployed (`mug deploy` has been run)\n\n**Important:** The workspace home screen (`subdomain.mug.work/`) requires authentication. It is NOT public. To demo it, use `_home` as the surface ID. Demo mode on individual surfaces does NOT carry over to the home screen — you must enable `_home` separately.\n\n## Step 2 — Enable demo mode\n\n```bash\n# Basic: demo as a specific identity\nmug demo enable <surface> --as demo@example.com\n\n# With notification routing\nmug demo enable <surface> --as demo@example.com --notify dev\nmug demo enable <surface> --as demo@example.com --sms-to +15551234567\n\n# Suppress workflows (show form UI only)\nmug demo enable <surface> --as demo@example.com --no-workflows\n\n# Custom expiry\nmug demo enable <surface> --as demo@example.com --expires 30d\n```\n\nPresent the command and explain what will happen. Wait for confirmation.\n\n## Step 3 — Verify demo is active\n\n```bash\nmug demo status\n```\n\nTest the surface URL in a browser — it should render without requiring verification.\n\n## Step 4 — Update workflow code (if needed)\n\nNotification routing is automatic — no code changes needed for `ctx.notify.*` calls. The `--notify` mode handles redirection/suppression transparently.\n\nOnly add `ctx.isDemo` guards for non-notification side effects:\n\n```typescript\nworkflow(\"handle-request\", async (ctx) => {\n // Notifications auto-routed by demo config — no guard needed\n await ctx.notify.email({ to: params.manager_email, message: \"New request\" });\n\n // Guard destructive writes\n if (ctx.isDemo) return;\n await ctx.exec(\"ops\", \"UPDATE requests SET status = 'submitted' WHERE id = ?\", [params.id]);\n});\n```\n\n## Feature Catalog\n\n### Notification modes\n\n| Mode | Behavior |\n|------|----------|\n| `demo-user` (default) | Redirect to `--as` identity. Email→email identity, SMS→phone identity. Non-matching channels suppressed. |\n| `dev` | Redirect to developer's account email. SMS/Slack suppressed unless overridden. |\n| `off` | Suppress all. Logged in `mug logs` but not sent. |\n\n### Per-channel overrides\n\nOverride any mode for specific channels:\n- `--email-to <address>` — redirect email to this address\n- `--sms-to <phone>` — redirect SMS to this number\n- `--slack-to <channel>` — redirect Slack to this channel/user\n\nOverrides take precedence over the mode. Combine with any `--notify` value.\n\n### Workflow suppression\n\n`--no-workflows` prevents any workflow from firing on surface submissions. The surface still renders, accepts input, and shows success — but nothing executes server-side. Use when demoing form UI without triggering backend logic.\n\n### ctx.isDemo\n\n`true` in workflows triggered from demo surfaces. Notifications are already handled by demo config — use `ctx.isDemo` only for:\n- Destructive database writes\n- External API calls (payment processing, third-party integrations)\n- State mutations that shouldn't happen during a demo\n\n### Suppressed notification logging\n\nAll suppressed notifications appear in `mug logs` step output with the reason:\n```\nnotify-email-1: suppressed (demo mode: demo-user)\nnotify-sms-2: suppressed (demo mode: off)\n```\n\nThis lets you verify the workflow path without sending real notifications.\n\n### Home screen demo\n\nThe workspace home screen (`subdomain.mug.work/`) **requires authentication** — it is not public. Use `_home` as the surface ID:\n\n```bash\nmug demo enable _home --as demo@example.com\nmug demo disable _home\n```\n\nDemo mode on `_home` must be set **separately** from individual surfaces. Enabling demo on a surface like `employee-portal` does not make the home screen accessible — visitors still hit the auth gate at the root URL.\n\n### Managing demos\n\n```bash\nmug demo status # list all active demos\nmug demo disable <surface> # immediately revoke\n```\n\nDemos auto-expire based on `--expires` (default 7 days). No cleanup needed.\n\n## Complete Example\n\n```bash\n# 1. Create a demo persona in your auth table\nmug sql main \"INSERT INTO employees (email, name, role) VALUES ('demo@example.com', 'Demo User', 'Technician')\"\n\n# 2. Enable demo on both home screen and surfaces\nmug demo enable _home --as demo@example.com --notify dev\nmug demo enable employee-portal --as demo@example.com --notify dev --sms-to +15551234567\n\n# 3. Share the root URL with stakeholder — they see home screen → surfaces\n# https://my-workspace.mug.work/\n\n# 4. When done, disable both\nmug demo disable _home\nmug demo disable employee-portal\n```\n",
|
|
30
|
+
"demo/demo.mdc": "---\ndescription: Share deployed surfaces with stakeholders — pre-authenticated links, notification routing, workflow control. Covers mug demo enable/disable/status and ctx.isDemo.\nglobs: [\"surfaces/**\", \"workflows/**\"]\n---\n\n# Demo Mode\n\nShare deployed auth'd surfaces with stakeholders without requiring them to verify. Demo mode creates pre-authenticated links with configurable notification routing and optional workflow suppression.\n\nFor full API reference, see `.mug/docs/demo.md`.\n\n## Enable demo mode\n\n```bash\n# Basic: demo as a specific identity\nmug demo enable <surface> --as demo@example.com\n\n# With notification routing\nmug demo enable <surface> --as demo@example.com --notify dev\nmug demo enable <surface> --as demo@example.com --sms-to +15551234567\n\n# Suppress workflows (show form UI only)\nmug demo enable <surface> --as demo@example.com --no-workflows\n\n# Custom expiry (default 7d)\nmug demo enable <surface> --as demo@example.com --expires 30d\n```\n\n## Notification modes\n\n| Mode | Behavior |\n|------|----------|\n| `demo-user` (default) | Redirect to `--as` identity. Email→email identity, SMS→phone identity. Non-matching channels suppressed. |\n| `dev` | Redirect to developer's account email. SMS/Slack suppressed unless overridden. |\n| `off` | Suppress all. Logged in `mug logs` but not sent. |\n\n## Per-channel overrides\n\nOverride any mode for specific channels:\n- `--email-to <address>` — redirect email to this address\n- `--sms-to <phone>` — redirect SMS to this number\n- `--slack-to <channel>` — redirect Slack to this channel/user\n\nOverrides take precedence over the mode. Combine with any `--notify` value.\n\n## Workflow suppression\n\n`--no-workflows` prevents any workflow from firing on surface submissions. The surface still renders and accepts input but nothing executes server-side.\n\n## ctx.isDemo\n\n`true` in workflows triggered from demo surfaces. Notifications are already handled by demo config — use `ctx.isDemo` only for non-notification side effects:\n\n```typescript\nworkflow(\"handle-request\", async (ctx) => {\n // Notifications auto-routed — no guard needed\n await ctx.notify.email({ to: params.manager_email, message: \"New request\" });\n\n // Guard destructive writes\n if (ctx.isDemo) return;\n await ctx.exec(\"ops\", \"UPDATE requests SET status = 'submitted' WHERE id = ?\", [params.id]);\n});\n```\n\n## Suppressed notification logging\n\nSuppressed notifications appear in `mug logs` step output:\n```\nnotify-email-1: suppressed (demo mode: demo-user)\n```\n\n## Home screen demo\n\nThe workspace home screen (`subdomain.mug.work/`) **requires authentication** — it is not public. Use `_home` as the surface ID:\n\n```bash\nmug demo enable _home --as demo@example.com\nmug demo disable _home\n```\n\nDemo mode on `_home` must be set **separately** from individual surfaces. Enabling demo on a surface does not make the home screen accessible.\n\n## Managing demos\n\n```bash\nmug demo status # list all active demos\nmug demo disable <surface> # immediately revoke\n```\n\nDemos auto-expire based on `--expires` (default 7 days).\n\n## Complete Example\n\n```bash\n# Create a demo persona in auth table\nmug sql main \"INSERT INTO employees (email, name, role) VALUES ('demo@example.com', 'Demo User', 'Technician')\"\n\n# Enable demo on home screen and surface\nmug demo enable _home --as demo@example.com --notify dev\nmug demo enable employee-portal --as demo@example.com --notify dev --sms-to +15551234567\n\n# Share the root URL: https://my-workspace.mug.work/\n\n# When done, disable both\nmug demo disable _home\nmug demo disable employee-portal\n```\n",
|
|
31
|
+
"mug/SKILL.md": "---\nname: mug\ndescription: Run Mug CLI developer workflow commands — dev server, sync, deploy, shutdown.\nargument-hint: \"dev | shutdown | sync | deploy\"\n---\n\n# Mug CLI\n\nRun a Mug developer workflow command.\n\n## Input\n\nCommand: `$ARGUMENTS`\n\n## Dispatch\n\nRun the matching CLI command in the workspace root:\n\n- `dev` → see **Dev Server** below\n- `shutdown` → see **Shutdown** below\n- `sync` → `mug sync`\n- `deploy` → `mug deploy`\n\nIf no argument or unrecognized argument, show available commands and ask which to run.\n\n## Dev Server\n\nCheck if a dev server is already running by looking for the PID file:\n\n```bash\ncat .mug/dev.pid 2>/dev/null\n```\n\nIf a PID file exists with a running process, shut it down first:\n\n```bash\nmug shutdown\n```\n\nThen start the dev server:\n\n```bash\nmug dev\n```\n\nRun this command **in the background** — it's a long-running process.\n\nOptions:\n- `mug dev --port <port>` — pin to a specific port (default: auto-detect from 8787)\n- `mug dev --tunnel` — expose via Cloudflare Quick Tunnel (requires `cloudflared` installed)\n\nThe dev server auto-detects free ports, so multiple workspaces can run simultaneously.\n\n## Shutdown\n\nGracefully stop the running dev server. Writes back database changes and cleans up.\n\n```bash\nmug shutdown\n```\n\nThis reads `.mug/dev.pid` and sends SIGTERM to the dev server process.\n",
|
|
32
|
+
"agents/SKILL.md": "---\nname: agent\ndescription: Build a custom AI agent — autonomous multi-step work with tools, brain memory, skills, and structured output. Scaffolds the agent folder with agent.json, soul.md, and skills.\nargument-hint: \"<what the agent should do>\"\n---\n\n# Build a Custom AI Agent\n\nCreate an autonomous AI agent that runs multi-step work with tools, memory, and structured output. Each agent is a folder in `agents/<name>/` with config, instructions, and skills. Invoked from workflows via `ctx.agent()`.\n\nFor full API reference, see `.mug/docs/agents.md`. For workflow integration, see the `/workflow` skill.\n\n## Input\n\nDescription of what the agent should do: `$ARGUMENTS`\n\nIf no argument provided, ask the user:\n- What task? (analyze data, generate reports, process requests, classify items)\n- What data does it need? (workspace databases, external APIs, files)\n- Should it remember things? (entities, outcomes, struggles)\n- Any safety constraints? (max turns, credit limits, approval requirements)\n\n## Step 1 — Design the agent\n\nBased on the user's description, decide:\n\n1. **Name** — kebab-case, descriptive (e.g., `invoice-analyzer`, `support-responder`)\n2. **Model** — fixed (`\"claude-sonnet\"`) or dynamic routing (`{ \"fast\": \"claude-haiku\", \"balanced\": \"claude-sonnet\", \"powerful\": \"claude-opus\" }`)\n3. **Tools** — what workspace capabilities it needs:\n - `query` — read-only SQL against workspace databases\n - `search` — semantic similarity search against synced data\n - `ask` — natural language Q&A against databases\n - `notify` — send email/SMS notifications\n - `http` — call external APIs\n - `workspace` — read/write workspace files\n - `ai` — sub-AI calls for classify/extract/summarize within a turn\n4. **Memory** — `entities: true` for people/company facts, `outcomes: true` for action tracking, `struggles: true` for self-improvement signals\n5. **Caps** — `maxTurns` (default 50), `maxCredits` (default 500), `maxDuration` (default 300s)\n6. **Approval** — which tools need human approval before execution\n\n## Step 2 — Create the agent folder\n\nCreate `agents/<name>/agent.json`:\n\n```json\n{\n \"name\": \"<name>\",\n \"model\": \"claude-sonnet\",\n \"tools\": [\"query\", \"notify\"],\n \"memory\": { \"entities\": true, \"outcomes\": true, \"struggles\": true },\n \"caps\": { \"maxTurns\": 30, \"maxCredits\": 200, \"maxDuration\": 300 },\n \"requireApproval\": [\"notify\"]\n}\n```\n\nFor dynamic model routing (Mug picks fast/balanced/powerful per turn):\n\n```json\n{\n \"model\": {\n \"fast\": \"claude-haiku\",\n \"balanced\": \"claude-sonnet\",\n \"powerful\": \"claude-opus\"\n }\n}\n```\n\n## Step 3 — Write soul.md\n\nCreate `agents/<name>/soul.md`. This is the agent's core identity and instructions — always loaded into the system prompt:\n\n```markdown\n# <Agent Name>\n\nYou are a <role description> for <workspace context>.\n\n## What you do\n<clear description of the agent's purpose and scope>\n\n## How you work\n1. <step-by-step approach>\n2. <data sources to query>\n3. <decisions to make>\n\n## Rules\n- <constraints and boundaries>\n- When you lack information, call flag_struggle with category \"knowledge_gap\"\n- Always deliver results via deliver_output\n```\n\n## Step 4 — Add skills (optional)\n\nCreate agent-specific skills at `agents/<name>/skills/<skill-name>/SKILL.md`:\n\n```markdown\n---\ndescription: <what this skill teaches the agent>\n---\n\n# <Skill Title>\n\n<domain knowledge, procedures, reference data>\n```\n\nShared skills for all agents go in `agents/shared-skills/<skill-name>/SKILL.md`.\n\nSkills are auto-discovered — the agent gets a registry of available skills and loads them on demand.\n\n## Step 5 — Wire into a workflow\n\nThe agent needs a workflow to invoke it:\n\n```typescript\nconst result = await ctx.agent(\"<name>\", {\n goal: \"Analyze the latest invoices and flag overdue ones\",\n context: { customerId: \"abc123\" },\n caps: { maxTurns: 10 },\n});\n\nif (result.capped) {\n // Agent hit resource limits — result.output has partial results\n}\n\n// result.output has the structured data from deliver_output\n// result.usage has { credits, turns, duration }\n```\n\n## Step 6 — Deploy and verify\n\n```bash\nmug deploy\n```\n\nDeploy validates agent.json, writes soul.md + skills to the agent runtime, and creates an empty brain.db on first deploy.\n\nAfter the agent runs, inspect its memory:\n```bash\nmug brain <name> # overview\nmug brain <name> struggles # what the agent can't do yet\n```\n\n## Brain memory\n\nWhen memory is enabled, the agent gets 4 tools automatically:\n- `remember(content, entity?)` — store facts about people/companies\n- `recall(query)` — search memory for relevant context\n- `log_outcome(action, result, effective?)` — track what works\n- `flag_struggle(category, description)` — signal knowledge gaps for admin review\n\nThe brain also auto-detects struggles: cap hits, approval rejections, and fallback responses are logged without the agent needing to do anything.\n\nAt session start, the agent receives a digest of its memory: entity summaries, recent facts, unresolved struggles, and effectiveness stats.\n\n## Human-in-the-loop approval\n\nWhen `requireApproval` lists tool names, the agent pauses before executing:\n\n```typescript\nconst result = await ctx.agent(\"ops-assistant\", {\n goal: \"Review tickets and notify overdue ones\",\n sessionKey: \"ticket-review\",\n});\n\nif (result.status === \"pending_approval\" && result.pendingApproval) {\n await ctx.agent(\"ops-assistant\", { goal: \"Continue — approved\", sessionKey: \"ticket-review\" });\n}\n```\n\n## Key differences from ctx.ai()\n\n| | `ctx.ai()` | `ctx.agent()` |\n|---|---|---|\n| Scope | Single prompt → single response | Multi-turn autonomous work |\n| Tools | None | query, search, ask, notify, http, workspace, ai |\n| Memory | Stateless | Brain: entities, facts, outcomes, struggles |\n| Output | Raw text | Structured via deliver_output |\n| Cost control | Token limit | Turn/credit/duration caps |\n| Use case | Transform, classify, generate | Analyze, investigate, process |\n",
|
|
33
|
+
"agents/agents.mdc": "---\ndescription: Build a custom AI agent — autonomous multi-step work with tools, brain memory, skills, and structured output.\nglobs: agents/**\n---\n\n# Custom Agents\n\nEach agent is a folder in `agents/<name>/` with:\n- `agent.json` — config (model, tools, caps, memory, requireApproval)\n- `soul.md` — core identity, role, and primary instructions (always loaded)\n- `skills/` — agent-specific skills (auto-discovered, loaded on demand)\n- `brain.db` — persistent memory (created on first deploy, managed by runtime)\n\nShared skills: `agents/shared-skills/<skill-name>/SKILL.md`\n\n## agent.json\n\n```json\n{\n \"name\": \"invoice-analyzer\",\n \"model\": \"claude-sonnet\",\n \"tools\": [\"query\", \"search\", \"notify\"],\n \"memory\": { \"entities\": true, \"outcomes\": true, \"struggles\": true },\n \"caps\": { \"maxTurns\": 30, \"maxCredits\": 200, \"maxDuration\": 300 },\n \"requireApproval\": [\"notify\"]\n}\n```\n\nModel can be a fixed string or dynamic routing:\n```json\n{ \"model\": { \"fast\": \"claude-haiku\", \"balanced\": \"claude-sonnet\", \"powerful\": \"claude-opus\" } }\n```\n\n## Invoke from workflow\n\n```typescript\nconst result = await ctx.agent(\"invoice-analyzer\", {\n goal: \"Analyze overdue invoices and notify the team\",\n context: { threshold: 30 },\n caps: { maxTurns: 10 },\n});\n// result: { response, output?, usage: { credits, turns, duration }, capped?, cappedReason? }\n```\n\n## Tool grants\n\n- `query` — read-only SQL against workspace databases\n- `search` — semantic similarity search against synced data\n- `ask` — natural language Q&A against databases (search + AI synthesis)\n- `notify` — send email (`send_email`) and SMS (`send_sms`)\n- `http` — fetch external APIs (`http_request`)\n- `workspace` — read/write workspace files\n- `ai` — sub-AI calls for classify/extract/summarize (credits count against caps)\n- `custom:<name>` — custom tools defined in soul.md\n\n## Brain memory\n\nWhen memory is configured, the agent gets 4 tools automatically:\n- `remember(content, entity?)` — store facts, upsert entities\n- `recall(query)` — search memory for entities, facts, struggles\n- `log_outcome(action, result, effective?)` — track action effectiveness\n- `flag_struggle(category, description)` — signal knowledge gaps or edge cases\n\nAuto-detected struggles (no agent action needed): cap hits, approval rejections, fallback responses.\n\nSession-start digest: entity summaries, recent facts, unresolved struggles, effectiveness stats.\n\nAdmin reviews brain via `mug brain <agent-name> struggles`.\n\n## Approval flow\n\nWhen `requireApproval` lists tool names, agent pauses before calling them. Result includes `status: \"pending_approval\"` and `pendingApproval: { tool, args }`. Resume by re-invoking with the same `sessionKey`.\n\n## Cap enforcement\n\nWhen caps are hit, the agent is forced to call `deliver_output` with partial results. `result.capped` is `true` and `result.cappedReason` explains why.\n"
|
|
34
|
+
};
|
|
35
|
+
export const docTemplates = {
|
|
36
|
+
"api.md": "# Mug API Reference\n\nUnified reference for all cross-cutting APIs, data patterns, configuration, and CLI commands. Feature-specific types (form fields, portal config, source definitions) live in their own docs — this document covers everything that spans multiple features.\n\n## WorkspaceContext (ctx)\n\nEvery workflow receives a `ctx` object with these methods. All methods are automatically logged with timing, input/output, and token usage. Logs are visible via `mug logs`.\n\nAll `ctx.*` methods **throw on failure**. Wrap in try/catch if you need to handle errors gracefully. In production (Cloudflare Workflows), each `ctx.*` call is a durable step — if the Worker restarts mid-execution, it resumes from the last completed step.\n\n### ctx.query(database, sql, params?)\n\nRead from any workspace database. Returns an array of row objects.\n\n```typescript\nasync query(\n database: string,\n sql: string,\n params?: (string | number | null)[]\n): Promise<Record<string, unknown>[]>\n```\n\n```typescript\nconst rows = await ctx.query(\"hubspot\", \"SELECT * FROM contacts WHERE status = ?\", [\"active\"]);\n```\n\n- Locally, each `database` maps to `databases/<name>.db` — query with `mug sql` (no dev server needed). In production, each maps to a Durable Object with its own SQLite.\n- Databases are created automatically on first access — no registration needed\n- Always use `?` placeholders for parameterized queries (prevents SQL injection)\n- Each `ctx.query()` targets one database — see [Cross-database queries](#cross-database-queries) for correlating across sources\n\n**Throws:** SQL syntax errors, table not found, database connection errors.\n\n### ctx.exec(database, sql, params?)\n\nWrite to any workspace database. Returns the number of rows changed.\n\n```typescript\nasync exec(\n database: string,\n sql: string,\n params?: (string | number | null)[]\n): Promise<number>\n```\n\n```typescript\nawait ctx.exec(\"internal\", \"CREATE TABLE IF NOT EXISTS alerts (id TEXT PRIMARY KEY, message TEXT, sent_at TEXT)\");\nawait ctx.exec(\"internal\", \"INSERT INTO alerts (id, message, sent_at) VALUES (?, ?, ?)\",\n [crypto.randomUUID(), \"Payment received\", new Date().toISOString()]);\n```\n\nCommon patterns:\n```typescript\n// Upsert\nawait ctx.exec(\"internal\", `INSERT INTO contacts (id, name, email) VALUES (?, ?, ?)\n ON CONFLICT(id) DO UPDATE SET name = excluded.name, email = excluded.email`,\n [id, name, email]);\n\n// Delete\nawait ctx.exec(\"internal\", \"DELETE FROM alerts WHERE sent_at < ?\", [cutoffDate]);\n```\n\n**Throws:** SQL syntax errors, constraint violations, database connection errors.\n\n### ctx.ai(model, options)\n\nCall an AI model. Use tier names (`\"fast\"`, `\"balanced\"`, `\"powerful\"`) to pick the cost level — the tier resolves to your workspace's configured model. See [ai.md](ai.md) for the full AI reference including model catalog and BYOK.\n\n**You must set `prompt` (user message) and `system` (system prompt).** These are passed directly to the model — you have full control over what the AI sees.\n\n```typescript\nasync ai(\n model: string, // \"fast\", \"balanced\", \"powerful\" (recommended), \"auto\", or \"provider/model\"\n options: {\n prompt: string; // user message — the data/question\n system?: string; // system prompt — instructions for how to respond\n maxTokens?: number; // default: 1024\n routing?: { fast?: string; balanced?: string; powerful?: string };\n billing?: string; // \"mug-metered\" (default) or BYOK key name\n }\n): Promise<{\n text: string;\n model: string;\n usage: { input_tokens: number; output_tokens: number };\n routing?: { tier: \"fast\" | \"balanced\" | \"powerful\"; model: string; provider: string; reason: string };\n}>\n```\n\n```typescript\nconst result = await ctx.ai(\"fast\", {\n prompt: `Classify this ticket as \"billing\", \"technical\", or \"general\":\\n\\n${ticket.body}`,\n system: \"Reply with exactly one word.\",\n maxTokens: 10,\n});\n// result.text = \"billing\"\n// result.routing = { tier: \"fast\", model: \"gpt-5.4-nano\", provider: \"openai\", reason: \"tier:fast\" }\n```\n\n**Throws:** API rate limits, invalid model, network errors, BYOK key not found. In production, AI calls auto-retry (2 retries, exponential backoff).\n\n### ctx.search(query, options?)\n\nSemantic similarity search across synced data. Embeds the query, searches the workspace's Vectorize index, and returns ranked results with full row data from SQLite.\n\n```typescript\nasync search(\n query: string,\n options?: {\n source?: string; // scope to one table name\n limit?: number; // topK results, default 10, max 50\n filter?: Record<string, string>; // Vectorize metadata filter\n }\n): Promise<{\n score: number;\n table: string;\n primaryKey: string;\n row: Record<string, unknown>;\n}[]>\n```\n\n```typescript\nconst results = await ctx.search(\"roof leak complaints\", { source: \"jobs\", limit: 5 });\nfor (const r of results) {\n console.log(`${r.table}:${r.primaryKey} (${r.score.toFixed(3)}) — ${r.row.description}`);\n}\n```\n\nAll synced text columns are automatically embedded during source sync — no configuration needed. Results are deduplicated by primary key (if a row produced multiple chunks, only the highest-scoring match is returned).\n\n**Requires:** deployed workspace (Vectorize has no local emulation). Use FTS5 keyword search in local dev — see below.\n\n**FTS5 keyword search (works locally):** every synced table gets an auto-created `{table}_fts` full-text index. Query directly via `ctx.query()`:\n```typescript\nconst results = await ctx.query(\"hubspot\", \n `SELECT * FROM contacts JOIN contacts_fts ON contacts.rowid = contacts_fts.rowid\n WHERE contacts_fts MATCH ? ORDER BY rank LIMIT 10`,\n [\"roof leak\"]\n);\n```\n\n### ctx.ask(question, options?)\n\nFull RAG — retrieves relevant data via `ctx.search()`, formats it as context, and sends to an LLM for a grounded natural language answer.\n\n```typescript\nasync ask(\n question: string,\n options?: {\n source?: string; // scope search to one table\n limit?: number; // topK for retrieval, default 10\n model?: string; // LLM model/tier, default \"balanced\"\n system?: string; // additional system prompt context\n }\n): Promise<{\n answer: string;\n sources: SearchResult[];\n usage: { input_tokens: number; output_tokens: number; search_results: number };\n}>\n```\n\n```typescript\nconst result = await ctx.ask(\"Which jobs had roof complaints last month?\", {\n source: \"jobs\",\n system: \"You are an operations assistant for an HVAC company.\",\n});\n// result.answer = \"Based on the records, 3 jobs mentioned roof issues: ...\"\n// result.sources = [{ score: 0.87, table: \"jobs\", primaryKey: \"J-1234\", row: {...} }, ...]\n// result.usage = { input_tokens: 2100, output_tokens: 340, search_results: 10 }\n```\n\nContext window management: results are included until ~3000 tokens of context, prioritizing higher-scored results. The LLM is instructed to cite which records informed its answer.\n\n**Requires:** deployed workspace (uses Vectorize + AI).\n\n### ctx.notify.email(options)\n\nSend a styled HTML email with optional CTA button. Workspace branding (logo, accent color) is applied automatically.\n\n```typescript\nasync email(options: {\n to: string;\n message: string; // supports basic markdown\n subject?: string; // default: \"Notification from <Workspace Name>\"\n fromName?: string; // default: workspace name titlecased\n cta?: { label: string; url: string };\n}): Promise<string> // returns status: \"delivered\", \"logged\", \"blocked\", \"skipped\"\n```\n\n**Markdown support:** `**bold**`, `*italic*`, unordered lists (`- item`), ordered lists (`1. item`), `[links](url)`. Headers, code blocks, tables, and images are **not** supported.\n\n```typescript\nawait ctx.notify.email({\n to: \"manager@company.com\",\n subject: `New request from ${name}`,\n message: `**${name}** submitted a service request.\\n\\n- Type: ${type}\\n- Urgency: ${urgency}`,\n cta: { label: \"Review Request\", url: ctx.surfaceUrl(\"approvals\", `/row/${requestId}`) },\n});\n```\n\nIn local dev, emails redirect to `dev.email` in `mug.json` (auto-set to logged-in user). Subject shows original recipient. Dev mode proxies through the Mug platform notify service — no local Resend key needed.\n\n**Returns:** Status string — `\"delivered\"` (sent successfully), `\"logged\"` (recorded but not delivered), `\"blocked\"` (no dev redirect configured), `\"skipped\"` (dev proxy unreachable), `\"suppressed\"` (demo mode suppressed the notification).\n\n### ctx.notify.sms(options)\n\nSend an SMS via Twilio.\n\n```typescript\nasync sms(options: {\n to: string; // E.164 format: +1234567890\n message: string; // plain text\n}): Promise<string> // returns status: \"delivered\", \"logged\", \"blocked\", \"skipped\", \"suppressed\"\n```\n\n**Returns:** Same status strings as `ctx.notify.email()`.\n\n### ctx.notify.slack(options)\n\nSend a Slack message. Supports raw Block Kit blocks for rich formatting, threading, and link unfurling.\n\n```typescript\nasync slack(options: {\n to: string; // channel ID, channel name, or user ID\n message: string; // fallback text (shown in notifications and non-Block Kit clients)\n blocks?: unknown[]; // Block Kit blocks — use for rich formatting, buttons, sections\n thread_ts?: string; // reply in thread (message timestamp of parent)\n unfurl_links?: boolean; // unfurl URL previews (default: Slack's default)\n unfurl_media?: boolean; // unfurl media previews (default: Slack's default)\n}): Promise<string> // returns status: \"delivered\", \"logged\", \"blocked\", \"skipped\", \"suppressed\"\n```\n\n```typescript\n// Plain text\nawait ctx.notify.slack({ to: \"#ops-alerts\", message: \"New job assigned\" });\n\n// Block Kit with action buttons\nawait ctx.notify.slack({\n to: \"C01234ABCDE\",\n message: \"Approval needed\",\n blocks: [\n { type: \"section\", text: { type: \"mrkdwn\", text: `*New job:* ${job.title}` } },\n { type: \"actions\", elements: [\n { type: \"button\", text: { type: \"plain_text\", text: \"Approve\" },\n action_id: \"mug:handle-approval:approve\", value: job.id, style: \"primary\" },\n ]},\n ],\n});\n\n// Threading\nawait ctx.notify.slack({ to: channelId, message: \"Update\", thread_ts: originalTs });\n```\n\nAction ID convention: `mug:<workflow>:<custom>` routes button clicks to specific workflows. See the `/slack` skill for full Block Kit patterns.\n\n### ctx.notify.channel(name, options)\n\nGeneric notification sender for custom channels. The built-in `email`, `sms`, and `slack` methods are convenience wrappers around this.\n\n```typescript\nasync channel(name: string, options: {\n to: string;\n message: string;\n subject?: string;\n fromName?: string;\n cta?: { label: string; url: string };\n}): Promise<string>\n```\n\nThe channel name is passed to the dispatch notification service. Standard channels (`email`, `sms`, `slack`) route to their respective providers. Custom channel names are logged but require a matching provider configuration on the platform side.\n\n### ctx.surfaceUrl(surfaceId, path?)\n\nGenerate a URL to a workspace surface. Automatically returns the correct URL for dev or production.\n\n```typescript\nsurfaceUrl(surfaceId: string, path?: string): string\n```\n\n```typescript\nctx.surfaceUrl(\"approvals\")\n// dev: \"http://localhost:8787/approvals\"\n// prod: \"https://my-workspace.mug.work/approvals\"\n\nctx.surfaceUrl(\"portal\", `/row/${id}`)\n// dev: \"http://localhost:8787/portal/row/42\"\n// prod: \"https://my-workspace.mug.work/portal/row/42\"\n```\n\nUse in notification CTAs instead of hardcoding URLs.\n\n### ctx.file(path)\n\nRead a file from the workspace `files/` directory. Returns the file content as an `ArrayBuffer`. In dev, reads from local `files/` directory. In production, reads from R2 via content-addressed blob storage.\n\n```typescript\nfile(path: string): Promise<ArrayBuffer>\n```\n\n```typescript\nconst logoBuffer = await ctx.file(\"branding/logo.png\");\nconst csvData = await ctx.file(\"data/price-list.csv\");\n```\n\nThrows if the file is not found. Drop files in `files/` and run `mug sync` to upload them to production.\n\n### ctx.fileText(path)\n\nConvenience wrapper that reads a file as a UTF-8 string.\n\n```typescript\nfileText(path: string): Promise<string>\n```\n\n```typescript\nconst template = await ctx.fileText(\"templates/invoice.html\");\nconst config = JSON.parse(await ctx.fileText(\"config/rules.json\"));\n```\n\n### ctx.collect(options)\n\nCreate a form that collects data from users. Returns the live form URL. See [forms.md](forms.md) for all field types, conditionals, multi-page forms, and access modes.\n\n```typescript\nasync collect(options: CollectOptions): Promise<string>\n```\n\n```typescript\nconst url = await ctx.collect({\n title: \"Service Request\",\n fields: [\n { name: \"name\", label: \"Your Name\", type: \"text\", required: true },\n { name: \"issue\", label: \"Describe the issue\", type: \"textarea\" },\n ],\n workflow: \"handle-service-request\",\n});\n```\n\nThe `id` option controls the URL path segment. If omitted, a random 8-character ID is generated. Use a fixed `id` for predictable URLs.\n\n### ctx.waitFor(eventName, options?)\n\nPause the workflow until an external event arrives. The workflow waits at zero cost — no compute charges while paused. Returns when the event is received or the timeout expires.\n\n```typescript\nasync waitFor<T>(eventName: string, options?: WaitForOptions): Promise<WaitForResult<T>>\n```\n\n**Options:** `{ timeout?: string | number, message?: string }`\n- `timeout` — how long to wait. CF duration format: `\"1 hour\"`, `\"30 minutes\"`, `\"7 days\"`. Default: `\"24 hours\"`. Max: `\"365 days\"`.\n- `message` — human-readable description (for logging/UI).\n\n**Returns:** `{ payload: T, type: string, timedOut: boolean }`\n\n```typescript\n// Send approval email, then wait for response\nconst callbackUrl = await ctx.waitForUrl(\"approval\");\nawait ctx.notify.email({\n to: \"manager@example.com\",\n subject: \"Expense approval needed\",\n message: `${employee} submitted $${amount} for ${category}.`,\n cta: { label: \"Approve\", url: `${callbackUrl}?action=approve` },\n});\nconst result = await ctx.waitFor<{ action: string }>(\"approval\", { timeout: \"48 hours\" });\nif (result.timedOut) {\n await ctx.notify.email({ to: employee, message: \"Your request timed out.\" });\n} else if (result.payload.action === \"approve\") {\n await ctx.exec(\"expenses\", \"UPDATE requests SET status = 'approved' WHERE id = ?\", [requestId]);\n}\n```\n\nIn local dev, `waitFor` resolves immediately with an empty payload for testing.\n\n### ctx.waitForUrl(eventName)\n\nGenerate a one-time callback URL for embedding in notifications. When the URL is visited (GET) or POSTed to, it sends the matching event to the waiting workflow.\n\n```typescript\nasync waitForUrl(eventName: string): Promise<string>\n```\n\n```typescript\nconst url = await ctx.waitForUrl(\"approval\");\n// url: https://api.mug.work/_callback/abc-123-...\n// Embed in email CTA, SMS, Slack button, etc.\n\n// Approve/reject with different actions:\nawait ctx.notify.email({\n to: manager,\n message: \"Review this request.\",\n cta: { label: \"Approve\", url: `${url}?action=approve` },\n});\n// For reject, use the same callback URL with a different action query param\n```\n\nCallback URLs expire after 7 days. The event payload includes `{ action, respondedVia: \"callback\" }` plus any additional query parameters.\n\n### ctx.http(url, options?)\n\nOutbound HTTP request. Returns a result object. Throws `HttpError` on non-2xx by default. Auto-retries connection errors and 429 (rate limit) with exponential backoff.\n\n```typescript\nasync http(url: string, options?: HttpOptions): Promise<HttpResult>\n\ninterface HttpOptions {\n method?: string; // default \"GET\"\n headers?: Record<string, string>;\n body?: unknown; // JSON-serialized if object, string as-is\n throwOnError?: boolean; // default true — throw HttpError on non-2xx\n retry?: { attempts?: number } | false; // default: auto-retry connection + 429 only\n timeout?: number; // ms, default 30000\n sign?: { secret: string; header?: string }; // HMAC-SHA256 signing\n}\n\ninterface HttpResult {\n status: number;\n headers: Record<string, string>;\n body: string;\n json: unknown; // parsed if content-type is JSON, null otherwise\n ok: boolean; // true for 200-299\n}\n```\n\n```typescript\n// GET with auth\nconst result = await ctx.http(\"https://api.example.com/orders\", {\n headers: { Authorization: `Bearer ${ctx.secret(\"API_KEY\")}` },\n});\nconst orders = result.json;\n\n// POST with auto-JSON\nawait ctx.http(\"https://hooks.slack.com/services/T.../B.../xxx\", {\n method: \"POST\",\n body: { text: \"Invoice approved\" },\n});\n\n// Don't throw on error — handle manually\nconst res = await ctx.http(\"https://api.example.com/check\", { throwOnError: false });\nif (!res.ok) { /* handle */ }\n\n// HMAC signing (outbound webhook)\nawait ctx.http(\"https://partner.com/webhook\", {\n method: \"POST\",\n body: { event: \"order.shipped\", id: \"123\" },\n sign: { secret: \"PARTNER_HMAC_KEY\", header: \"X-Hub-Signature-256\" },\n});\n```\n\n**Retry behavior:** Connection errors and 429 responses auto-retry with exponential backoff (3 attempts). All other errors throw immediately. Set `retry: false` to disable, or `retry: { attempts: 5 }` for more attempts.\n\n**Metering:** Each `ctx.http()` call counts as 1 operation (not per retry).\n\n### ctx.respond(body, status?)\n\nSet a custom HTTP response for webhook-triggered workflows. First call wins — subsequent calls are no-ops. If never called, the webhook returns `{ ok: true }`.\n\n```typescript\nrespond(body: unknown, status?: number): void\n```\n\n```typescript\n// Slack URL verification\nif (ctx.params.type === \"url_verification\") {\n ctx.respond({ challenge: ctx.params.challenge });\n return;\n}\n\n// Custom status code\nctx.respond({ error: \"Invalid payload\" }, 400);\n```\n\n### ctx.params\n\nParameters passed to the workflow. The shape depends on how the workflow was triggered:\n\n#### Form submission\n\nUser-submitted field values plus metadata:\n\n```typescript\n{\n name: \"Alice Smith\", // form field values (by field name)\n email: \"alice@example.com\",\n photo: \"https://r2.mug.work/workspace/uploads/abc123.jpg\", // file uploads → R2 URLs\n _verified_email: \"alice@example.com\", // verified identity (identify/auth mode)\n _verified_phone: \"+1234567890\", // verified identity (phone mode)\n _auth_row: { id: 1, name: \"Alice Smith\", department: \"Engineering\", ... }, // full auth table row (auth mode)\n _surface: \"intake-form\", // surface ID\n _workspace: \"my-workspace\", // workspace name\n _edit: true, // present when editing existing record\n _editRecord: { id: \"123\", ... }, // original record data (edit mode)\n}\n```\n\n- **File upload fields** contain R2 URL strings, not binary data\n- `_verified_email` / `_verified_phone` are only present when the form uses `identify` or `auth` access mode\n- `_auth_row` is the auth row — only present with `auth` access mode. Contains all columns from the auth table, plus any computed columns if the access config uses `query`. Use it to access any user attribute (department, role, manager_id, balances, etc.) without a separate query\n- `_edit` and `_editRecord` are only present when the form uses `editMode`\n- **Locked field values** are enforced server-side — the handler receives the known source value regardless of what was submitted\n\n#### Portal action\n\nAll row data plus action metadata:\n\n```typescript\n{\n action: \"approve\", // the action name from portal config\n id: 123, // row data fields from the query\n employee_name: \"John\",\n status: \"pending\",\n _verified_email: \"manager@example.com\", // session identity\n _surface: \"approvals\", // portal surface ID\n _workspace: \"my-workspace\",\n}\n```\n\n#### Webhook\n\nThe POST body of the incoming webhook request:\n\n```typescript\n{\n workspace: \"my-workspace\",\n workflow: \"process-event\",\n // ... all fields from the webhook POST body\n}\n```\n\n#### Event trigger (data change)\n\nWhen a workflow has `trigger: { source, table, on }` in its options and synced data changes:\n\n```typescript\n{\n _trigger: {\n source: \"quickbooks\", // source that changed\n table: \"invoices\", // table that changed\n event: \"insert\", // \"insert\" | \"delete\"\n count: 3, // number of affected rows\n },\n rows: [...], // inserted/updated rows (on insert events)\n deletedPks: [...], // deleted primary keys (on delete events)\n}\n```\n\n#### `mug run` (CLI)\n\nCurrently empty (`{}`). Params from CLI are not yet supported — test handler workflows by submitting forms or triggering portal actions.\n\n### ctx.isDemo\n\n`true` when the workflow was triggered from a surface in demo mode (via `mug demo enable`).\n\n```typescript\nget isDemo(): boolean\n```\n\nNotifications are automatically routed during demo mode based on `--notify` mode and per-channel overrides — no manual guards needed for `ctx.notify.*`. Use `ctx.isDemo` to guard other side effects (destructive writes, external API calls):\n\n```typescript\nworkflow(\"handle-request\", async (ctx) => {\n // Notifications auto-routed by demo config\n await ctx.notify.sms({ to: manager.phone, message: \"New request submitted\" });\n\n // Guard non-notification side effects\n if (ctx.isDemo) return;\n await ctx.exec(\"ops\", \"UPDATE requests SET status = ? WHERE id = ?\", [\"submitted\", ctx.params.id]);\n});\n```\n\n**Demo surface IDs:** Any surface ID works with `mug demo enable`. Use `_home` as the surface ID to demo the workspace home screen.\n\n### ctx.instanceId\n\nUnique ID for the current workflow run. In production, the format is `workspace-timestamp-id`. In local dev, it's `local-{runId}`.\n\n```typescript\nget instanceId(): string | undefined\n```\n\nUse to correlate log entries, pass to external systems for tracking, or reference in `mug status`:\n\n```typescript\nawait ctx.notify.email({\n to: admin,\n message: `Workflow started. Track: \\`mug status ${workflowName} ${ctx.instanceId}\\``,\n});\n```\n\n### ctx.changesetId / ctx.changesetSource\n\nAuto-set on every workflow run. `changesetId` is a unique ID for the run, `changesetSource` is `\"workflow:<name>\"`. Passed to `ctx.exec()` automatically for audit trail — every database write is tagged with which workflow made the change.\n\n```typescript\nget changesetId(): string | undefined\nget changesetSource(): string | undefined\n```\n\nThese are set automatically — you don't need to manage them. They're useful when querying the ops database to trace which workflow modified a record.\n\n### ctx.steps\n\n*Read-only.* Array of `StepRecord` objects for the current workflow run. Each `ctx.*` call appends a step with timing, input/output, and token usage. Primarily used internally for logging — visible in `mug logs` output. Not typically needed in user workflow code.\n\n```typescript\nget steps(): StepRecord[]\n```\n\n### ctx.credential(name?)\n\n*Source context only.* Resolve an API credential from workspace secrets.\n\n```typescript\nasync credential(name?: string): Promise<string>\n```\n\n**Resolution chain:**\n\n1. Checks `mug.json` source `auth.value` for the source\n2. If `auth.value` matches an environment variable name → returns that env var's value\n3. If `auth.value` doesn't match an env var → returns it as a literal string\n4. Falls back to the platform credential store (OAuth tokens from `mug auth`)\n\nThe `name` parameter defaults to the source name. Override it when a source needs a differently-named credential.\n\n```typescript\n// In a source — resolves via the source config\nconst token = await ctx.credential();\n\n// Override credential name\nconst token = await ctx.credential(\"GITHUB_PAT\");\n```\n\n**Wiring credentials:**\n```bash\nmug secret set AIRTABLE_API_KEY=pat_xxxxx # store the credential\n```\n\n```json\n// mug.json — reference the credential (not the value itself)\n\"sources\": {\n \"airtable\": {\n \"auth\": { \"type\": \"bearer\", \"value\": \"AIRTABLE_API_KEY\" },\n \"baseUrl\": \"https://api.airtable.com/v0\",\n \"syncs\": { \"airtable\": { \"database\": \"airtable\", \"schedule\": \"*/15 * * * *\" } }\n }\n}\n```\n\nHere `auth.value` is `\"AIRTABLE_API_KEY\"` — the runtime checks if an env var with that name exists (it does, from `.mug/secrets`), and returns its value.\n\n**Throws:** `No credentials for \"<source>\"` when no credential can be resolved.\n\n### ctx.secret(name)\n\n*Workflow context.* Read a workspace secret by name. Secrets are stored in `.mug/secrets` via `mug secret set`.\n\n```typescript\nsecret(name: string): string\n```\n\nReturns the secret value as a string. **Throws** if the secret is not found.\n\n```typescript\n// Read an API key stored in .mug/secrets\nconst apiKey = ctx.secret(\"EXTERNAL_API_KEY\");\n\n// Use it for outbound HTTP calls\nconst res = await fetch(\"https://api.example.com/data\", {\n headers: { Authorization: `Bearer ${apiKey}` },\n});\n```\n\nUnlike `ctx.credential()` (which is source-only and resolves through `mug.json` source config), `ctx.secret()` is a direct key-value lookup — any secret set via `mug secret set KEY=VALUE` is accessible by name. Use it when workflows need API keys, tokens, or other secrets that aren't tied to a source.\n\n### ctx.slack.updateMessage(options)\n\nUpdate an existing Slack message (e.g., replace action buttons with a result after a user clicks). Requires `SLACK_BOT_TOKEN` in secrets.\n\n```typescript\nasync updateMessage(options: {\n channel: string; // channel ID\n ts: string; // message timestamp to update\n text?: string; // new fallback text\n blocks?: unknown[]; // new Block Kit blocks\n}): Promise<void>\n```\n\n```typescript\nawait ctx.slack.updateMessage({\n channel: ctx.params.channelId,\n ts: ctx.params.messageTs,\n text: \"Approved\",\n blocks: [\n { type: \"section\", text: { type: \"mrkdwn\", text: `*Approved* by <@${ctx.params.userId}>` } },\n ],\n});\n```\n\n**Throws:** `SLACK_BOT_TOKEN not configured`, Slack API errors.\n\n### ctx.slack.openModal(options)\n\nOpen a Slack modal from a slash command or interaction. Requires a `triggerId` from the incoming event.\n\n```typescript\nasync openModal(options: {\n triggerId: string; // from ctx.params.triggerId (slash commands/interactions)\n view: Record<string, unknown>; // Slack modal view payload\n}): Promise<void>\n```\n\n```typescript\nawait ctx.slack.openModal({\n triggerId: ctx.params.triggerId,\n view: {\n type: \"modal\",\n title: { type: \"plain_text\", text: \"Create Dispatch\" },\n submit: { type: \"plain_text\", text: \"Create\" },\n blocks: [\n { type: \"input\", element: { type: \"plain_text_input\", action_id: \"title\" },\n label: { type: \"plain_text\", text: \"Job Title\" } },\n ],\n },\n});\n```\n\n**Throws:** `SLACK_BOT_TOKEN not configured`, Slack API errors, expired trigger ID.\n\n### ctx.agent(name, options)\n\nInvoke a custom AI agent from a workflow. Agents run autonomous multi-step work with tools, memory, and structured output. See [agents.md](agents.md) for full agent config, memory, tool grants, and cap enforcement.\n\n```typescript\nasync agent(\n name: string,\n options: {\n goal: string; // what the agent should accomplish\n context?: Record<string, unknown>; // contextual data passed to the agent\n sessionKey?: string; // custom session key (default: runId-agentName)\n caps?: { maxTurns?: number; maxCredits?: number; maxDuration?: number };\n }\n): Promise<{\n response: string; // agent's text response\n output?: Record<string, unknown>; // structured output from deliver_output\n usage: { credits: number; turns: number; duration: number };\n capped?: boolean; // true if agent hit a cap\n cappedReason?: string; // \"turn_limit\" | \"credit_limit\" | \"duration_limit\"\n pendingApproval?: { tool: string; args: Record<string, unknown>; sessionKey: string };\n}>\n```\n\n```typescript\nconst result = await ctx.agent(\"invoice-analyzer\", {\n goal: \"Review all invoices from the past week and flag overdue ones\",\n context: { overdueThreshold: 30 },\n caps: { maxTurns: 20 },\n});\n\nif (result.pendingApproval) {\n // Agent paused for human approval — see agents.md for the full pattern\n}\n```\n\n**Throws:** Agent not found, agent runtime errors.\n\n### ctx.lastSync\n\n*Source context only.* ISO 8601 timestamp of the last successful sync, or `null` on first sync. Use for incremental sync logic.\n\n```typescript\nlastSync: string | null\n```\n\n## Data Patterns\n\n### Soft deletes\n\nSynced tables (from sources) include system columns:\n\n| Column | Purpose |\n|--------|---------|\n| `_mug_synced_at` | ISO timestamp of last sync that touched this row |\n| `_mug_deleted_at` | ISO timestamp when row was marked deleted (null if active) |\n\n**Always filter deleted rows when querying synced data:**\n\n```typescript\n// Good — excludes deleted records\nconst contacts = await ctx.query(\"hubspot\", \"SELECT * FROM contacts WHERE _mug_deleted_at IS NULL\");\n\n// Bad — includes records that were deleted in the source system\nconst contacts = await ctx.query(\"hubspot\", \"SELECT * FROM contacts\");\n```\n\nThis applies everywhere: workflows, portal queries, and any SQL against synced tables. Workflow-created tables (via `ctx.exec`) do not have system columns.\n\n### Database auto-creation\n\nEach database name in Mug maps to a `databases/<name>.db` file locally and a Durable Object in production. Use `mug sql <name> <sql>` to query locally (no dev server needed), `mug push databases/<name>` to upload to production, `mug pull databases/<name>` to download.\n\n- **Source databases** are registered in `mug.json` automatically when you configure a source\n- **Workflow databases** are created on first access — `ctx.exec(\"my-new-db\", \"CREATE TABLE IF NOT EXISTS...\")` creates both the database and the table. No mug.json registration needed. Locally, use `mug sql my-new-db \"CREATE TABLE...\"` to create the database file.\n- **The ops database** (`_mug_ops`) is created automatically and contains `workflow_runs` and `workflow_steps` tables\n\n### Cross-database queries\n\nEach `ctx.query()` call targets exactly one database. To correlate data across sources, query each database separately and join in TypeScript:\n\n```typescript\nconst invoices = await ctx.query(\"quickbooks\", \"SELECT * FROM invoices WHERE status = 'overdue'\");\nconst contacts = await ctx.query(\"hubspot\", \"SELECT * FROM contacts WHERE _mug_deleted_at IS NULL\");\n\nconst contactMap = new Map(contacts.map(c => [c.id, c]));\nconst enriched = invoices.map(inv => ({\n ...inv,\n contact: contactMap.get(inv.customer_id as string),\n}));\n```\n\n### Ops database\n\nWorkflow runs are automatically persisted to the `_mug_ops` database.\n\n**workflow_runs:**\n\n| Column | Type | Description |\n|--------|------|-------------|\n| id | TEXT PK | Run ID (`<name>-<timestamp>-<random>`) |\n| workflow | TEXT | Workflow name |\n| status | TEXT | `\"complete\"` or `\"errored\"` |\n| started_at | TEXT | ISO 8601 |\n| completed_at | TEXT | ISO 8601 |\n| duration_ms | INTEGER | Total duration |\n| params | TEXT | JSON input params |\n| result | TEXT | JSON return value |\n| error | TEXT | Error message |\n\n**workflow_steps:**\n\n| Column | Type | Description |\n|--------|------|-------------|\n| id | INTEGER PK | Auto-increment |\n| run_id | TEXT FK | References workflow_runs.id |\n| step_name | TEXT | Step identifier (e.g., `query-hubspot-1`) |\n| step_type | TEXT | `query`, `exec`, `ai`, `notify`, `collect` |\n| started_at | TEXT | ISO 8601 |\n| completed_at | TEXT | ISO 8601 |\n| duration_ms | INTEGER | Step duration |\n| input | TEXT | JSON (truncated to 4096 chars) |\n| output | TEXT | JSON (truncated to 4096 chars) |\n| error | TEXT | Error message |\n| tokens_used | INTEGER | AI token count |\n| retries | INTEGER | Retry count (default 0) |\n\n### The `:user` and `:auth.column` parameters (portals and forms)\n\nIn portal SQL queries, `:user` is a parameterized value bound to the current session's verified identity (email or phone).\n\n```sql\nSELECT * FROM requests WHERE employee_email = :user ORDER BY created_at DESC\n```\n\n- Bound as a parameterized query value — SQL-injection safe\n- Resolves to the session's verified email or phone\n- In dev mode, resolves to the \"View As\" banner identity\n- **With `access: { mode: \"public\" }`**: resolves to empty string — queries filtering on `:user` will return zero rows. Only use `:user` with `identify` or `auth` access modes.\n\nWith `auth` access mode, `:auth.column` references any column from the user's auth table row:\n\n```sql\nSELECT * FROM requests WHERE department = :auth.department ORDER BY created_at DESC\nSELECT * FROM tasks WHERE assignee_id = :auth.id AND status = 'active'\n```\n\n- Requires `auth` access mode — the runtime fetches `SELECT *` from the auth table at render time\n- Any column name from the auth table works: `:auth.id`, `:auth.name`, `:auth.department`, `:auth.role`\n- SQL-injection safe (parameterized)\n- Combine with `:user`: `WHERE email = :user AND department = :auth.department`\n\n## Error Handling\n\nAll `ctx.*` methods throw standard JavaScript `Error` objects on failure. Workflows that don't catch errors will terminate with `status: \"errored\"` and the error message is recorded in `mug logs`.\n\n```typescript\nworkflow(\"safe-notify\", async (ctx) => {\n const overdue = await ctx.query(\"quickbooks\", \"SELECT * FROM invoices WHERE status = 'overdue' AND _mug_deleted_at IS NULL\");\n\n for (const inv of overdue) {\n try {\n await ctx.notify.sms({\n to: inv.phone as string,\n message: `Invoice #${inv.number} is overdue.`,\n });\n } catch (e) {\n // Log failure but continue processing other invoices\n await ctx.exec(\"internal\", \"INSERT INTO notification_failures (invoice_id, error, ts) VALUES (?, ?, ?)\",\n [inv.id as string, (e as Error).message, new Date().toISOString()]);\n }\n }\n});\n```\n\n### Common errors\n\n| Error | Cause | Fix |\n|-------|-------|-----|\n| `no such table: <name>` | Table doesn't exist in the database | Run the source sync first, or create the table with `ctx.exec()` |\n| `AI not configured: missing ANTHROPIC_API_KEY` | No API key in dev | `mug secret set ANTHROPIC_API_KEY=sk-ant-...` |\n| `AI request failed (429)` | Rate limit hit | Reduce concurrency or add delays between AI calls |\n| `AI credit limit exceeded` | AI credits at 0 remaining | `mug buy-pack`, `mug workspace plan`, or switch to BYOK (`billing: \"ai.anthropic\"`) — see [billing.md](billing.md) |\n| `Usage limit exceeded for email` | Email send limit reached | `mug buy-pack` or `mug workspace plan` — catch in workflow to handle gracefully |\n| `Usage limit exceeded for sms` | SMS send limit reached | `mug buy-pack` or `mug workspace plan` |\n| `No credentials for \"<source>\"` | Credential not found | Check `mug secret set` and `mug.json` source config — see [ctx.credential](#ctxcredentialname) |\n| `Workflow \"<name>\" not found` | Workflow file not in `workflows/` directory | Ensure the file exists at `workflows/<name>.ts` and uses `workflow(\"<name>\", ...)` to register |\n\n### Production durability\n\nIn production, workflows run as Cloudflare Workflows with durable execution. Each `ctx.*` call becomes a durable step — if the Worker restarts mid-execution, the workflow resumes from the last completed step automatically. AI calls in production include automatic retry (2 retries, exponential backoff).\n\n## Webhook-Triggered Workflows\n\nWorkflows can be triggered by external webhooks. Configure in the workflow options:\n\n```typescript\n// No auth (public endpoint)\nworkflow(\"process-event\", async (ctx) => { ... }, {\n webhook: true,\n});\n\n// HMAC signature verification\nworkflow(\"stripe-handler\", async (ctx) => { ... }, {\n webhook: { auth: \"hmac\", secret: \"STRIPE_WEBHOOK_SECRET\" },\n});\n\n// Bearer token\nworkflow(\"partner-sync\", async (ctx) => { ... }, {\n webhook: { auth: \"bearer\", secret: \"PARTNER_API_TOKEN\" },\n});\n```\n\nThe `secret` value references a key in `.mug/secrets` (same as source credentials).\n\n### Webhook URL\n\nAfter `mug deploy`, the webhook URL is:\n```\nPOST https://api.mug.work/hook/<workspace>/<workflow-name>\n```\n\nDuring local dev with `mug dev --tunnel`:\n```\nPOST https://<tunnel-url>/hook/<workflow-name>\n```\n\n### Handling webhook payloads\n\nThe POST body arrives as `ctx.params`:\n\n```typescript\nworkflow(\"process-event\", async (ctx) => {\n const { event_type, data } = ctx.params;\n // Process the webhook payload\n});\n```\n\n### Controlling the webhook response\n\nBy default, webhooks return `{ ok: true }` immediately. Use `ctx.respond()` to send a custom response (e.g., for Slack URL verification):\n\n```typescript\nworkflow(\"slack-events\", async (ctx) => {\n if (ctx.params.type === \"url_verification\") {\n ctx.respond({ challenge: ctx.params.challenge });\n return;\n }\n // Process the event\n}, { webhook: true });\n```\n\n## Inbound Message Routing\n\nReceive inbound SMS replies, email replies, and Slack interactions as workflow triggers. Configure in the workflow options:\n\n```typescript\nworkflow(\"handle-sms-reply\", async (ctx) => { ... }, {\n inbound: \"sms\",\n});\n\nworkflow(\"handle-email-reply\", async (ctx) => { ... }, {\n inbound: \"email\",\n});\n\nworkflow(\"handle-slack-action\", async (ctx) => { ... }, {\n inbound: \"slack\",\n});\n```\n\nAfter `mug deploy`, webhook URLs are displayed:\n- SMS: `https://api.mug.work/inbound/sms/<workspace>` (set as Twilio webhook)\n- Email: `https://api.mug.work/inbound/email/<workspace>` (set as Resend inbound webhook)\n- Slack: `https://api.mug.work/inbound/slack/<workspace>` (set as Slack request URL)\n\n### Inbound SMS\n\nWorkflow receives Twilio webhook data as params:\n\n```typescript\nworkflow(\"handle-sms-reply\", async (ctx) => {\n const { from, body } = ctx.params as { from: string; body: string };\n const [contact] = await ctx.query(\"main\", \"SELECT * FROM contacts WHERE phone = ?\", [from]);\n if (!contact?.pending_action) return;\n\n if (contact.pending_action === \"expense-approval\" && body.trim().toUpperCase() === \"YES\") {\n const data = JSON.parse(contact.pending_data as string);\n await ctx.exec(\"main\", \"UPDATE expenses SET status = 'approved' WHERE id = ?\", [data.expenseId]);\n await ctx.notify.sms({ to: from, message: \"Approved. Thanks!\" });\n }\n await ctx.exec(\"main\", \"UPDATE contacts SET pending_action = NULL WHERE id = ?\", [contact.id]);\n});\n```\n\n### Inbound Email\n\n```typescript\nworkflow(\"handle-email-reply\", async (ctx) => {\n const { from, subject, body } = ctx.params as { from: string; subject: string; body: string };\n // Route based on subject line, sender lookup, or pending_action field\n});\n```\n\n### Inbound Slack\n\n```typescript\nworkflow(\"handle-slack-action\", async (ctx) => {\n const { userId, actionId, actionValue } = ctx.params as { userId: string; actionId: string; actionValue: string };\n // Handle button clicks, modal submissions, etc.\n});\n```\n\n### \"Send and wait for reply\" pattern\n\nUse two workflows — one sends + flags the contact, the other catches the reply + checks the flag:\n\n```typescript\n// Workflow 1: Send and flag\nworkflow(\"request-approval\", async (ctx) => {\n const { contactId, amount } = ctx.params;\n const [contact] = await ctx.query(\"main\", \"SELECT phone FROM contacts WHERE id = ?\", [contactId]);\n await ctx.notify.sms({ to: contact.phone, message: `Approve $${amount}? Reply YES or NO` });\n await ctx.exec(\"main\",\n \"UPDATE contacts SET pending_action = 'expense-approval', pending_data = ? WHERE id = ?\",\n [JSON.stringify({ amount, contactId }), contactId]);\n});\n\n// Workflow 2: Catch reply\nworkflow(\"handle-sms-reply\", async (ctx) => {\n const { from, body } = ctx.params as { from: string; body: string };\n const [contact] = await ctx.query(\"main\", \"SELECT * FROM contacts WHERE phone = ?\", [from]);\n if (!contact?.pending_action) return;\n // ... check pending_action, take action, clear flag\n});\n```\n\nThe database is the correlation store — inspectable, debuggable, and under your control.\n\n## Scheduling\n\n### Cron expressions\n\nAdd schedules to `mug.json`:\n\n```json\n{\n \"workflows\": {\n \"daily-report\": {\n \"schedule\": \"0 9 * * 1-5\",\n \"file\": \"workflows/daily-report.ts\"\n }\n },\n \"sources\": {\n \"hubspot\": {\n \"auth\": { \"type\": \"bearer\", \"value\": \"HUBSPOT_TOKEN\" },\n \"baseUrl\": \"https://api.hubapi.com\",\n \"syncs\": {\n \"hubspot\": { \"database\": \"hubspot\", \"schedule\": \"*/15 * * * *\" }\n }\n }\n }\n}\n```\n\nCron schedules run in **UTC**. Convert from your timezone when setting schedules.\n\n**Minimum interval per tier:** Schedules faster than the tier floor are silently clamped on deploy. Free = daily, Starter = 15min, Pro = 5min, Business = 1min. See [billing.md](billing.md) for details.\n\n| Expression | Meaning | Min tier |\n|-----------|---------|----------|\n| `*/5 * * * *` | Every 5 minutes | Pro |\n| `*/15 * * * *` | Every 15 minutes | Starter |\n| `0 * * * *` | Every hour | Free |\n| `0 9 * * 1-5` | Weekdays at 9am UTC | Free |\n| `0 0 * * *` | Daily at midnight UTC | Free |\n| `0 9 * * 1` | Mondays at 9am UTC | Free |\n| `0 */6 * * *` | Every 6 hours | Free |\n\n## mug.json Config Reference\n\n```json\n{\n \"name\": \"my-workspace\",\n \"id\": \"workspace-id-from-platform\",\n \"plan\": \"starter\",\n \"subdomain\": \"custom-subdomain\",\n \"settings\": {\n \"timezone\": \"America/Denver\"\n },\n \"sources\": {\n \"<name>\": {\n \"auth\": {\n \"type\": \"bearer | api-key | basic | oauth2\",\n \"value\": \"<credential-or-env-var-name>\",\n \"header\": \"<header-name>\"\n },\n \"baseUrl\": \"<API root URL>\",\n \"syncs\": {\n \"<sync-name>\": { \"database\": \"<db-name>\", \"schedule\": \"<cron expression>\" }\n }\n }\n },\n \"databases\": {\n \"<name>\": {\n \"tables\": {}\n }\n },\n \"workflows\": {\n \"<name>\": {\n \"schedule\": \"<cron expression>\",\n \"file\": \"workflows/<name>.ts\",\n \"webhook\": \"(deprecated — use workflow options instead)\",\n \"trigger\": \"{ type: 'slack_command' | 'slack_event', command?, event?, description? }\"\n }\n },\n \"inbound\": \"(deprecated — use workflow options instead)\",\n \"ai\": {\n \"routing\": {\n \"fast\": \"openai/gpt-5.4-nano\",\n \"balanced\": \"@cf/moonshotai/kimi-k2.6\",\n \"powerful\": \"anthropic/claude-sonnet-4-6\"\n },\n \"billing\": {\n \"default\": \"mug-metered\",\n \"fast\": \"mug-metered\",\n \"balanced\": \"mug-metered\",\n \"powerful\": \"ai.anthropic\"\n }\n },\n \"surfaces\": {},\n \"branding\": {\n \"logo\": \"assets/logo.png\",\n \"logoSquare\": \"assets/icon.png\",\n \"accentColor\": \"#1a5276\",\n \"ogImage\": \"assets/og-image.png\"\n },\n \"dev\": {\n \"email\": \"developer@example.com\"\n }\n}\n```\n\n### settings\n\n- `timezone` — IANA timezone (e.g., `\"America/Denver\"`). Auto-detected at `mug init`. Controls how `date` and `datetime` values display in surfaces. Can be overridden per-surface by adding `\"timezone\"` to the surface JSON.\n\n### sources\n\nExternal API connections and their sync config. **Credentials are not stored here** — `auth.value` references an env var name from `.mug/secrets` or a literal token. Source code lives in `connectors/<name>.ts`.\n\n- `auth.type: \"bearer\"` — sends `Authorization: Bearer <token>`\n- `auth.type: \"api-key\"` — sends credential in a custom header (specify `header` field)\n- `auth.type: \"basic\"` — sends `Authorization: Basic <base64>`\n- `auth.type: \"oauth2\"` — managed by `mug auth <provider>`, tokens refreshed automatically\n- `syncs` — maps sync names to `{ database, schedule }` pairs\n\n### databases\n\nExplicit database registration. Sources auto-register their databases here. Workflow-created databases don't need registration — they're created on first access.\n\n### workflows\n\n- `schedule` — cron expression (UTC) for automatic execution\n- `file` — path to the workflow TypeScript file\n- `webhook` — **deprecated** — move to workflow options: `workflow(\"name\", handler, { webhook: { auth: \"hmac\", secret: \"KEY\" } })`\n- `trigger` — Slack trigger config. `{ \"type\": \"slack_command\", \"command\": \"/dispatch\", \"description\": \"...\" }` for slash commands, `{ \"type\": \"slack_event\", \"event\": \"message\" }` for events.\n\n### inbound\n\n**Deprecated** — move to workflow options: `workflow(\"name\", handler, { inbound: \"sms\" })`\n\nMaps inbound message channels to handler workflows. After `mug deploy`, webhook URLs are displayed for each configured channel.\n\n### slack\n\nSlack app configuration. Managed by `/slack` skill and `mug deploy`. See the Slack skill for guided setup.\n\n- `enabled` — `true` to deploy a Slack app for this workspace\n- `name` — app display name in Slack\n- `description` — app description\n- `color` — hex brand color for the app\n- `scopes` — OAuth scopes (e.g., `[\"chat:write\", \"commands\"]`)\n- `eventSubscriptions` — events to listen for (e.g., `[\"message.app_mention\"]`)\n- `interactivityEnabled` — enable interactive components (buttons, modals)\n\n### ai\n\nAI model routing and billing configuration. See [ai.md](ai.md) for the full reference.\n\n- `routing` — maps tiers to provider/model. Keys: `fast`, `balanced`, `powerful`. Values: `\"provider/model\"` format (e.g., `\"openai/gpt-5.4-nano\"`, `\"@cf/moonshotai/kimi-k2.6\"`). Platform defaults used if not set.\n- `billing` — maps tiers to billing method. Keys: `default`, `fast`, `balanced`, `powerful`. Values: `\"mug-metered\"` (Mug credits) or a BYOK key name from `mug secret set` (e.g., `\"ai.anthropic\"`).\n\n### branding\n\n- `logo` — rectangle logo for headers (relative path or URL). Uploaded to R2 on deploy.\n- `logoSquare` — square logo variant for compact layouts. Falls back to `logo` if not set.\n- `accentColor` — hex color applied to buttons, links, focus rings, progress bars via CSS `--accent`. Also tints the browser favicon.\n- `ogImage` — custom 1200x630 PNG for link previews (Slack, iMessage, LinkedIn, Discord). Uploaded to R2 on deploy, served at `/_og-image.png`. Falls back to Mug default if not set.\n\nBranding is applied automatically to forms, portals, home screen, and emails. All surfaces emit Open Graph and Twitter Card meta tags (og:title, og:description, og:image, twitter:card) for link previews. In dev, changes to branding hot-reload.\n\n### plan\n\nWorkspace billing tier. Managed by `mug workspace plan` (interactive — opens Stripe Checkout for paid tiers). Values: `\"free\"`, `\"starter\"`, `\"pro\"`, `\"business\"`. Do not edit manually. See [billing.md](billing.md) for tier limits, overage packs, and schedule enforcement.\n\n### subdomain\n\nOptional custom subdomain for the workspace's production URL. Defaults to `name` if not set. The production URL becomes `https://<subdomain>.mug.work/`.\n\n### dev\n\nDevelopment overrides. Auto-managed — `mug init`, `mug create`, `mug sync`, and `mug dev` set `dev.email` to the logged-in user's email.\n\n- `email` — redirect all dev email notifications to this address instead of real recipients. Subject is prefixed with original recipient: `[DEV → manager@company.com] ...`\n\n## Home Screen (`surfaces/_home.json`)\n\nThe workspace root URL (`subdomain.mug.work/`) shows a branded auth screen, then a surface directory after login. Auth is configless — scans workspace owner/admin and all surface auth tables. No setup needed.\n\nTo customize the layout, create `surfaces/_home.json`:\n\n```json\n{\n \"title\": \"Narvick Construction\",\n \"description\": \"Employee portal for time-off requests, job dispatch, and more\",\n \"groups\": [\n {\n \"label\": \"HR\",\n \"description\": \"Time off, benefits, and employee info\",\n \"color\": \"#2563eb\",\n \"buttons\": [\n { \"surface\": \"time-off-request\", \"label\": \"Request Time Off\", \"color\": \"#16a34a\" }\n ],\n \"cards\": [\n { \"surface\": \"portal\", \"label\": \"Employee Dashboard\", \"description\": \"View your requests and approvals\", \"color\": \"#f59e0b\" }\n ]\n },\n {\n \"label\": \"Operations\",\n \"buttons\": [{ \"surface\": \"dispatch-form\" }],\n \"cards\": [{ \"surface\": \"dispatch-portal\", \"label\": \"Job Board\" }]\n }\n ]\n}\n```\n\n**Top-level fields:**\n- `title` — optional display name. Rendered as an `<h1>` heading in the header, used in `<title>` and OG meta. Falls back to title-cased workspace slug (e.g., `narvick-construction` → \"Narvick Construction\").\n- `description` — optional description. Rendered below the title in the header, and used in OG/Twitter meta tags for link previews.\n\n**Group fields:**\n- `label` — optional group heading. Omit for a section without a header.\n- `description` — optional subtitle below the group heading.\n- `color` — optional hex color. Tints the group container border and gives it a subtle background fill.\n- `buttons` — action surfaces (compact, horizontal row). Good for forms.\n- `cards` — destination surfaces (larger, stacked). Good for portals.\n\n**Surface item fields** (in both `buttons` and `cards`):\n- `surface` — **(required)** surface ID matching a file in `surfaces/`.\n- `label` — optional override. Falls back to the surface's `title` field.\n- `color` — optional hex color. Buttons get a colored background. Cards get a colored top border.\n- `description` — *(cards only)* optional context line below the title.\n\nBoth `buttons` and `cards` are optional per group. Surfaces not listed in any group appear at the bottom as default cards. No `_home.json` = all surfaces shown alphabetically as cards.\n\nDeployed to R2 on `mug deploy`. In dev, `localhost:8787/` reads from `surfaces/_home.json` — changes hot-reload like any other surface.\n\n## Workspace Files and Databases\n\nTwo top-level directories manage workspace data. Both have a `.remote` manifest that tracks what exists in production.\n\n### files/\n\nStatic files synced to R2 — assets, templates, CSVs, images. Drop a file here and run `mug sync` to upload it to production. Workflows access files at runtime via `ctx.file()` / `ctx.fileText()`.\n\nThe `.remote` manifest (`files/.remote`) tracks production state:\n\n```json\n{\n \"synced_at\": \"2026-05-14T10:30:00\",\n \"files\": {\n \"logo.png\": { \"size\": 24580, \"sha256\": \"a1b2c3...\", \"updated_at\": \"2026-05-13T08:00:00\" },\n \"templates/invoice.html\": { \"size\": 3200, \"sha256\": \"d4e5f6...\", \"updated_at\": \"2026-05-14T09:15:00\" }\n }\n}\n```\n\n### databases/\n\nLocal SQLite files synced to production Durable Objects. `.db` files are gitignored — the `.remote` manifest tracks what exists in production including full table schemas.\n\nThe `.remote` manifest (`databases/.remote`) tracks production state:\n\n```json\n{\n \"synced_at\": \"2026-05-14T10:30:00\",\n \"databases\": {\n \"crm\": {\n \"size_mb\": 12.4,\n \"tables\": {\n \"contacts\": {\n \"columns\": [\n { \"name\": \"id\", \"type\": \"INTEGER\" },\n { \"name\": \"email\", \"type\": \"TEXT\" },\n { \"name\": \"name\", \"type\": \"TEXT\" }\n ],\n \"row_count\": 3420\n }\n },\n \"updated_at\": \"2026-05-14T09:00:00\"\n }\n }\n}\n```\n\n### Sync behavior\n\n- `mug sync` pulls the latest remote manifests and pushes local-only files/databases to production\n- `mug dev` and `mug sync` repair missing directories and manifests automatically\n- Files not in `.remote` but present locally are uploaded on next `mug sync`\n- Files in `.remote` but not present locally are remote-only — accessible via `ctx.file()` at runtime\n- `.remote` is tracked in git so all collaborators see what exists in production\n\n## CLI Quick Reference\n\n### Workspace setup\n\n| Command | Description |\n|---------|-------------|\n| `mug init [name]` | Create a new workspace |\n| `mug sync` | Regenerate platform files (CLAUDE.md, skills, docs). Warns if CLI is outdated. Updates instruction files, skills, and docs only — your code in `connectors/`, `workflows/`, `agents/` is safe. Framework types come from the `@mugwork/mug` package. |\n| `mug login` | Authenticate via email verification (creates account on first use) |\n| `mug whoami` | Show account email, current workspace, and all workspace memberships |\n| `mug create workspace <name>` | Register workspace on the platform |\n\n### Development\n\n| Command | Description |\n|---------|-------------|\n| `mug dev` | Start local dev server (auto-detects ports from 8787, hot reload, WebSocket) |\n| `mug dev --port <port>` | Pin to a specific port |\n| `mug dev --tunnel` | Expose via Cloudflare Quick Tunnel (requires `cloudflared`) |\n| `mug shutdown` | Gracefully stop the running dev server (writes back databases) |\n| `mug run <workflow>` | Execute workflow locally |\n| `mug run <workflow> --production` | Execute in production (creates CF Workflow instance) |\n| `mug run <workflow> --port <n>` | Override dev server port |\n| `mug status <workflow> <instanceId>` | Check production workflow status |\n| `mug status <workflow> <instanceId> --json` | JSON output |\n| `mug logs [workflow]` | View execution history (tries dev, falls back to production) |\n| `mug logs [workflow] --production` | Force production logs |\n| `mug logs <workflow> --limit <n>` | Show more entries (default: 10) |\n| `mug logs <workflow> --json` | JSON output |\n| `mug sql <database> <sql>` | Run SQL against `databases/<database>.db` (no dev server needed) |\n| `mug sql <database> <sql> --json` | JSON output |\n| `mug sql <database> <sql> --production` | Run SQL against production database |\n| `mug sql <database> <sql> --dev` | Route through dev server instead of local file |\n| `mug push databases/<name>` | Upload local database to production |\n| `mug push files/<path>` | Upload a specific file to production |\n| `mug push --all` | Upload all local files and databases |\n| `mug push --all --force` | Push all without confirmation prompt |\n| `mug pull databases/<name>` | Download production database locally |\n| `mug pull files/<path>` | Download a specific file from production |\n| `mug pull --all` | Download all remote files and databases |\n| `mug usage` | Show usage across all 6 billing dimensions (--json, --period YYYY-MM) |\n| `mug usage --json` | JSON output |\n\n### Connectors\n\n| Command | Description |\n|---------|-------------|\n| `mug connector discover <product>` | Record API availability (tier, auth, docs URL, spec URL) |\n| `mug connector gather --slug <name>` | Produce OpenAPI spec (`--from-spec`, `--from-file`, `--from-har`) |\n| `mug connector verify --slug <name> --source <name>` | Run 7-probe live API verification |\n| `mug connector scaffold --slug <name>` | Generate TypeScript source from enriched spec |\n| `mug connector init <product>` | Full pipeline: discover → gather → verify → scaffold |\n| `mug connector search <query>` | Search community connector catalog |\n| `mug connector pull --slug <name>` | Download connector spec from catalog |\n\n### Forms\n\n| Command | Description |\n|---------|-------------|\n| `mug form init <name>` | Scaffold form + handler workflows |\n| `mug form validate [name]` | Validate form schemas |\n| `mug form list` | List forms and URLs |\n\n### Portals\n\n| Command | Description |\n|---------|-------------|\n| `mug portal init <name>` | Scaffold portal config |\n| `mug portal list` | List portals and URLs |\n\n### Secrets\n\n| Command | Description |\n|---------|-------------|\n| `mug secret set <KEY=VALUE>` | Store secret in `.mug/secrets` (gitignored) |\n| `mug secret set <KEY=VALUE> --production` | Sync secret to production |\n| `mug secret list` | Show stored secret keys (not values) |\n| `mug secret remove <KEY>` | Remove a secret from `.mug/secrets` |\n\n### Auth\n\n| Command | Description |\n|---------|-------------|\n| `mug auth <provider>` | Connect provider via OAuth (opens browser). Supported providers depend on platform configuration. |\n\n### Workspace management\n\n| Command | Description |\n|---------|-------------|\n| `mug workspace status` | Show workspace metadata, URL, plan tier, last deploy |\n| `mug workspace plan` | View or change plan tier (opens Stripe Checkout for paid tiers) |\n| `mug buy-pack` | Purchase 25% overage pack for current billing period |\n| `mug billing` | View/update billing settings (`--auto-packs`, `--max-packs`, `--email`) |\n| `mug workspace invite <email>` | Send admin invite to workspace |\n| `mug workspace transfer <email>` | Transfer ownership (sends invite, transfers when accepted) |\n| `mug workspace remove <email>` | Remove member |\n| `mug workspace members` | List members + pending invites |\n| `mug workspace cancel-invite <id>` | Cancel a pending invite you sent |\n| `mug workspace check-subdomain <slug>` | Check if a subdomain is available |\n| `mug workspace archive` | Archive workspace (365-day data retention) |\n| `mug workspace restore` | Restore an archived workspace (opens Stripe for paid tiers) |\n| `mug workspace delete` | Permanently delete an archived workspace |\n| `mug workspace export` | Export workspace data as `.tar` (`--categories <list>`, `--all`) |\n| `mug create workspace <name>` | Register workspace (`--subdomain <slug>`, `--tier free\\|starter\\|pro\\|business`) |\n| `mug account email <new-email>` | Change account email (verifies both old and new email) |\n| `mug account invites` | Show pending incoming and sent invites |\n| `mug account accept <id>` | Accept a workspace invite |\n| `mug account decline <id>` | Decline a workspace invite |\n| `mug whoami` | Show account, workspaces, and pending invites |\n\n### Webhooks & Issues\n\n| Command | Description |\n|---------|-------------|\n| `mug webhooks` | List webhook URLs, inbound channels, and event triggers |\n| `mug issue` | File a bug report or feature request on GitHub (`--dry-run` to preview) |\n\n### Deployment\n\n| Command | Description |\n|---------|-------------|\n| `mug deploy` | Bundle and deploy to Cloudflare. Requires `MUG_API_KEY` in `.mug/secrets`. |\n\n### Production workflow\n\n```bash\nmug secret set MUG_API_KEY=<your-key> # one-time setup\nmug dev # test locally\nmug deploy # deploy to production\nmug run <workflow> --production # trigger in production\nmug status <workflow> <instanceId> # check status\n```\n",
|
|
37
|
+
"sources.md": "# Sources — Full API Reference\n\nSources sync data from external APIs into workspace SQLite databases. Mug handles pagination, rate limiting, retries, and incremental sync. You write the source definition — Mug runs the infrastructure.\n\nFor a guided walkthrough, use the `/connect` skill. For `ctx.credential()` resolution chain and credential wiring, see [api.md — ctx.credential](api.md#ctxcredentialname).\n\n## SourceDef\n\n```typescript\nimport { source } from \"@mugwork/mug\";\n\nsource({\n name: string; // unique identifier, used in sync URLs and CLI\n database: string; // SQLite database name (one DB per source)\n tables: TableDef[]; // tables to sync\n baseUrl?: string; // API root URL, prepended to table endpoints\n rateLimits?: RateLimitConfig;\n errorRetry?: ErrorRetryConfig;\n});\n```\n\n## TableDef\n\nEach table maps to one API endpoint (or custom fetch function).\n\n```typescript\ninterface TableDef {\n name: string; // SQLite table name\n primaryKey: string; // column used for upsert (usually \"id\")\n endpoint?: string; // URL path appended to source's baseUrl\n fetch: (ctx: SourceContext) => Promise<Record<string, unknown>[]>;\n extractItems?: (body: unknown) => Record<string, unknown>[];\n pagination?: PaginationConfig;\n sync?: SyncConfig;\n}\n```\n\n### fetch function\n\nThe `fetch` function is the core of a table definition. It makes the API call and returns an array of records. Each record becomes a row in the SQLite table — columns are auto-detected from keys.\n\n```typescript\ntables: [{\n name: \"contacts\",\n primaryKey: \"id\",\n async fetch(ctx) {\n const token = await ctx.credential();\n const res = await fetch(\"https://api.example.com/contacts\", {\n headers: { Authorization: `Bearer ${token}` },\n });\n const data = await res.json();\n return data.contacts;\n },\n}]\n```\n\nWhen `endpoint` and `pagination` are set, the sync runtime handles fetching automatically — `fetch` is only called for custom/non-paginated endpoints.\n\n### extractItems\n\nWhen using automatic pagination (endpoint + pagination config), the sync runtime needs to extract the array of items from the API response body. By default, it checks these keys in order: `data`, `results`, `records`, `items`, `entries`. If the response is a top-level array, it uses that directly.\n\nOverride `extractItems` when the API wraps results differently:\n\n```typescript\nextractItems: (body) => (body as any).response.contacts,\n```\n\n## SourceContext\n\nPassed to `fetch` functions. Provides credentials and sync state.\n\n```typescript\ninterface SourceContext {\n credential: (name?: string) => Promise<string>;\n lastSync: string | null; // ISO 8601 timestamp of last successful sync\n}\n```\n\n### credential(name?)\n\nReturns a secret value from `.mug/secrets`. Without a name argument, returns the default credential for the source. With a name, returns a specific secret.\n\n```typescript\nconst token = await ctx.credential(); // default credential\nconst apiKey = await ctx.credential(\"api_key\"); // named secret\n```\n\n### Auth types in mug.json\n\nEach source entry in `mug.json` has an `auth` object that tells the runtime how to send credentials. The `auth.type` determines the HTTP auth mechanism:\n\n- `\"bearer\"` — sends `Authorization: Bearer <token>`\n- `\"basic\"` — sends `Authorization: Basic <base64>`\n- `\"oauth2\"` — managed by `mug auth <provider>`, tokens refreshed automatically\n- `\"api-key\"` — sends the credential in a custom HTTP header. **Requires `auth.header`** to specify which header name the API expects, since APIs use different header names (e.g., `X-API-Key`, `Api-Token`, `Authorization`).\n\nExample `api-key` config in `mug.json`:\n\n```json\n\"auth\": { \"type\": \"api-key\", \"value\": \"MY_API_KEY\", \"header\": \"X-API-Key\" }\n```\n\nThe `value` field references a secret name from `.mug/secrets` (set via `mug secret set`). The `header` field tells the runtime which HTTP header to send the credential in. This field is required for `api-key` type.\n\n### lastSync\n\nISO 8601 timestamp of the last successful sync for this table, or `null` on first sync. Use this for incremental sync — only fetch records updated after this timestamp.\n\n```typescript\nasync fetch(ctx) {\n let url = \"https://api.example.com/contacts\";\n if (ctx.lastSync) {\n url += `?updated_since=${ctx.lastSync}`;\n }\n // ...\n}\n```\n\n## PaginationConfig\n\nHandles automatic pagination across four styles. Set on a `TableDef` alongside `endpoint`.\n\n```typescript\ninterface PaginationConfig {\n style: \"cursor\" | \"offset\" | \"page\" | \"link-header\";\n\n // cursor style\n cursorParam?: string; // query param name (default: \"cursor\")\n cursorPath?: string; // JSON path to next cursor in response (default: \"next_cursor\")\n\n // offset style\n offsetParam?: string; // query param name (default: \"offset\")\n\n // page style\n pageParam?: string; // query param name (default: \"page\")\n\n // shared\n pageSizeParam?: string; // query param for page size\n defaultPageSize?: number; // items per page (default: 100)\n maxPageSize?: number; // maximum page size\n}\n```\n\n### Cursor pagination\n\nMost modern APIs (Airtable, Slack, HubSpot). The API returns a cursor token; send it back to get the next page.\n\n```typescript\npagination: {\n style: \"cursor\",\n cursorParam: \"cursor\", // ?cursor=<value>\n cursorPath: \"meta.next_cursor\", // where to find cursor in response JSON\n}\n```\n\nThe runtime stops when `cursorPath` resolves to a falsy value (null, undefined, empty string).\n\n### Offset pagination\n\nAPIs that use skip/offset (older REST APIs). The runtime increments the offset by `defaultPageSize` each page.\n\n```typescript\npagination: {\n style: \"offset\",\n offsetParam: \"offset\", // ?offset=200\n defaultPageSize: 100, // increment per page\n}\n```\n\nStops when `extractItems` returns fewer items than `defaultPageSize`.\n\n### Page pagination\n\nAPIs that use page numbers. The runtime increments the page number each request.\n\n```typescript\npagination: {\n style: \"page\",\n pageParam: \"page\", // ?page=3\n defaultPageSize: 50,\n}\n```\n\nStops when the response includes `total_pages`/`totalPages` and current page >= total, or when `extractItems` returns an empty array.\n\n### Link-header pagination\n\nReserved for APIs that use `Link` HTTP headers (GitHub API style). Currently parses but does not follow — use cursor pagination for these APIs.\n\n## RateLimitConfig\n\nControls request pacing to avoid hitting API rate limits.\n\n```typescript\ninterface RateLimitConfig {\n requestsPerSecond?: number;\n requestsPerMinute?: number;\n}\n```\n\nThe runtime inserts a delay between requests. `requestsPerSecond` takes precedence over `requestsPerMinute` if both are set.\n\n```typescript\nrateLimits: {\n requestsPerSecond: 5, // max 5 requests/sec = 200ms between requests\n}\n```\n\nThe runtime also respects `Retry-After` headers from 429 responses automatically.\n\n## SyncConfig\n\nControls incremental sync behavior — how the runtime filters for new/updated records.\n\n```typescript\ninterface SyncConfig {\n filterParam?: string; // query param for \"updated since\" filter\n filterFormat?: \"iso8601\" | \"unix\" | \"epoch_ms\"; // timestamp format\n updatedAtField?: string; // field name for last-updated timestamp\n deletedAtField?: string; // field name for soft-delete timestamp\n isDeletedField?: string; // boolean field indicating deletion\n deletionStrategy?: \"soft-delete-field\" | \"tombstone-endpoint\" | \"full-sync-only\";\n}\n```\n\n### Incremental sync\n\nWhen `filterParam` is set and `lastSync` is available, the runtime appends a filter to the request URL:\n\n```typescript\nsync: {\n filterParam: \"updated_since\",\n filterFormat: \"iso8601\", // sends ISO 8601 string\n}\n// Result: ?updated_since=2024-01-15T10:30:00.000Z\n```\n\n### Deletion strategies\n\n- **`soft-delete-field`** — records have a `deletedAtField` or `isDeletedField`. Mug sets `_mug_deleted_at` on matching rows.\n- **`tombstone-endpoint`** — API has a separate endpoint for deleted records.\n- **`full-sync-only`** — no incremental support. Every sync fetches all records.\n\n## ErrorRetryConfig\n\nControls automatic retry behavior for failed requests.\n\n```typescript\ninterface ErrorRetryConfig {\n maxRetries?: number; // default: 3\n retryOn5xx?: boolean; // retry server errors (default: true)\n retryOn429?: boolean; // retry rate limit errors (default: true)\n backoffMs?: number; // initial backoff delay in ms (default: 1000)\n}\n```\n\nUses exponential backoff: `backoffMs * 2^(attempt-1)`. For 429 responses, the runtime uses the `Retry-After` header if present.\n\n```typescript\nerrorRetry: {\n maxRetries: 5,\n retryOn5xx: true,\n retryOn429: true,\n backoffMs: 2000, // 2s, 4s, 8s, 16s, 32s\n}\n```\n\n## System columns\n\nSynced tables automatically get these columns:\n\n- `_mug_synced_at` — ISO 8601 timestamp of when the row was last synced\n- `_mug_deleted_at` — ISO 8601 timestamp if the row was soft-deleted (null if active)\n\nAlways filter for active records:\n```sql\nSELECT * FROM contacts WHERE _mug_deleted_at IS NULL\n```\n\n## CLI commands\n\n```bash\nmug connector discover \"<product>\" # record API availability and research\nmug connector gather --slug <name> # produce OpenAPI spec (from URL, file, or HAR)\nmug connector verify --slug <name> # run 7-probe verification against live API\nmug connector scaffold --slug <name> # generate TypeScript source from enriched spec\nmug connector init <product> # full pipeline: discover -> gather -> verify -> scaffold\n```\n\n### Trigger sync locally\n\n```bash\nmug dev # start dev server\ncurl -s -X POST http://localhost:8787/sync/<name> # trigger sync\nmug sql <database> \"SELECT count(*) FROM <table>\" # verify\n```\n\n## Complete example\n\n```typescript\nimport { source } from \"@mugwork/mug\";\n\nsource({\n name: \"hubspot\",\n database: \"hubspot\",\n baseUrl: \"https://api.hubapi.com\",\n rateLimits: { requestsPerSecond: 10 },\n errorRetry: { maxRetries: 3, retryOn429: true, backoffMs: 1000 },\n tables: [\n {\n name: \"contacts\",\n primaryKey: \"id\",\n endpoint: \"/crm/v3/objects/contacts\",\n extractItems: (body) => (body as any).results,\n pagination: {\n style: \"cursor\",\n cursorParam: \"after\",\n cursorPath: \"paging.next.after\",\n defaultPageSize: 100,\n },\n sync: {\n filterParam: \"filterGroups\",\n filterFormat: \"iso8601\",\n updatedAtField: \"updatedAt\",\n deletionStrategy: \"soft-delete-field\",\n isDeletedField: \"archived\",\n },\n async fetch(ctx) {\n const token = await ctx.credential();\n const res = await fetch(\"https://api.hubapi.com/crm/v3/objects/contacts\", {\n headers: { Authorization: `Bearer ${token}` },\n });\n return ((await res.json()) as any).results;\n },\n },\n {\n name: \"deals\",\n primaryKey: \"id\",\n endpoint: \"/crm/v3/objects/deals\",\n extractItems: (body) => (body as any).results,\n pagination: {\n style: \"cursor\",\n cursorParam: \"after\",\n cursorPath: \"paging.next.after\",\n },\n async fetch(ctx) {\n const token = await ctx.credential();\n const res = await fetch(\"https://api.hubapi.com/crm/v3/objects/deals\", {\n headers: { Authorization: `Bearer ${token}` },\n });\n return ((await res.json()) as any).results;\n },\n },\n ],\n});\n```\n",
|
|
38
|
+
"workflows.md": "# Workflows — Full API Reference\n\nWorkflows are multi-step automations that query data, apply AI logic, and take action. Register a workflow with `workflow()`, then run it with `mug run` or on a schedule.\n\nFor a guided walkthrough, use the `/workflow` skill. For the full WorkspaceContext API (all `ctx.*` methods, error handling, data patterns), see [api.md](api.md).\n\n## Registering a workflow\n\n```typescript\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"invoice-followup\", async (ctx) => {\n // Find all overdue unpaid invoices\n const overdue = await ctx.query(\"quickbooks\", \"SELECT * FROM invoices WHERE due_date < date('now') AND status = 'open'\");\n\n for (const inv of overdue) {\n // Send a payment reminder SMS to each customer\n await ctx.notify.sms({\n to: inv.customer_phone as string,\n message: `Invoice #${inv.number} for $${inv.amount} is overdue. Please pay at your earliest convenience.`,\n });\n }\n\n // Return how many reminders were sent\n return { notified: overdue.length };\n}, { description: \"Sends SMS reminders to customers with overdue invoices\" });\n```\n\nWorkflows in `workflows/` are auto-discovered by `mug deploy` — no import needed.\n\n### Workflow options\n\nThe third argument to `workflow()` is an optional options object:\n\n```typescript\nworkflow(\"expensive-analysis\", handler, {\n description: \"Runs deep analysis on quarterly data and emails a summary to the CFO\",\n billing: \"ai.anthropic\",\n});\n```\n\n| Option | Type | Description |\n|--------|------|-------------|\n| `description` | `string` | Plain English description of what the workflow does. Displayed in the workspace explorer. Required for all workflows. |\n| `billing` | `string` | Billing method for all `ctx.ai()` calls in this workflow. `\"mug-metered\"` (default) or a BYOK key name (e.g., `\"ai.anthropic\"`). See [ai.md](ai.md) for full billing precedence. |\n\n## Step descriptions\n\nAdd a `//` comment on the line immediately above each `ctx.*` call and `return` statement. The workspace explorer parses these into human-readable step descriptions.\n\n```typescript\n// Check for overdue invoices in QuickBooks\nconst overdue = await ctx.query(\"quickbooks\",\n \"SELECT * FROM invoices WHERE due_date < date('now') AND status = 'open'\");\n\n// No overdue invoices, nothing to do\nif (overdue.length === 0) return { skipped: true };\n\n// Send a reminder SMS to each customer\nfor (const inv of overdue) {\n await ctx.notify.sms({ to: inv.phone as string, message: `Invoice #${inv.number} is overdue.` });\n}\n\n// Return a summary of how many reminders were sent\nreturn { notified: overdue.length };\n```\n\nEvery `ctx.query`, `ctx.exec`, `ctx.ai`, `ctx.notify.*`, `ctx.collect`, and `return` statement should have a description comment. Multi-line `//` comments above the same call are joined into a single description.\n\n## WorkflowContext — ctx API\n\nEvery `ctx` method is automatically logged with timing, input/output summary, and token usage. Steps appear in `mug logs`.\n\nFor full method signatures, return types, error behavior, and data patterns, see [api.md](api.md). Quick reference of available methods:\n\n| Method | Purpose |\n|--------|---------|\n| `ctx.query(db, sql, params?)` | Read rows from any workspace database |\n| `ctx.exec(db, sql, params?)` | Write to any workspace database, returns change count |\n| `ctx.ai(model, options)` | Call an AI model — use `\"auto\"` for smart routing. See [ai.md](ai.md) |\n| `ctx.notify.email(options)` | Send styled HTML email with optional CTA. See [notifications.md](notifications.md) |\n| `ctx.notify.sms(options)` | Send SMS via Twilio (E.164 format) |\n| `ctx.notify.slack(options)` | Send Slack message |\n| `ctx.surfaceUrl(id, path?)` | Generate dev/prod-aware surface URL |\n| `ctx.file(path)` | Read file from `files/` as ArrayBuffer |\n| `ctx.fileText(path)` | Read file from `files/` as UTF-8 string |\n| `ctx.collect(options)` | Create a form dynamically, returns URL. See [forms.md](forms.md) |\n| `ctx.secret(name)` | Read a workspace secret by name (from `.mug/secrets`). Throws if not found |\n| `ctx.http(url, options?)` | Outbound HTTP. Returns `{ status, headers, body, json, ok }`. Throws on non-2xx. Auto-retries 429/connection errors |\n| `ctx.respond(body, status?)` | Set custom webhook response. First call wins. For Slack challenge, Twilio TwiML |\n| `ctx.params` | Input parameters (form fields, portal action data, webhook payload, trigger data) |\n| `ctx.isDemo` | `true` when triggered from a demo mode surface |\n| `ctx.steps` | Step records for the current run (used internally for logging) |\n\n## WorkflowResult\n\nReturned by `runWorkflow()` (internal). Visible in `mug logs`.\n\n```typescript\ninterface WorkflowResult {\n workflow: string;\n runId: string; // unique: <name>-<timestamp>-<random>\n status: \"complete\" | \"errored\";\n startedAt: string; // ISO 8601\n completedAt: string;\n durationMs: number;\n stepCount: number;\n steps: StepRecord[];\n result?: unknown; // return value from handler\n error?: string; // error message if errored\n}\n```\n\n## StepRecord\n\nEach `ctx.*` call produces a step record.\n\n```typescript\ninterface StepRecord {\n name: string; // auto-generated: <type>-<target>-<counter>\n type: string; // \"query\", \"exec\", \"ai\", \"notify\", \"collect\"\n startedAt: number; // epoch ms\n completedAt?: number;\n durationMs?: number;\n input?: string; // JSON, truncated to 4096 chars\n output?: string; // JSON, truncated to 4096 chars\n error?: string;\n tokensUsed?: number; // AI steps only\n}\n```\n\n## Ops database\n\nWorkflow runs are automatically persisted to the `_mug_ops` database with `workflow_runs` and `workflow_steps` tables. See [api.md — Ops database](api.md#ops-database) for the full schema.\n\n## Scheduling\n\nAdd a schedule to `mug.json` to run workflows automatically:\n\n```json\n{\n \"workflows\": {\n \"invoice-followup\": {\n \"schedule\": \"0 9 * * 1-5\",\n \"file\": \"workflows/invoice-followup.ts\"\n }\n }\n}\n```\n\n### Common cron expressions\n\n| Expression | Meaning |\n|-----------|---------|\n| `*/15 * * * *` | Every 15 minutes |\n| `0 * * * *` | Every hour |\n| `0 9 * * 1-5` | Weekdays at 9am |\n| `0 0 * * *` | Daily at midnight |\n| `0 9 * * 1` | Mondays at 9am |\n| `0 */6 * * *` | Every 6 hours |\n\n## Production execution\n\nIn production, workflows run as Cloudflare Workflows (durable execution). Each `ctx.*` call becomes a durable step — if the Worker restarts mid-execution, it resumes from the last completed step.\n\n```bash\nmug run <workflow> # run locally\nmug run <workflow> --production # run in production (creates CF Workflow instance)\nmug status <workflow> <instanceId> # check production instance status\nmug logs <workflow> # view execution history (dev or production)\n```\n\n## CLI commands\n\n```bash\nmug run <workflow> # execute workflow locally\nmug run <workflow> --production # execute in production\nmug status <workflow> <instanceId> # check production workflow status\nmug logs [workflow] # view execution logs (tries dev, falls back to production)\nmug logs <workflow> --production # view production logs explicitly\nmug logs <workflow> --limit 20 # view more log entries\nmug logs --json # JSON output for scripting\n```\n\n## Two-workflow pattern\n\nFor form-based workflows, use two workflows: one creates the form, another handles submissions.\n\n```typescript\n// workflows/create-intake-form.ts\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"create-intake-form\", async (ctx) => {\n const url = await ctx.collect({\n title: \"Client Intake\",\n fields: [\n { name: \"company\", label: \"Company Name\", type: \"text\", required: true },\n { name: \"revenue\", label: \"Annual Revenue\", type: \"number\" },\n ],\n workflow: \"handle-intake\",\n });\n await ctx.notify.email({\n to: \"client@example.com\",\n message: `Please fill out the intake form: ${url}`,\n subject: \"Client Intake Form\",\n });\n return { formUrl: url };\n});\n\n// workflows/handle-intake.ts\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"handle-intake\", async (ctx) => {\n const { company, revenue } = ctx.params;\n await ctx.exec(\"internal\", \"INSERT INTO clients (company, revenue) VALUES (?, ?)\", [company, revenue]);\n await ctx.notify.slack({\n to: \"#new-clients\",\n message: `New intake: ${company} ($${revenue} revenue)`,\n });\n});\n```\n\n## Trigger types\n\nWorkflows can be triggered by cron schedule, webhook, inbound message, or data change event. Configure in workflow options (third argument to `workflow()`).\n\n### Webhook triggers\n\n```typescript\nworkflow(\"process-stripe-event\", async (ctx) => {\n if (ctx.params.type === \"url_verification\") {\n ctx.respond({ challenge: ctx.params.challenge });\n return;\n }\n const { type, data } = ctx.params;\n if (type === \"invoice.paid\") {\n await ctx.exec(\"internal\", \"UPDATE invoices SET status = 'paid' WHERE stripe_id = ?\",\n [data.object.id as string]);\n }\n}, { webhook: { auth: \"hmac\", secret: \"STRIPE_WEBHOOK_SECRET\" } });\n```\n\nURL: `POST https://api.mug.work/hook/<workspace>/<workflow>`. See [api.md — Webhook-Triggered Workflows](api.md#webhook-triggered-workflows).\n\n### Event triggers\n\nFire a workflow when synced data changes:\n\n```typescript\nworkflow(\"new-invoice-alert\", async (ctx) => {\n const { source, table, event, count } = ctx.params._trigger;\n const rows = ctx.params.rows;\n for (const inv of rows) {\n await ctx.notify.email({\n to: \"owner@company.com\",\n subject: `New invoice: ${inv.number}`,\n message: `Amount: $${inv.amount}`,\n });\n }\n}, { trigger: { source: \"quickbooks\", table: \"invoices\", on: \"insert\" } });\n```\n\nTrigger options: `source` (required), `table` (optional, defaults to all), `on` (\"insert\" | \"update\" | \"delete\" | \"change\"), `includeInitialSync` (default false — first full sync doesn't fire triggers).\n\n### Outbound HTTP\n\nCall external APIs or send webhooks from workflows:\n\n```typescript\nconst result = await ctx.http(\"https://api.example.com/orders\", {\n headers: { Authorization: `Bearer ${ctx.secret(\"API_KEY\")}` },\n});\nconst orders = result.json;\n\n// Fire-and-forget webhook with HMAC signing\nawait ctx.http(\"https://partner.com/webhook\", {\n method: \"POST\",\n body: { event: \"order.shipped\", orderId: \"123\" },\n sign: { secret: \"PARTNER_HMAC_KEY\" },\n});\n```\n\n## Complete example\n\n```typescript\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"daily-report\", async (ctx) => {\n // 1. Query data from multiple sources\n const openTickets = await ctx.query(\"zendesk\",\n \"SELECT * FROM tickets WHERE status = 'open' AND _mug_deleted_at IS NULL\");\n const overdueInvoices = await ctx.query(\"quickbooks\",\n \"SELECT * FROM invoices WHERE due_date < date('now') AND status = 'unpaid'\");\n\n // 2. AI summary\n const summary = await ctx.ai(\"balanced\", {\n system: \"Summarize operational status in 3-4 bullet points for the business owner.\",\n prompt: `Open support tickets: ${openTickets.length}\\n${openTickets.map(t => `- ${t.subject}`).join(\"\\n\")}\\n\\nOverdue invoices: ${overdueInvoices.length}\\n${overdueInvoices.map(i => `- #${i.number}: $${i.amount}`).join(\"\\n\")}`,\n });\n\n // 3. Send report\n await ctx.notify.email({\n to: \"owner@company.com\",\n subject: `Daily Report — ${new Date().toLocaleDateString()}`,\n message: summary.text,\n });\n\n // 4. Log the report\n await ctx.exec(\"internal\",\n \"INSERT INTO reports (id, type, content, created_at) VALUES (?, ?, ?, ?)\",\n [crypto.randomUUID(), \"daily\", summary.text, new Date().toISOString()]);\n\n return { tickets: openTickets.length, invoices: overdueInvoices.length };\n});\n```\n",
|
|
39
|
+
"forms.md": "# Forms — Full API Reference\n\nForms collect data from end users. Create a JSON config file in `surfaces/<name>.json` — the form is live immediately in `mug dev` and deploys automatically with `mug deploy`. For forms that need to be generated dynamically at runtime, use `ctx.collect()` (see [Dynamic Forms](#dynamic-forms-ctxcollect) below).\n\nFor a guided walkthrough, use the `/form` skill. For the full `ctx.params` shape (including special fields like `_verified_email` and file upload URLs), see [api.md — ctx.params](api.md#ctxparams).\n\n## Static form config (recommended)\n\nCreate `surfaces/<name>.json`:\n\n```json\n{\n \"type\": \"form\",\n \"title\": \"Contact Us\",\n \"description\": \"We'll get back to you within 24 hours.\",\n \"submitText\": \"Send Message\",\n \"workflow\": \"handle-contact\",\n \"access\": { \"mode\": \"public\" },\n \"fields\": [\n { \"name\": \"name\", \"label\": \"Your Name\", \"type\": \"text\", \"required\": true },\n { \"name\": \"email\", \"label\": \"Email\", \"type\": \"email\", \"required\": true },\n { \"name\": \"message\", \"label\": \"Message\", \"type\": \"textarea\", \"rows\": 5 }\n ]\n}\n```\n\n### Config fields\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `type` | `\"form\"` | yes | Surface type |\n| `title` | string | yes | Form heading |\n| `description` | string | no | Subtitle below heading |\n| `submitText` | string | no | Submit button label (default: \"Submit\") |\n| `workflow` | string | yes | Handler workflow name |\n| `access` | FormAccess | no | Access control (default: public) |\n| `fields` | FormField[] | * | Single-page form fields |\n| `pages` | FormPage[] | * | Multi-page form (overrides `fields`) |\n| `editMode` | EditMode | no | Pre-fill from existing records |\n\n\\* One of `fields` or `pages` is required.\n\n### Multi-page form\n\n```json\n{\n \"type\": \"form\",\n \"title\": \"Client Onboarding\",\n \"workflow\": \"handle-onboarding\",\n \"pages\": [\n {\n \"id\": \"company\",\n \"title\": \"Company Info\",\n \"fields\": [\n { \"name\": \"company\", \"label\": \"Company Name\", \"type\": \"text\", \"required\": true },\n { \"name\": \"industry\", \"label\": \"Industry\", \"type\": \"select\", \"options\": [\n { \"label\": \"Construction\", \"value\": \"construction\" },\n { \"label\": \"HVAC\", \"value\": \"hvac\" },\n { \"label\": \"Property Management\", \"value\": \"property\" }\n ]}\n ]\n },\n {\n \"id\": \"contact\",\n \"title\": \"Primary Contact\",\n \"fields\": [\n { \"name\": \"contact_name\", \"label\": \"Name\", \"type\": \"text\", \"required\": true },\n { \"name\": \"contact_email\", \"label\": \"Email\", \"type\": \"email\", \"required\": true },\n { \"name\": \"contact_phone\", \"label\": \"Phone\", \"type\": \"phone\" }\n ]\n }\n ]\n}\n```\n\n## Field types\n\nAll fields share a base interface:\n\n```typescript\ninterface BaseField {\n name: string; // field identifier (used in submission params)\n label: string; // display label\n required?: boolean; // default: false\n placeholder?: string; // placeholder text\n showWhen?: Condition[]; // conditional visibility\n default?: string | number | boolean; // static default value\n prefill?: FieldPrefill; // auto-fill from auth row, URL param, or database\n locked?: boolean; // read-only in UI + server-enforced\n helpText?: string; // hint text displayed below the label\n validate?: ValidationRule[]; // custom validation rules with error messages\n}\n\ninterface ValidationRule {\n rule: \"min\" | \"max\" | \"minLength\" | \"maxLength\" | \"pattern\";\n value: number | string; // the constraint value\n message: string; // user-facing error message shown on failure\n}\n```\n\n**Field names must be unique across the entire form** — including across pages and conditional (`showWhen`) fields. Duplicate names cause silent data loss: the form submits the wrong field's value. `mug form validate` and `mug dev` catch duplicates at config load time.\n\nBoth `helpText` and `validate` support `{{column}}` template syntax — values are resolved from the authenticated user's database row at render time. This lets you pipe dynamic limits and context into forms:\n\n```typescript\n{\n name: \"hours\", type: \"number\", label: \"Hours Requested\", required: true,\n helpText: \"You have {{available_pto_hours}} hours available\",\n validate: [\n { rule: \"min\", value: 0.5, message: \"Must request at least 30 minutes\" },\n { rule: \"max\", value: \"{{available_pto_hours}}\", message: \"Cannot exceed your {{available_pto_hours}} available hours\" }\n ]\n}\n```\n\nTemplate values that resolve to numbers (like `{{available_pto_hours}}` → `16`) are automatically coerced for numeric comparisons in `min`/`max` rules. Unresolved templates (column not found in auth row) are left as-is.\n\n### text\n\nSingle-line text input. Also used for email and phone with built-in validation.\n\n```typescript\ninterface TextField extends BaseField {\n type: \"text\" | \"email\" | \"phone\";\n pattern?: string; // regex pattern for validation\n}\n```\n\n```typescript\n{ name: \"name\", label: \"Full Name\", type: \"text\", required: true }\n{ name: \"email\", label: \"Email Address\", type: \"email\", required: true }\n{ name: \"phone\", label: \"Phone Number\", type: \"phone\", placeholder: \"+1 (555) 000-0000\" }\n{ name: \"zip\", label: \"ZIP Code\", type: \"text\", pattern: \"^\\\\d{5}(-\\\\d{4})?$\" }\n```\n\n### number\n\nNumeric input with optional range constraints.\n\n```typescript\ninterface NumberField extends BaseField {\n type: \"number\";\n min?: number;\n max?: number;\n step?: number; // increment (e.g., 0.01 for currency)\n}\n```\n\n```typescript\n{ name: \"quantity\", label: \"Quantity\", type: \"number\", min: 1, max: 100 }\n{ name: \"price\", label: \"Price\", type: \"number\", min: 0, step: 0.01 }\n```\n\n### select / multiselect\n\nDropdown or multi-choice selection.\n\n```typescript\ninterface SelectField extends BaseField {\n type: \"select\" | \"multiselect\";\n options: { label: string; value: string }[];\n}\n```\n\n```typescript\n{ name: \"priority\", label: \"Priority\", type: \"select\", options: [\n { label: \"Low\", value: \"low\" },\n { label: \"Medium\", value: \"medium\" },\n { label: \"High\", value: \"high\" },\n]}\n\n{ name: \"services\", label: \"Services Needed\", type: \"multiselect\", options: [\n { label: \"Plumbing\", value: \"plumbing\" },\n { label: \"Electrical\", value: \"electrical\" },\n { label: \"HVAC\", value: \"hvac\" },\n]}\n```\n\n### date\n\nDate picker with optional min/max constraints.\n\n```typescript\ninterface DateField extends BaseField {\n type: \"date\";\n min?: string; // ISO date string (e.g., \"2024-01-01\")\n max?: string;\n}\n```\n\n```typescript\n{ name: \"preferred_date\", label: \"Preferred Date\", type: \"date\", min: \"2024-01-01\" }\n```\n\n### textarea\n\nMulti-line text input.\n\n```typescript\ninterface TextareaField extends BaseField {\n type: \"textarea\";\n rows?: number; // visible rows (default: 3)\n maxLength?: number; // character limit\n}\n```\n\n```typescript\n{ name: \"notes\", label: \"Additional Notes\", type: \"textarea\", rows: 5, maxLength: 2000 }\n```\n\n### file\n\nFile upload field.\n\n```typescript\ninterface FileField extends BaseField {\n type: \"file\";\n accept?: string; // MIME types (e.g., \"image/*,.pdf\")\n maxSizeMb?: number; // max file size in MB\n}\n```\n\n```typescript\n{ name: \"photo\", label: \"Upload Photo\", type: \"file\", accept: \"image/*\", maxSizeMb: 10 }\n{ name: \"document\", label: \"Supporting Document\", type: \"file\", accept: \".pdf,.doc,.docx\", maxSizeMb: 25 }\n```\n\n**In the handler workflow**, file fields arrive as R2 URL strings in `ctx.params`:\n```typescript\nworkflow(\"handle-request\", async (ctx) => {\n const photoUrl = ctx.params.photo as string; // \"https://r2.mug.work/workspace/uploads/abc123.jpg\"\n await ctx.exec(\"internal\", \"INSERT INTO requests (photo_url) VALUES (?)\", [photoUrl]);\n});\n```\n\n### calculated\n\nDisplay-only field that computes a value from other fields. Not submitted.\n\n```typescript\ninterface CalculatedField {\n name: string;\n type: \"calculated\";\n label: string;\n expression: string; // JavaScript expression referencing other field names\n format?: \"number\" | \"currency\" | \"percent\";\n showWhen?: Condition[];\n}\n```\n\n```typescript\n{ name: \"total\", type: \"calculated\", label: \"Total\", expression: \"quantity * price\", format: \"currency\" }\n{ name: \"tax\", type: \"calculated\", label: \"Tax (8%)\", expression: \"quantity * price * 0.08\", format: \"currency\" }\n{ name: \"grand_total\", type: \"calculated\", label: \"Grand Total\", expression: \"quantity * price * 1.08\", format: \"currency\" }\n```\n\n### hidden\n\nInvisible field — never rendered, always included in submission. Use for form IDs, tracking params, or auth-prefilled values the user shouldn't see.\n\n```typescript\ninterface HiddenField {\n name: string;\n type: \"hidden\";\n default?: string | number | boolean;\n prefill?: FieldPrefill;\n locked?: boolean;\n}\n```\n\n```typescript\n{ name: \"form_version\", type: \"hidden\", default: \"v2\" }\n{ name: \"employee_id\", type: \"hidden\", prefill: { source: \"auth\", column: \"id\" } }\n{ name: \"department\", type: \"hidden\", prefill: { source: \"url\", param: \"dept\" } }\n```\n\n## Default values\n\nAny field can have a `default` value — a static value pre-populated when the form loads. Defaults are the lowest priority in the fill chain: prefill > editRecord > URL param > default.\n\n```typescript\n{ name: \"status\", type: \"hidden\", default: \"pending\" }\n{ name: \"priority\", type: \"select\", label: \"Priority\", default: \"medium\", options: [...] }\n{ name: \"country\", type: \"text\", label: \"Country\", default: \"US\" }\n```\n\nFor select fields, the matching option is pre-selected. For multiselect, pass comma-separated values: `default: \"plumbing,hvac\"`.\n\n## Prefill\n\nAuto-fill field values from three sources: the authenticated user's database row, URL parameters, or a related database record.\n\n```typescript\ntype FieldPrefill =\n | { source: \"auth\"; column: string }\n | { source: \"url\"; param: string }\n | { source: \"db\"; table: string; column: string; match: { column: string; field?: string; param?: string } };\n```\n\n### Prefill from auth row\n\nWhen a form uses `auth` access mode, the runtime fetches the full row from the auth table (not just checking existence). Any field can reference a column from that row:\n\n```typescript\n{ name: \"employee_name\", type: \"text\", label: \"Your Name\",\n prefill: { source: \"auth\", column: \"name\" }, locked: true }\n\n{ name: \"department\", type: \"text\", label: \"Department\",\n prefill: { source: \"auth\", column: \"department\" }, locked: true }\n\n{ name: \"employee_id\", type: \"hidden\",\n prefill: { source: \"auth\", column: \"id\" } }\n```\n\nAuth prefill is resolved server-side at render time — the value is already in the HTML when the page loads. Combined with `locked: true`, the user sees their name but can't change it, and the server enforces the value on submission.\n\n### Prefill from URL parameters\n\nReference a URL query parameter:\n\n```typescript\n{ name: \"department\", type: \"text\", label: \"Department\",\n prefill: { source: \"url\", param: \"dept\" } }\n// URL: https://workspace.mug.work/form-id?dept=Engineering\n```\n\nURL prefill is resolved client-side.\n\n### Prefill from database\n\nFetch a value from a related table at render time:\n\n```typescript\n{ name: \"manager_name\", type: \"text\", label: \"Manager\",\n prefill: { source: \"db\", table: \"departments\", column: \"manager_name\",\n match: { column: \"id\", param: \"dept_id\" } }, locked: true }\n// URL: https://workspace.mug.work/form-id?dept_id=3\n// Fetches: SELECT manager_name FROM departments WHERE id = 3\n```\n\nThe `match` object specifies how to find the row:\n- `match.param` — match against a URL query parameter\n- `match.field` — match against another field's prefill value (for chaining)\n\n### Priority chain\n\nWhen multiple sources provide a value for the same field, higher priority wins:\n\n1. **locked + prefill source** (highest — server enforces this)\n2. **prefill** (auth, URL, or DB)\n3. **editRecord** (edit mode)\n4. **URL parameter** (informal — any URL param matching a field name)\n5. **default** (lowest)\n\n## Locked fields\n\n`locked: true` makes a field read-only in the UI and server-enforced on submission. The submitted value is ignored — the server uses the known value from the prefill source or default.\n\n```typescript\n{ name: \"employee_name\", type: \"text\", label: \"Your Name\",\n prefill: { source: \"auth\", column: \"name\" }, locked: true }\n```\n\n- Input fields render with `readonly` attribute\n- Select/multiselect fields render as `disabled` (with hidden input for submission)\n- Visual styling: grey background, muted text, not-allowed cursor\n- **Server-side enforcement**: on submission, the handler overwrites the field value with the known source value, preventing HTML tampering\n\nLocked fields work with any value source:\n```typescript\n// Locked with auth prefill — user's name from the employees table\n{ name: \"name\", type: \"text\", label: \"Your Name\",\n prefill: { source: \"auth\", column: \"name\" }, locked: true }\n\n// Locked with static default — always submits \"v2\"\n{ name: \"form_version\", type: \"hidden\", default: \"v2\", locked: true }\n\n// Locked with URL param — set by the link, user can't change\n{ name: \"referral_source\", type: \"text\", label: \"Source\",\n prefill: { source: \"url\", param: \"ref\" }, locked: true }\n```\n\n## Conditional fields\n\nAny field can be shown/hidden based on the value of another field using `showWhen`.\n\n```typescript\ninterface Condition {\n field: string; // name of the field to check\n op: \"eq\" | \"neq\" | \"in\" | \"gt\" | \"lt\" | \"filled\" | \"empty\";\n value?: string | number | string[]; // comparison value\n}\n```\n\n### Operators\n\n| Operator | Meaning | Value type |\n|----------|---------|------------|\n| `eq` | equals | string or number |\n| `neq` | not equals | string or number |\n| `in` | value is one of | string[] |\n| `gt` | greater than | number |\n| `lt` | less than | number |\n| `filled` | field has any value | (no value needed) |\n| `empty` | field is empty | (no value needed) |\n\n### Examples\n\n```typescript\n// Show \"Other\" text field when category is \"other\"\n{ name: \"category_other\", label: \"Specify\", type: \"text\",\n showWhen: [{ field: \"category\", op: \"eq\", value: \"other\" }] }\n\n// Show warranty fields for high-value orders\n{ name: \"warranty\", label: \"Add Warranty?\", type: \"select\",\n options: [{ label: \"Yes\", value: \"yes\" }, { label: \"No\", value: \"no\" }],\n showWhen: [{ field: \"price\", op: \"gt\", value: 1000 }] }\n\n// Multiple conditions (AND logic — all must be true)\n{ name: \"rush_fee\", type: \"calculated\", label: \"Rush Fee\",\n expression: \"total * 0.25\", format: \"currency\",\n showWhen: [\n { field: \"priority\", op: \"eq\", value: \"high\" },\n { field: \"total\", op: \"gt\", value: 500 },\n ] }\n```\n\n## Multi-page forms\n\n### FormPage\n\n```typescript\ninterface FormPage {\n id: string; // unique page identifier\n title?: string; // page heading\n description?: string; // page description\n fields: FormField[]; // fields on this page\n showWhen?: Condition[]; // conditionally show/skip entire page\n nextPage?: string | { // navigation control\n conditions: PageBranch[];\n default: string;\n };\n}\n```\n\n### Linear page flow\n\nPages display in array order by default. No `nextPage` needed:\n\n```typescript\npages: [\n { id: \"step1\", title: \"Basic Info\", fields: [...] },\n { id: \"step2\", title: \"Details\", fields: [...] },\n { id: \"step3\", title: \"Confirmation\", fields: [...] },\n]\n```\n\n### Conditional page skipping\n\nSkip a page based on prior answers:\n\n```typescript\npages: [\n { id: \"type\", fields: [\n { name: \"service_type\", label: \"Service Type\", type: \"select\", options: [\n { label: \"Residential\", value: \"residential\" },\n { label: \"Commercial\", value: \"commercial\" },\n ]},\n ]},\n { id: \"commercial_details\", title: \"Commercial Details\",\n showWhen: [{ field: \"service_type\", op: \"eq\", value: \"commercial\" }],\n fields: [...] },\n { id: \"schedule\", title: \"Schedule\", fields: [...] },\n]\n```\n\n### Branching page flow\n\nRoute to different pages based on answers:\n\n```typescript\ninterface PageBranch {\n when: Condition[];\n goto: string; // page ID to jump to\n}\n```\n\n```typescript\npages: [\n {\n id: \"triage\",\n fields: [{ name: \"urgency\", label: \"Urgency\", type: \"select\", options: [\n { label: \"Emergency\", value: \"emergency\" },\n { label: \"Routine\", value: \"routine\" },\n ]}],\n nextPage: {\n conditions: [\n { when: [{ field: \"urgency\", op: \"eq\", value: \"emergency\" }], goto: \"emergency_info\" },\n ],\n default: \"routine_info\",\n },\n },\n { id: \"emergency_info\", title: \"Emergency Details\", fields: [...] },\n { id: \"routine_info\", title: \"Routine Request\", fields: [...] },\n]\n```\n\n## Access modes\n\nControl who can access the form.\n\n```typescript\ntype FormAccess = FormAccessPublic | FormAccessIdentify | FormAccessAuth;\n```\n\n**Choosing the right mode:**\n\n| Scenario | Mode | Why |\n|----------|------|-----|\n| Open form (contact us, public intake) | `public` | Anyone can submit |\n| Self-service — anyone can sign up (customer request, application) | `identify` | User proves email ownership, but anyone can submit |\n| Internal form — only pre-registered users (employee requests, client portals) | `auth` | User must exist in a database table |\n\n**Rule of thumb:** if you have a table of who should access this form, use `auth`. If anyone with a valid email can submit, use `identify`.\n\n### Public (default)\n\nAnyone with the link can submit.\n\n```typescript\naccess: { mode: \"public\" }\n```\n\n### Identify\n\nAnyone can access after verifying email/phone. Good for self-service flows where you need to know who submitted but don't restrict who can submit.\n\n```typescript\ninterface FormAccessIdentify {\n mode: \"identify\";\n method: \"email\" | \"phone\";\n sessionDuration: string; // e.g., \"24h\", \"7d\"\n}\n```\n\n```typescript\naccess: { mode: \"identify\", method: \"email\", sessionDuration: \"24h\" }\n```\n\n### Auth\n\nRestricted to known users. Checks submitted identity against a database table before allowing access. Use for internal tools — employee forms, client-only portals, manager workflows.\n\n```typescript\ninterface FormAccessAuth {\n mode: \"auth\";\n method: \"email\" | \"phone\";\n table: string; // database table — must be a valid SQL identifier (letters, numbers, underscores)\n matchColumn: string; // column name — must be a valid SQL identifier\n sessionDuration: string;\n query?: string; // custom SQL to fetch auth row (use :identity for the user's email/phone)\n}\n```\n\n```typescript\naccess: {\n mode: \"auth\",\n method: \"email\",\n table: \"employees\",\n matchColumn: \"email\",\n sessionDuration: \"7d\",\n}\n```\n\n#### Auth query (computed columns)\n\nBy default, the runtime fetches `SELECT * FROM <table> WHERE <matchColumn> = ?`. Use `query` to replace this with a custom SQL statement that includes JOINs, calculations, or any computed columns. Use `:identity` as the placeholder for the authenticated user's email/phone:\n\n```typescript\naccess: {\n mode: \"auth\",\n method: \"email\",\n table: \"employees\",\n matchColumn: \"email\",\n query: `SELECT e.*, r.hours_per_year - COALESCE(u.used, 0) AS available_pto\n FROM employees e\n LEFT JOIN pto_rules r ON r.employee_id = e.id\n LEFT JOIN (SELECT employee_id, SUM(hours) AS used FROM pto_requests WHERE status != 'denied' GROUP BY employee_id) u ON u.employee_id = e.id\n WHERE e.email = :identity`,\n sessionDuration: \"7d\",\n}\n```\n\nThe computed columns are available in `{{templates}}` (help text, validation messages), `prefill: { source: \"auth\" }`, and `ctx.params._auth_row` in workflows. Values are computed fresh on every form render — no denormalized columns or sync workflows needed. `table` and `matchColumn` are still required (used for dev-mode user dropdown and identity verification).\n\nIn dev mode (`mug dev`), `auth` surfaces show a dropdown of registered users in the View As banner. `identify` surfaces show a freeform email input. Both bypass verification.\n\n### Auth context (auth row)\n\nWhen a surface uses `auth` access mode, the runtime fetches the auth row (via `SELECT *` or the custom `query` if configured) — not just checking if the user exists. This row is available to:\n\n- **Form fields** via `prefill: { source: \"auth\", column: \"name\" }` — auto-fill fields from the user's record\n- **Handler workflows** via `ctx.params._auth_row` — the full row object from the auth table\n- **Portal queries** via `:auth.column` binding — filter data by any column from the user's record (see [portals.md](portals.md))\n\n```typescript\n// In a handler workflow, access the full auth row:\nworkflow(\"handle-request\", async (ctx) => {\n const authRow = ctx.params._auth_row as Record<string, string>;\n const department = authRow.department;\n const managerId = authRow.manager_id;\n});\n```\n\n## Edit mode\n\nPre-fill form fields from existing database records. Useful for update/edit flows.\n\n```typescript\ninterface EditMode {\n table: string; // database table to read from\n recordParam: string; // URL query param containing the record identifier\n matchColumn: string; // column to match against the param value\n}\n```\n\n```typescript\neditMode: {\n table: \"contacts\",\n recordParam: \"contact_id\",\n matchColumn: \"id\",\n}\n// Form URL: https://workspace.mug.work/surfaceId?contact_id=123\n// Fields are pre-filled from the contacts row where id = 123\n```\n\n## Form URLs\n\n| Environment | URL pattern |\n|-------------|-------------|\n| Local dev | `http://localhost:8787/<name>` |\n| Production | `https://<workspace>.mug.work/<name>` |\n\nThe filename (minus `.json`) is the surface ID used in the URL. For example, `surfaces/intake.json` is served at `/intake`.\n\n## Branding\n\nForms automatically pick up workspace branding from `mug.json`:\n\n```json\n{\n \"branding\": {\n \"logo\": \"assets/logo.png\",\n \"logoSquare\": \"assets/icon.png\",\n \"accentColor\": \"#1a5276\"\n }\n}\n```\n\n- **logo** — rectangle logo displayed in the form header above the title. Path relative to workspace root, or a URL.\n- **logoSquare** — square logo variant (used as fallback if `logo` is not set). Reserved for future use in favicons and compact layouts.\n- **accentColor** — hex color applied to submit buttons, focus rings, progress bar, session badge via CSS variable `--accent`, and the browser favicon.\n\nNo changes to form config or code needed — workspace branding is injected automatically. In dev, logos are served from local files and changes to `mug.json` hot-reload. On deploy, logos are uploaded to R2 and embedded in surface configs.\n\n### Per-surface branding overrides\n\nAdd `\"branding\"` directly to a form's JSON config to override workspace branding for that form. Surface values take precedence; workspace values are the fallback.\n\n```json\n{\n \"type\": \"form\",\n \"title\": \"Request Time Off\",\n \"branding\": {\n \"logoSquare\": \"files/company-logo.png\",\n \"accentColor\": \"#2563eb\"\n }\n}\n```\n\n## Breadcrumbs (cross-surface navigation)\n\nWhen linking to a form from another surface (e.g., a portal link), add `?from=` and `?fromLabel=` query parameters to render a \"Back\" breadcrumb above the form header.\n\n```\n/request-form?from=/employee-portal&fromLabel=Back to Portal\n```\n\n| Param | Required | Description |\n|-------|----------|-------------|\n| `from` | yes | URL to navigate back to |\n| `fromLabel` | no | Breadcrumb text (default: \"Back\") |\n\nWhen opened directly without `?from=`, no breadcrumb appears. Breadcrumbs work on both form and portal surfaces — see [portals.md](portals.md#breadcrumbs-cross-surface-navigation).\n\n## CLI commands\n\n```bash\nmug form init <name> # scaffold form config + handler workflow\nmug form validate [name] # validate form configs for errors\nmug form list # list forms and their URLs\n```\n\n## Dynamic forms (ctx.collect)\n\nFor forms that need to be generated programmatically at runtime — when fields depend on database state, forms are per-user, or you need to create forms on the fly — use `ctx.collect()` inside a workflow instead of a static JSON config.\n\n```typescript\nconst url = await ctx.collect({\n id: \"dynamic-intake\",\n title: \"Intake Form\",\n workflow: \"handle-intake\",\n access: { mode: \"public\" },\n fields: [\n { name: \"name\", type: \"text\", label: \"Name\", required: true },\n ],\n});\n```\n\n`ctx.collect()` accepts the same fields as the static JSON config (title, description, submitText, workflow, access, fields/pages, editMode). The `id` field controls the surface ID in the URL — if omitted, a random 8-character ID is generated.\n\n**Important:** Dynamic forms require running the creation workflow before the URL works. In production, deploy then run `mug run <workflow> --production`. In local dev, `ctx.collect()` does not persist the config — use static JSON configs for development.\n\n## Complete example\n\nService request form with conditional fields, multi-page, and authenticated access:\n\n**Form config** (`surfaces/service-request.json`):\n```json\n{\n \"type\": \"form\",\n \"title\": \"Service Request\",\n \"description\": \"Submit a service request and we'll schedule a technician.\",\n \"submitText\": \"Submit Request\",\n \"workflow\": \"handle-service-request\",\n \"access\": {\n \"mode\": \"auth\",\n \"method\": \"email\",\n \"table\": \"customers\",\n \"matchColumn\": \"email\",\n \"sessionDuration\": \"30d\"\n },\n \"pages\": [\n {\n \"id\": \"service\",\n \"title\": \"Service Details\",\n \"fields\": [\n { \"name\": \"service_type\", \"label\": \"Service Type\", \"type\": \"select\", \"required\": true, \"options\": [\n { \"label\": \"Repair\", \"value\": \"repair\" },\n { \"label\": \"Installation\", \"value\": \"installation\" },\n { \"label\": \"Maintenance\", \"value\": \"maintenance\" }\n ]},\n { \"name\": \"urgency\", \"label\": \"Urgency\", \"type\": \"select\", \"required\": true, \"options\": [\n { \"label\": \"Emergency (24hr)\", \"value\": \"emergency\" },\n { \"label\": \"Urgent (48hr)\", \"value\": \"urgent\" },\n { \"label\": \"Standard (1 week)\", \"value\": \"standard\" }\n ]},\n { \"name\": \"description\", \"label\": \"Describe the issue\", \"type\": \"textarea\", \"rows\": 4, \"required\": true },\n { \"name\": \"photos\", \"label\": \"Photos (optional)\", \"type\": \"file\", \"accept\": \"image/*\", \"maxSizeMb\": 10 }\n ]\n },\n {\n \"id\": \"equipment\",\n \"title\": \"Equipment Info\",\n \"showWhen\": [{ \"field\": \"service_type\", \"op\": \"in\", \"value\": [\"repair\", \"maintenance\"] }],\n \"fields\": [\n { \"name\": \"equipment_type\", \"label\": \"Equipment Type\", \"type\": \"text\" },\n { \"name\": \"model_number\", \"label\": \"Model Number\", \"type\": \"text\" },\n { \"name\": \"install_year\", \"label\": \"Year Installed\", \"type\": \"number\", \"min\": 1990, \"max\": 2026 }\n ]\n },\n {\n \"id\": \"schedule\",\n \"title\": \"Scheduling\",\n \"fields\": [\n { \"name\": \"preferred_date\", \"label\": \"Preferred Date\", \"type\": \"date\" },\n { \"name\": \"preferred_time\", \"label\": \"Preferred Time\", \"type\": \"select\", \"options\": [\n { \"label\": \"Morning (8am-12pm)\", \"value\": \"morning\" },\n { \"label\": \"Afternoon (12pm-5pm)\", \"value\": \"afternoon\" },\n { \"label\": \"Any time\", \"value\": \"any\" }\n ]},\n { \"name\": \"access_notes\", \"label\": \"Access Instructions\", \"type\": \"textarea\", \"rows\": 2,\n \"placeholder\": \"Gate code, parking instructions, etc.\" }\n ]\n }\n ]\n}\n```\n",
|
|
40
|
+
"portals.md": "# Portals — Full API Reference\n\nPortals display workspace data as tabbed, section-based pages. Each tab contains typed sections (table, stats, progress, text, chart, gallery) with optional accordion containers. Config-driven JSON — no TypeScript code needed for the surface itself. Table sections support detail pages and action buttons that trigger workflows.\n\nFor a guided walkthrough, use the `/portal` skill. For the full `ctx.params` shape when portal actions trigger workflows, see [api.md — ctx.params](api.md#ctxparams).\n\n## PortalConfig\n\nPortal configs live at `surfaces/<name>.json`. The `type` field must be `\"portal\"`.\n\n```typescript\ninterface PortalConfig {\n type: \"portal\";\n title: string; // page heading\n description?: string; // subtitle below heading\n access: FormAccess; // who can view (same as forms)\n database?: string; // workspace database name (default: workspace name)\n sections?: PortalSection[]; // sections rendered above the tab bar, visible on all tabs\n tabs: PortalTab[]; // one or more tabs\n branding?: BrandingConfig; // optional per-surface override; falls back to mug.json workspace branding\n}\n```\n\n### Per-surface branding\n\nOverride workspace branding for individual surfaces. Surface branding takes precedence; workspace branding (from `mug.json`) is the fallback for any field not specified.\n\n```json\n{\n \"type\": \"portal\",\n \"title\": \"Team Requests — Renwick Builders\",\n \"branding\": {\n \"logoSquare\": \"files/renwick-logo.png\",\n \"accentColor\": \"#2563eb\"\n }\n}\n```\n\n`logo` (rectangle), `logoSquare` (square), and `accentColor` are all optional. Logo paths are relative to the workspace root — files in `files/` work directly.\n\n### Top-level sections\n\nSections defined at the config root render **above the tab bar** and stay visible when switching tabs. Useful for stats cards or summary content that applies across all tabs — avoids duplicating sections in every tab.\n\n```json\n{\n \"type\": \"portal\",\n \"title\": \"Approvals\",\n \"sections\": [\n {\n \"type\": \"stats\",\n \"query\": \"SELECT count(*) filter (where status='pending') as pending, count(*) filter (where status='approved') as approved, count(*) filter (where status='denied') as denied FROM requests WHERE approver_email = :user\",\n \"items\": [\n { \"label\": \"Pending\", \"column\": \"pending\", \"color\": \"#f59e0b\", \"href\": \"?tab=pending\" },\n { \"label\": \"Approved\", \"column\": \"approved\", \"color\": \"#16a34a\", \"href\": \"?tab=approved\" },\n { \"label\": \"Denied\", \"column\": \"denied\", \"color\": \"#ef4444\", \"href\": \"?tab=denied\" }\n ]\n }\n ],\n \"tabs\": [...]\n}\n```\n\n## PortalTab\n\nEach tab has an ID, a label for the tab bar, and an array of sections.\n\n```typescript\ninterface PortalTab {\n id: string; // URL-safe identifier (e.g., \"timeoff\")\n label: string; // tab bar display text\n color?: string; // tab text + underline color (hex or CSS color)\n countQuery?: string; // SQL query returning a single number — shown as badge: \"Label (N)\"\n sections: PortalSection[]; // sections rendered in order\n links?: PortalLink[]; // nav links shown when this tab is active\n}\n```\n\nSingle-tab portals don't show a tab bar — the sections render directly.\n\n### Tab colors\n\nSet `color` on a tab to give it a colored text and active underline instead of the default accent color. Useful for status-based tabs where color conveys meaning:\n\n```json\n\"tabs\": [\n { \"id\": \"pending\", \"label\": \"Pending\", \"color\": \"#f59e0b\", ... },\n { \"id\": \"approved\", \"label\": \"Approved\", \"color\": \"#16a34a\", ... },\n { \"id\": \"denied\", \"label\": \"Denied\", \"color\": \"#ef4444\", ... }\n]\n```\n\n### Tab count badges\n\nSet `countQuery` on a tab to show a dynamic count badge next to the label. The query must return a single row with a single numeric column. The count updates on every page load.\n\n```json\n{\n \"id\": \"pending\",\n \"label\": \"Pending\",\n \"color\": \"#f59e0b\",\n \"countQuery\": \"SELECT count(*) FROM requests WHERE approver_email = :user AND status = 'pending'\",\n \"sections\": [...]\n}\n```\n\nRenders as: **Pending (3)** — with a pill badge that inherits the tab's color.\n\n## Section Types\n\nSections are a discriminated union on the `type` field:\n\n### table\n\nPaginated table with clickable rows → detail pages → action buttons. This is the main data display section.\n\n```typescript\ninterface TableSection {\n type: \"table\";\n query: string; // SQL SELECT query — use :user for session identity. Must start with SELECT or WITH, no semicolons.\n primaryKey?: string; // column for row identity (default: \"id\") — must be a valid SQL identifier\n pageSize?: number; // rows per page (default: 25)\n columns: PortalColumn[]; // list view table columns\n detail?: {\n title?: string; // template with {{column}} interpolation\n fields: PortalField[]; // detail page fields\n };\n actions?: PortalAction[]; // buttons on detail page\n emptyMessage?: string; // shown when query returns 0 rows\n}\n```\n\n### stats\n\nRow of metric cards from a single-row query result.\n\n```typescript\ninterface StatsSection {\n type: \"stats\";\n query: string; // should return a single row\n items: {\n label: string; // card heading\n column: string; // column name from query result\n format?: \"number\" | \"currency\" | \"currency-whole\" | \"currency-short\" | \"percent\";\n color?: string; // top border accent color (hex or CSS color)\n valueColor?: string; // color for the number — see below\n href?: string; // makes the card a clickable link\n }[];\n}\n```\n\n### Clickable stat cards (`href`)\n\nSet `href` on a stat item to make the entire card a clickable link. The card gets a hover effect (border darkens, subtle shadow). Useful for linking stats to corresponding tabs:\n\n```json\n\"items\": [\n { \"label\": \"Pending\", \"column\": \"pending\", \"color\": \"#f59e0b\", \"href\": \"?tab=pending\" },\n { \"label\": \"Approved\", \"column\": \"approved\", \"color\": \"#16a34a\", \"href\": \"?tab=approved\" }\n]\n```\n\nAny valid URL works — relative paths, `?tab=` for tab navigation, or external links.\n\n**`valueColor` options:**\n\n| Value | Effect |\n|-------|--------|\n| `\"#hex\"` | Explicit color for the number |\n| `\"match\"` | Inherit from the item's `color` (border color) |\n| `\"neutral\"` | Default dark text (`#1a1a1a`) — no accent |\n| omitted + `color` set | Implicit `\"match\"` — number matches border color |\n| omitted + no `color` | Inherits workspace `accentColor` (default behavior) |\n\nExample — yellow border + yellow number for \"Pending\", green for \"Approved\", neutral for \"Total\":\n```json\n\"items\": [\n { \"label\": \"Total\", \"column\": \"total\", \"valueColor\": \"neutral\" },\n { \"label\": \"Pending\", \"column\": \"pending\", \"color\": \"#f59e0b\" },\n { \"label\": \"Approved\", \"column\": \"approved\", \"color\": \"#16a34a\" }\n]\n```\n\n### progress\n\nRow of progress bars, one per result row.\n\n```typescript\ninterface ProgressSection {\n type: \"progress\";\n query: string; // each row = one progress bar\n labelColumn: string; // column for bar label\n valueColumn: string; // column for current value\n maxColumn: string; // column for max value\n subtitleTemplate?: string; // template with {{column}} interpolation\n color?: string; // bar fill color (default: accent color)\n colorColumn?: string; // column containing hex color per row (overrides color)\n colorThresholds?: Array<{ // threshold-based coloring by percentage (overrides color)\n percent: number; // upper bound percentage (inclusive)\n color: string; // hex color when pct <= this threshold\n }>;\n}\n```\n\nColor priority: `colorColumn` > `colorThresholds` > `color` > accent default.\n\n**colorThresholds example** — green under 50%, yellow 50–80%, red above:\n```json\n{\n \"type\": \"progress\",\n \"query\": \"SELECT type, used, max FROM budgets\",\n \"labelColumn\": \"type\", \"valueColumn\": \"used\", \"maxColumn\": \"max\",\n \"colorThresholds\": [\n { \"percent\": 50, \"color\": \"#10b981\" },\n { \"percent\": 80, \"color\": \"#f59e0b\" },\n { \"percent\": 100, \"color\": \"#ef4444\" }\n ]\n}\n```\n\n**colorColumn example** — query computes color per row:\n```json\n{\n \"type\": \"progress\",\n \"query\": \"SELECT type, used, max, CASE WHEN used*1.0/max > 0.8 THEN '#ef4444' WHEN used*1.0/max > 0.5 THEN '#f59e0b' ELSE '#10b981' END as bar_color FROM budgets\",\n \"labelColumn\": \"type\", \"valueColumn\": \"used\", \"maxColumn\": \"max\",\n \"colorColumn\": \"bar_color\"\n}\n```\n\n### text\n\nStatic markdown content block — no query needed.\n\n```typescript\ninterface TextSection {\n type: \"text\";\n content: string; // markdown: # headings, **bold**, *italic*, - lists\n}\n```\n\n### chart\n\nInline SVG chart — no charting library required.\n\n```typescript\ninterface ChartSection {\n type: \"chart\";\n query: string;\n chartType: \"bar\" | \"donut\";\n labelColumn?: string; // column for labels (default: \"label\")\n valueColumn?: string; // column for values (default: \"value\")\n title?: string; // chart heading\n color?: string; // bar color (bar chart only)\n colorColumn?: string; // per-bar color from data\n colors?: Record<string, string>; // per-label color map (label value → hex color)\n}\n```\n\n- **Bar chart**: horizontal bars sorted by value. Max 20 bars.\n- **Donut chart**: circle segments with legend. Max 8 segments.\n- Excess items truncated with \"+N others\" note.\n- **`labelColumn`/`valueColumn`**: default to `\"label\"` and `\"value\"` — alias your SQL columns to match and you can omit these.\n- **`colors`**: map label values to hex colors. Unmatched labels use the palette. Example: `\"colors\": { \"complete\": \"#16a34a\", \"error\": \"#ef4444\" }`.\n\n### gallery\n\nImage card grid.\n\n```typescript\ninterface GallerySection {\n type: \"gallery\";\n query: string;\n imageColumn: string; // column containing image URL\n titleColumn?: string; // column for card title\n fields?: PortalField[]; // additional fields below title\n columns?: number; // cards per row (default: 3)\n}\n```\n\n### accordion\n\nCollapsible container that wraps other sections. Uses native `<details>`/`<summary>` — no JavaScript.\n\n```typescript\ninterface AccordionSection {\n type: \"accordion\";\n label: string; // clickable summary text\n open?: boolean; // start expanded (default: false)\n sections: PortalSection[]; // child sections (recursive)\n}\n```\n\n## PortalColumn\n\nDefines a column in a table section's list view.\n\n```typescript\ninterface PortalColumn {\n key: string; // column name from query result — must be a valid SQL identifier (letters, numbers, underscores)\n label: string; // display header text\n format?: \"date\" | \"datetime\" | \"currency\" | \"currency-whole\" | \"currency-short\" | \"number\" | \"percent\";\n dateFormat?: \"short\"; // compact date rendering (M/D/YY, h:mm AM)\n badge?: boolean; // render as colored status badge\n badgeColors?: Record<string, string>; // custom badge color map\n}\n```\n\n### Format examples\n\n| Format | dateFormat | Input | Output (table) | Output (stat) |\n|--------|-----------|-------|--------|--------|\n| `date` | (none) | `2026-05-15` | May 15, 2026 | — |\n| `date` | `\"short\"` | `2026-05-15` | 5/15/26 | — |\n| `datetime` | (none) | `2026-05-15T15:30:00` | May 15, 2026, 3:30 PM | — |\n| `datetime` | `\"short\"` | `2026-05-15T15:30:00` | 5/15/26, 3:30 PM | — |\n| `currency` | — | `1234.5` | $1,234.50 | $1,235 |\n| `currency-whole` | — | `1234.5` | $1,235 | $1,235 |\n| `currency-short` | — | `125000` | $125K | $125K |\n| `number` | — | `1234` | 1,234 | 1,234 |\n| `percent` | — | `85.3` | 85.3% | 85.3% |\n| (none) | — | any | raw string value | raw string value |\n\nStats use whole dollars by default for `currency`. Values that overflow stat card width auto-abbreviate to K/M.\n\nAll `date` and `datetime` values render in the workspace timezone (`settings.timezone` in mug.json, auto-detected at `mug init`). To override for a specific surface, add `\"timezone\": \"America/New_York\"` to the surface JSON root.\n\n## PortalField\n\nDefines a field in a table section's detail view. Same as PortalColumn plus `multiline` format.\n\n```typescript\ninterface PortalField {\n key: string;\n label: string;\n format?: \"date\" | \"datetime\" | \"currency\" | \"currency-whole\" | \"currency-short\" | \"number\" | \"percent\" | \"multiline\";\n badge?: boolean;\n badgeColors?: Record<string, string>; // custom badge color map\n}\n```\n\n## PortalAction\n\nButton on a table section's detail page that triggers a workflow.\n\n```typescript\ninterface PortalAction {\n name: string; // action identifier — sent as params.action to workflow\n label: string; // button text\n workflow: string; // workflow name to trigger\n style?: \"primary\" | \"danger\" | \"success\" | \"warning\" | \"default\";\n color?: string; // explicit hex override (overrides style color, hover still works)\n showWhen?: Condition; // show only when row data matches\n confirm?: string; // confirmation dialog text — shown before firing\n afterMessage?: string; // status text after action fires — {{timestamp}} replaced with local date/time\n afterColor?: string; // color for the after-action status message (default: accent color)\n afterActions?: { // follow-up buttons shown after action fires\n label: string; // button text\n action: string; // sent as params.action to the same workflow\n style?: string; // button style (same options as PortalAction.style)\n }[];\n}\n```\n\n**Button styles:**\n\n| Style | Color | Use case |\n|-------|-------|----------|\n| `\"primary\"` | Workspace accent color | Main CTA |\n| `\"success\"` | Green (`#16a34a`) | Approve, confirm, complete |\n| `\"warning\"` | Amber (`#d97706`) | Caution, escalate |\n| `\"danger\"` | Red (`#dc2626`) | Deny, delete, reject |\n| `\"default\"` | Gray | Secondary actions |\n\nThe `color` property overrides the style's background with any hex color. Hover darkening still works.\n\n**Optimistic behavior** — action buttons are replaced immediately with a status message. On backend error, original buttons restore. Default message: \"✓ {label} · {timestamp}\". Customize with `afterMessage` (use `{{timestamp}}` placeholder). Add `afterActions` for follow-up buttons (e.g., undo/revoke).\n\n**Approve/deny pattern** — use `\"success\"` and `\"danger\"` instead of `\"primary\"` to avoid both buttons inheriting accentColor. Use `afterColor` to match the status message to the action:\n```json\n\"actions\": [\n {\n \"name\": \"approve\", \"label\": \"Approve\", \"workflow\": \"handle-approval\", \"style\": \"success\",\n \"showWhen\": { \"field\": \"status\", \"op\": \"eq\", \"value\": \"pending\" },\n \"afterMessage\": \"Approved on {{timestamp}}\", \"afterColor\": \"#16a34a\",\n \"afterActions\": [{ \"label\": \"Revoke\", \"action\": \"revoke\", \"style\": \"danger\" }]\n },\n {\n \"name\": \"deny\", \"label\": \"Deny\", \"workflow\": \"handle-approval\", \"style\": \"danger\",\n \"showWhen\": { \"field\": \"status\", \"op\": \"eq\", \"value\": \"pending\" },\n \"confirm\": \"Deny this request?\",\n \"afterMessage\": \"Denied on {{timestamp}}\", \"afterColor\": \"#dc2626\"\n }\n]\n```\n\n### Action data flow\n\nWhen a user clicks an action button, the workflow receives:\n\n```typescript\n{\n action: \"approve\", // the action name\n id: 123, // all row data fields\n employee_name: \"John\",\n status: \"pending\",\n _verified_email: \"manager@example.com\",\n _surface: \"approvals\",\n _workspace: \"my-workspace\",\n}\n```\n\n### showWhen condition\n\nSame condition format as form field conditionals:\n\n```typescript\ninterface Condition {\n field: string;\n op: \"eq\" | \"neq\" | \"in\" | \"gt\" | \"lt\" | \"filled\" | \"empty\";\n value?: string | number | string[];\n}\n```\n\n## Timeline Playback\n\nAction buttons can trigger scripted visual sequences instead of real workflows. Set `\"timeline\"` (array of `TimelineEvent`) instead of `\"workflow\"` (string). Playback is purely client-side — no server round-trips, no workflow execution.\n\nUse cases: client demos, presentations, marketing demos, prototyping workflows before they're built.\n\n### TimelineEvent\n\n```typescript\ninterface TimelineEvent {\n delay: number; // seconds from trigger (not cumulative)\n event: \"toast\" | \"preview\" | \"stream\" | \"update\" | \"highlight\";\n message?: string; // toast: notification text; highlight: badge text\n channel?: \"email\" | \"sms\"; // preview: message channel\n to?: string; // preview: recipient display name\n subject?: string; // preview: email subject line\n body?: string; // preview: message body (supports \\n)\n target?: string; // stream: CSS selector for target element\n text?: string; // stream: text to reveal char-by-char\n row?: string; // update/highlight: data-row-id of the table row\n action?: string; // highlight: action name (targets button on detail page)\n field?: string; // update: column header text to match\n value?: string; // update: new cell value\n}\n```\n\n### Event types\n\n| Event | Behavior | Mobile |\n|---|---|---|\n| `toast` | Popup top-right, auto-dismiss 4s, stacks vertically | Bottom banner, full-width |\n| `preview` | Email/SMS card slides in from right | Full-width bottom sheet |\n| `stream` | Char-by-char text reveal (~30ms/char) with blinking cursor | Same |\n| `update` | Swap table cell value, yellow flash highlight | Same |\n| `highlight` | Pulse-highlight a row or button with gold glow + badge with pointer icon. Row: `row` field targets `data-row-id`. Button: `action` field targets `data-action`. `message` shows as a badge. Row uses gold; button inherits its own color. | Same |\n\n### Example: action button with timeline\n\n```json\n{\n \"name\": \"send-reminder\",\n \"label\": \"Send Reminder\",\n \"style\": \"primary\",\n \"timeline\": [\n { \"delay\": 1, \"event\": \"toast\", \"message\": \"AI drafting reminder email...\" },\n { \"delay\": 3, \"event\": \"preview\", \"channel\": \"email\", \"to\": \"Mr. Davis\", \"subject\": \"Payment Reminder\", \"body\": \"Hi Mr. Davis,\\n\\nThis is a friendly reminder that your invoice is past due.\" },\n { \"delay\": 5, \"event\": \"toast\", \"message\": \"Email sent to Mr. Davis\" },\n { \"delay\": 6, \"event\": \"toast\", \"message\": \"SMS reminder also sent to (555) 234-5678\" },\n { \"delay\": 8, \"event\": \"update\", \"row\": \"inv-001\", \"field\": \"status\", \"value\": \"reminded\" }\n ]\n}\n```\n\nButton is disabled during playback and re-enabled after the last event fires + 2 seconds.\n\n### Autoplay\n\nPortal config accepts an `\"autoplay\"` array — same `TimelineEvent` format, fires once on page load. Use for guided demos (highlight rows/buttons on load) or AI-style summary streams on dashboards:\n\n```json\n{\n \"type\": \"portal\",\n \"title\": \"Customers\",\n \"autoplay\": [\n { \"delay\": 0, \"event\": \"highlight\", \"row\": \"c-001\", \"message\": \"Click to see AI follow-up\" },\n { \"delay\": 0, \"event\": \"highlight\", \"action\": \"draft-followup\", \"message\": \"Try it\" }\n ],\n \"tabs\": [...]\n}\n```\n\nOn the list page, the row highlight fires (button target doesn't exist — ignored). On the detail page, the button highlight fires (row target doesn't exist — ignored). Both events coexist safely in the same autoplay array.\n\nThe `stream` event's `target` must match an element in the page. On detail pages, stream targets from action timelines are auto-created as hidden containers and revealed when content streams in.\n\n## Embed Mode\n\nAppend `?embed=true` to any surface URL (portal or form) to render in iframe-friendly mode:\n\n- Strips: logo, session info, logout button, breadcrumbs\n- Keeps: title, description, tabs, sections, actions, timeline playback\n- CSP header: `frame-ancestors 'self' https://mug.work https://*.mug.work`\n- Works with demo mode (identity set via demo config, not session cookie)\n- Subdomain routing preserves the parameter: `workspace.mug.work/surface?embed=true`\n\nExample iframe:\n```html\n<iframe src=\"https://demo.mug.work/renwick-finance?embed=true\" style=\"width:100%;height:600px;border:none;\"></iframe>\n```\n\n## PortalLink\n\nNavigation links displayed when a tab is active.\n\n```typescript\ninterface PortalLink {\n label: string;\n href: string;\n}\n```\n\n## Breadcrumbs (cross-surface navigation)\n\nWhen linking from one surface to another, add `?from=` and `?fromLabel=` query parameters to give the target surface a \"Back\" breadcrumb.\n\n```json\n{\n \"links\": [{ \"label\": \"Submit Request\", \"href\": \"/request-form?from=/portal&fromLabel=Back to Portal\" }]\n}\n```\n\nThis renders a `← Back to Portal` link above the form header. Works on both form and portal surfaces.\n\n| Param | Required | Description |\n|-------|----------|-------------|\n| `from` | yes | URL to navigate back to |\n| `fromLabel` | no | Breadcrumb text (default: \"Back\") |\n\nWhen a surface is opened directly (without `?from=`), no breadcrumb appears — the surface renders as usual.\n\n## FormAccess (shared with forms)\n\n```typescript\ninterface FormAccess {\n mode: \"public\" | \"identify\" | \"auth\";\n method?: \"email\" | \"phone\";\n sessionDuration?: string; // e.g., \"30m\", \"24h\", \"7d\"\n table?: string; // auth mode only\n matchColumn?: string; // auth mode only\n}\n```\n\n## The `:user` parameter\n\nUse `:user` in SQL queries to reference the current user's verified identity:\n\n```sql\nSELECT * FROM requests WHERE employee_email = :user ORDER BY created_at DESC\n```\n\n- Bound as a parameterized query value (SQL-injection safe)\n- Resolves to the session's verified email or phone\n- Can appear multiple times in the same query\n- In dev mode, resolves to the \"View As\" banner identity\n- **With `access: { mode: \"public\" }`**: resolves to empty string — queries filtering on `:user` will return zero rows. Only use `:user` with `identify` or `auth` access modes\n\n## The `:auth.column` parameter\n\nWhen a portal uses `auth` access mode, you can reference any column from the user's auth table row in SQL queries:\n\n```sql\nSELECT * FROM requests WHERE department = :auth.department ORDER BY created_at DESC\nSELECT * FROM tasks WHERE assignee_id = :auth.id AND status = 'active'\n```\n\n- Requires `auth` access mode — the runtime fetches `SELECT *` from the auth table when the user authenticates\n- `:auth.name`, `:auth.department`, `:auth.id` — any column from the auth table is available\n- SQL-injection safe (parameterized)\n- Works in table section queries, stats queries, and detail view queries\n- Combine with `:user`: `WHERE email = :user AND department = :auth.department`\n\nThis enables role-based portals where different users see different data based on their attributes, not just their identity.\n\n## Badge colors\n\nWhen `badge: true` is set on a column or field, values render as colored badges.\n\n### Built-in status colors (default)\n\n| Color | Values (case-insensitive) |\n|-------|---------------------------|\n| Yellow | pending, waiting, draft, review |\n| Green | approved, active, done, success, completed |\n| Red | denied, rejected, failed, error |\n| Gray | cancelled, expired, archived |\n\nUnrecognized values render as gray.\n\n### Custom badge colors (`badgeColors`)\n\nFor non-status values (categories, types, tags), use `badgeColors` to map values to hex colors. The hex is used as the text color; the background is auto-generated at 13% opacity.\n\n```json\n{ \"key\": \"type\", \"label\": \"Type\", \"badge\": true, \"badgeColors\": {\n \"PTO\": \"#3b82f6\",\n \"Sick\": \"#ef4444\",\n \"Personal\": \"#8b5cf6\"\n}}\n```\n\nLookup is case-insensitive. Values not in the custom map fall through to the built-in status colors, then to gray. `badgeColors` works on both `PortalColumn` (list view) and `PortalField` (detail view).\n\n## URL routing\n\n| Path | Purpose |\n|------|---------|\n| `/<surfaceId>` | First tab (default) |\n| `/<surfaceId>?tab=<tabId>` | Specific tab |\n| `/<surfaceId>/row/<rowId>?tab=<tabId>§ion=<N>` | Detail page for a table section row |\n| `/<surfaceId>/action` | POST endpoint for action buttons |\n| `/<surfaceId>/auth` | Authentication flow (same as forms) |\n\nPagination uses `?page_0=2&page_1=3` — each table section in a tab has its own page parameter keyed by section index.\n\n## Branding\n\nPortals automatically pick up workspace branding from `mug.json`:\n\n```json\n{\n \"branding\": {\n \"logo\": \"assets/logo.png\",\n \"logoSquare\": \"assets/icon.png\",\n \"accentColor\": \"#1a5276\"\n }\n}\n```\n\n## Choosing the right access mode\n\n| Scenario | Mode | Why |\n|----------|------|-----|\n| Anyone can view (public dashboard, leaderboard) | `public` | No identity needed |\n| Self-service — anyone can sign up (customer portal) | `identify` | User proves email ownership |\n| Internal tool — only pre-registered users | `auth` | User must exist in a database table |\n\n## Dev mode — \"View As\" banner\n\nIn `mug dev`, all surfaces display a sticky yellow banner. The banner adapts to the access mode:\n\n- **`auth` mode**: dropdown of registered users from the auth table\n- **`identify` mode**: freeform email input\n- **`public` mode**: freeform email input (for testing `:user` queries)\n\n## CLI commands\n\n```bash\nmug portal init <name> # scaffold portal config in surfaces/<name>.json\nmug portal list # list all portal surfaces with URLs\nmug dev # serve portals locally (with View As banner)\n```\n\n## Complete example — Employee portal with tabs\n\n**Employee portal** (`surfaces/employee-portal.json`):\n```json\n{\n \"type\": \"portal\",\n \"title\": \"Employee Portal\",\n \"access\": { \"mode\": \"auth\", \"method\": \"email\", \"table\": \"employees\", \"matchColumn\": \"email\", \"sessionDuration\": \"7d\" },\n \"tabs\": [\n {\n \"id\": \"timeoff\",\n \"label\": \"Time Off\",\n \"sections\": [\n {\n \"type\": \"progress\",\n \"query\": \"SELECT type, used, annual, (annual - used) as remaining FROM pto_balances WHERE email = :user\",\n \"labelColumn\": \"type\",\n \"valueColumn\": \"used\",\n \"maxColumn\": \"annual\",\n \"subtitleTemplate\": \"{{remaining}} hrs remaining\"\n },\n {\n \"type\": \"stats\",\n \"query\": \"SELECT count(*) as total, sum(case when status='pending' then 1 else 0 end) as pending FROM time_off_requests WHERE employee_email = :user\",\n \"items\": [\n { \"label\": \"Total Requests\", \"column\": \"total\", \"valueColor\": \"neutral\" },\n { \"label\": \"Pending\", \"column\": \"pending\", \"color\": \"#f59e0b\" }\n ]\n },\n {\n \"type\": \"table\",\n \"query\": \"SELECT id, type, start_date, end_date, hours, status FROM time_off_requests WHERE employee_email = :user ORDER BY created_at DESC\",\n \"columns\": [\n { \"key\": \"type\", \"label\": \"Type\", \"badge\": true, \"badgeColors\": { \"PTO\": \"#3b82f6\", \"Sick\": \"#ef4444\", \"Personal\": \"#8b5cf6\" } },\n { \"key\": \"start_date\", \"label\": \"Start\", \"format\": \"date\" },\n { \"key\": \"end_date\", \"label\": \"End\", \"format\": \"date\" },\n { \"key\": \"hours\", \"label\": \"Hours\" },\n { \"key\": \"status\", \"label\": \"Status\", \"badge\": true }\n ],\n \"detail\": {\n \"title\": \"{{type}} — {{start_date}} to {{end_date}}\",\n \"fields\": [\n { \"key\": \"type\", \"label\": \"Type\", \"badge\": true, \"badgeColors\": { \"PTO\": \"#3b82f6\", \"Sick\": \"#ef4444\", \"Personal\": \"#8b5cf6\" } },\n { \"key\": \"start_date\", \"label\": \"Start Date\", \"format\": \"date\" },\n { \"key\": \"end_date\", \"label\": \"End Date\", \"format\": \"date\" },\n { \"key\": \"hours\", \"label\": \"Hours\" },\n { \"key\": \"reason\", \"label\": \"Reason\" },\n { \"key\": \"status\", \"label\": \"Status\", \"badge\": true }\n ]\n },\n \"actions\": [\n { \"name\": \"cancel\", \"label\": \"Cancel Request\", \"workflow\": \"handle-cancel\", \"style\": \"danger\", \"showWhen\": { \"field\": \"status\", \"op\": \"eq\", \"value\": \"pending\" } }\n ],\n \"pageSize\": 10\n }\n ],\n \"links\": [{ \"label\": \"Request Time Off\", \"href\": \"/request-timeoff?from=/employee-portal&fromLabel=Back to Portal\" }]\n },\n {\n \"id\": \"company\",\n \"label\": \"Company\",\n \"sections\": [\n {\n \"type\": \"text\",\n \"content\": \"## Company Announcements\\nLatest updates from the team.\"\n },\n {\n \"type\": \"table\",\n \"query\": \"SELECT id, title, posted_at FROM announcements ORDER BY posted_at DESC\",\n \"columns\": [\n { \"key\": \"title\", \"label\": \"Title\" },\n { \"key\": \"posted_at\", \"label\": \"Posted\", \"format\": \"date\" }\n ],\n \"detail\": {\n \"title\": \"{{title}}\",\n \"fields\": [\n { \"key\": \"title\", \"label\": \"Title\" },\n { \"key\": \"body\", \"label\": \"Content\", \"format\": \"multiline\" },\n { \"key\": \"posted_at\", \"label\": \"Posted\", \"format\": \"datetime\" }\n ]\n }\n }\n ]\n }\n ]\n}\n```\n\n**Simple single-tab portal** (no tab bar shown):\n```json\n{\n \"type\": \"portal\",\n \"title\": \"Pending Approvals\",\n \"access\": { \"mode\": \"auth\", \"method\": \"email\", \"table\": \"managers\", \"matchColumn\": \"email\", \"sessionDuration\": \"7d\" },\n \"tabs\": [\n {\n \"id\": \"main\",\n \"label\": \"Approvals\",\n \"sections\": [\n {\n \"type\": \"table\",\n \"query\": \"SELECT id, employee_name, type, start_date, end_date, hours, status FROM time_off_requests WHERE approver_email = :user AND status = 'pending' ORDER BY created_at DESC\",\n \"columns\": [\n { \"key\": \"employee_name\", \"label\": \"Employee\" },\n { \"key\": \"type\", \"label\": \"Type\" },\n { \"key\": \"start_date\", \"label\": \"Start\", \"format\": \"date\" },\n { \"key\": \"hours\", \"label\": \"Hours\" }\n ],\n \"detail\": {\n \"title\": \"{{employee_name}} — {{type}}\",\n \"fields\": [\n { \"key\": \"employee_name\", \"label\": \"Employee\" },\n { \"key\": \"type\", \"label\": \"Type\" },\n { \"key\": \"start_date\", \"label\": \"Start\", \"format\": \"date\" },\n { \"key\": \"end_date\", \"label\": \"End\", \"format\": \"date\" },\n { \"key\": \"hours\", \"label\": \"Hours\" },\n { \"key\": \"reason\", \"label\": \"Reason\" }\n ]\n },\n \"actions\": [\n { \"name\": \"approve\", \"label\": \"Approve\", \"workflow\": \"handle-approval\", \"style\": \"success\" },\n { \"name\": \"deny\", \"label\": \"Deny\", \"workflow\": \"handle-approval\", \"style\": \"danger\", \"confirm\": \"Deny this request?\" }\n ],\n \"emptyMessage\": \"No pending approvals.\"\n }\n ]\n }\n ]\n}\n```\n",
|
|
41
|
+
"cli.md": "# CLI — Full Command Reference\n\nComplete reference for all `mug` CLI commands. For a compact table of every command and flag, see [api.md — CLI Quick Reference](api.md#cli-quick-reference).\n\n## Workspace setup\n\n### mug init [name]\n\nCreate a new Mug workspace.\n\n```bash\nmug init my-client # create workspace in ./my-client/\nmug init # create workspace in current directory\n```\n\nCreates the workspace structure:\n```\nmy-client/\n├── mug.json # workspace config\n├── CLAUDE.md # AI agent instructions (with mug:start/mug:end block)\n├── AGENTS.md # AI agent instructions for Codex\n├── .cursor/rules/mug.mdc # Cursor rules\n├── .claude/skills/ # Claude Code skills (connect, workflow, form)\n├── .agents/skills/ # Codex skills\n├── .mug/\n│ ├── docs/ # platform reference docs (sources, workflows, forms, portals, CLI)\n│ └── secrets # workspace credentials (.gitignored)\n├── connectors/ # connector source files (auto-discovered)\n├── workflows/ # workflow files (auto-discovered)\n├── agents/ # AI agent configs\n├── surfaces/ # form and portal JSON configs\n├── files/ # static files synced to R2 (assets, templates, CSVs)\n│ └── .remote # manifest of production files\n├── databases/ # local SQLite files synced to production DOs (.db gitignored)\n│ └── .remote # manifest of production databases\n├── package.json\n└── .gitignore\n```\n\n### mug sync\n\nBidirectional sync between local workspace and production. Regenerates platform files, pushes local files/databases to production, and pulls remote manifests. Run after updating the CLI, changing `mug.json`, or adding files to `files/` or `databases/`. Warns if the CLI is outdated.\n\n```bash\nmug sync\n```\n\n`mug sync` updates platform files only — your code in `connectors/`, `workflows/`, `agents/` is safe. Framework types come from the `@mugwork/mug` package.\n\nUpdates (down — production to local):\n- **Scaffolding** — creates missing `files/` and `databases/` directories with `.remote` manifests\n- **Instruction files** — CLAUDE.md, AGENTS.md, .cursor/rules/mug.mdc (regenerates the `mug:start`/`mug:end` block)\n- **Skills** — .claude/skills/, .agents/skills/, .cursor/rules/\n- **Docs** — .mug/docs/ (platform reference documentation)\n- **Remote manifests** — fetches production state into `files/.remote` and `databases/.remote`\n\nUploads (up — local to production):\n- **Local files** — new or changed files in `files/` are uploaded to R2\n- **Local databases** — new or changed `.db` files in `databases/` are pushed to production DOs\n\nOutput: `Synced 2 instruction files, 6 skills, 4 docs`\n\n### mug pull\n\nDownload remote files or databases to the local workspace.\n\n```bash\nmug pull files/templates/invoice.html # download a specific file\nmug pull databases/crm # download a database as .db\nmug pull --all # download everything remote\n```\n\nFiles are written to `files/` and databases to `databases/`. The `.remote` manifest is updated after each pull. Existing local databases are backed up before overwrite.\n\n### mug push\n\nUpload local files or databases to production.\n\n```bash\nmug push databases/crm # upload a local database to production\nmug push files/templates/invoice.html # upload a specific file\nmug push --all # upload all local files and databases\n```\n\nReads from `databases/*.db` and `files/`, uploads to production DOs and R2. The `.remote` manifest is updated after each push.\n\n### mug login\n\nAuthenticate with the Mug platform via email verification.\n\n```bash\nmug login\n```\n\nPrompts for email, sends a 6-digit verification code, and stores the session token in `~/.mug/credentials`. Creates a new account on first use.\n\n### mug whoami\n\nShow account info and workspace membership.\n\n```bash\nmug whoami\n```\n\nDisplays: account email, current workspace (name, ID, plan tier, role) if in a workspace directory, and all workspaces the account has access to.\n\n## Development\n\n### mug dev\n\nStart the local development server.\n\n```bash\nmug dev # auto-detects ports from 8787\nmug dev --port 9000 # pin to a specific port\nmug dev --tunnel # expose via Cloudflare Quick Tunnel (requires cloudflared)\n```\n\nStarts a Wrangler dev server (default port 8787). Provides:\n- Local workflow execution with hot reload\n- Source sync endpoints (`POST /sync/<source-name>`)\n- Surface rendering (forms, portals, home screen)\n- Workspace explorer at `/explorer`\n- Workflow test runner at `/_dev/run/<workflow-name>`\n- Local Durable Object for SQLite databases\n\n### mug shutdown\n\nGracefully stop the running dev server. Writes back databases from Durable Objects to `databases/*.db` files.\n\n```bash\nmug shutdown\n```\n\n### mug webhooks\n\nList all webhook URLs, inbound message channels, and event triggers for this workspace.\n\n```bash\nmug webhooks\n```\n\nShows the production URLs for webhook-triggered workflows, inbound SMS/email/Slack endpoints, and data change triggers. Useful after `mug deploy` to see what URLs to configure in external systems.\n\n### mug run \\<workflow\\>\n\nExecute a workflow.\n\n```bash\nmug run invoice-followup # run locally\nmug run invoice-followup --production # run in production (Cloudflare Workflows)\n```\n\nLocal runs execute synchronously and print step-by-step output. Production runs create a Cloudflare Workflow instance and return an instance ID.\n\n### mug status \\<workflow\\> \\<instanceId\\>\n\nCheck the status of a production workflow instance.\n\n```bash\nmug status invoice-followup inv-followup-1234567890-abc123\n```\n\n### mug logs [workflow]\n\nView workflow execution history. Automatically tries the local dev server first; if no dev server is running, fetches production logs.\n\n```bash\nmug logs # all recent runs (dev or production)\nmug logs invoice-followup # runs of a specific workflow\nmug logs --production # force production logs (skip dev server check)\nmug logs invoice-followup --limit 20 # more entries\nmug logs --json # JSON output for scripting\n```\n\nShows per-run summary: status, duration, step count, errors. Includes per-step details with timing, input/output, and token usage. Production logs are stored per-workspace and retain the last 1000 runs.\n\n### mug sql \\<database\\> \\<sql\\> (alias: mug query)\n\nRun SQL against a workspace database. Reads and writes `databases/<database>.db` directly — no dev server needed.\n\n```bash\nmug sql hubspot \"SELECT count(*) FROM contacts\"\nmug sql quickbooks \"SELECT * FROM invoices WHERE status = 'overdue' LIMIT 10\"\nmug sql narvick \"INSERT INTO time_off_requests (employee, type, hours) VALUES ('jane', 'pto', 8)\"\nmug sql main \"CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, email TEXT)\"\n```\n\nSupports both reads and writes. Writes auto-create the database file if it doesn't exist. The `databases/` directory is the local source of truth — `mug push` uploads to production, `mug pull` downloads from production.\n\nFlags:\n- `--json` — JSON output\n- `--production` — run against production database\n- `--dev` — route through dev server instead of local file (for debugging)\n\n### mug usage\n\nShow workspace usage across all 6 billing dimensions: operations, database records, file storage, email sends, SMS sends, AI credits. Displays tier, limits, progress bars, and overage pack status. Includes AI model breakdown from Analytics Engine.\n\n```bash\nmug usage # current period usage with progress bars\nmug usage --period 2026-04 # view a specific billing period\nmug usage --json # structured JSON output\n```\n\n### mug buy-pack\n\nPurchase a 25% overage pack for the current billing period. Adds 25% more of every usage dimension. Not available on the free tier.\n\n```bash\nmug buy-pack\n```\n\n### mug billing\n\nView or update workspace billing settings. Controls auto-purchase behavior for overage packs.\n\n```bash\nmug billing # show current billing settings\nmug billing --auto-packs on # auto-buy packs when hitting 100%\nmug billing --max-packs 5 # cap auto-purchases at 5/month (0 = unlimited)\nmug billing --email billing@co.com # set billing notification email\n```\n\n## Connectors\n\n### mug connector discover \\<product\\>\n\nRecord API availability for a product. First step of the connector pipeline.\n\n```bash\nmug connector discover \"HubSpot\" \\\n --tier 1 \\\n --has-api \\\n --api-type rest \\\n --docs-url \"https://developers.hubspot.com/docs/api\" \\\n --spec-url \"https://api.hubspot.com/api-catalog-public/v1/apis\" \\\n --auth-type oauth2 \\\n --zapier --make \\\n --notes \"V3 API is current, V1 deprecated\"\n```\n\nFlags:\n- `--tier <1|2|3>` — 1: has OpenAPI spec, 2: has docs, 3: no docs\n- `--has-api` / `--no-api` — whether an API exists\n- `--api-type <type>` — rest, graphql, soap, etc.\n- `--docs-url <url>` — developer portal URL\n- `--spec-url <url>` — OpenAPI spec URL\n- `--auth-type <type>` — bearer, api-key, oauth2, basic\n- `--zapier`, `--make`, `--n8n` — integration platform availability\n- `--notes <text>` — additional notes\n\n### mug connector gather\n\nProduce an OpenAPI spec from a connector.\n\n```bash\nmug connector gather --slug hubspot --from-spec \"https://api.hubspot.com/spec.json\"\nmug connector gather --slug custom-api --from-file ./my-spec.yaml\nmug connector gather --slug webapp --from-har ./traffic.har\n```\n\nInputs:\n- `--from-spec <url>` — download and normalize an existing OpenAPI spec\n- `--from-file <path>` — read a local OpenAPI spec file\n- `--from-har <path>` — extract API endpoints from a HAR file\n\n### mug connector verify\n\nRun 7-probe verification against the live API. Enriches the spec with `x-mug-*` annotations for pagination, rate limits, and sync config.\n\n```bash\nmug connector verify --slug hubspot --source hubspot\n```\n\nRequires a source configured in `mug.json` with valid credentials.\n\n### mug connector scaffold\n\nGenerate a TypeScript source file from the enriched spec.\n\n```bash\nmug connector scaffold --slug hubspot\n```\n\nCreates `connectors/<slug>.ts` with table definitions, pagination config, and sync settings derived from the verified spec.\n\n### mug connector init \\<product\\>\n\nFull pipeline: discover, gather, verify, scaffold in one command.\n\n```bash\nmug connector init hubspot\n```\n\nInteractive — prompts for research data, credentials, and spec source.\n\n### mug connector search \\<query\\>\n\nSearch the community connector catalog for pre-built connector specs.\n\n```bash\nmug connector search \"hubspot\"\nmug connector search \"crm\" --json\n```\n\nReturns matching connectors with endpoint count, quality level, and auth type.\n\n### mug connector pull\n\nDownload a connector spec from the community catalog into your workspace.\n\n```bash\nmug connector pull --slug hubspot\n```\n\nSaves to `connectors/.specs/<slug>/`. From there, run `mug connector verify` or `mug connector scaffold`.\n\n## Issues\n\n### mug issue\n\nFile a bug report or feature request on GitHub (github.com/mugwork/mug).\n\n```bash\nmug issue # interactive interview — prompts for category, description, steps\nmug issue --dry-run # print issue body without submitting\n```\n\nWalks through a structured interview: category, what you were doing, what happened, what you expected, steps to reproduce, and relevant files. Auto-collects workspace context (CLI version, source/workflow/agent counts). Submits to GitHub if `MUG_GITHUB_TOKEN` or `GITHUB_TOKEN` is set; otherwise prints the issue body for manual copy.\n\n## Forms\n\n### mug form init \\<name\\>\n\nScaffold a form and its handler workflow.\n\n```bash\nmug form init service-request\n```\n\nCreates:\n- `workflows/<name>.ts` — workflow that creates the form via `ctx.collect()`\n- `workflows/handle-<name>.ts` — handler workflow for submissions\n\n### mug form validate [name]\n\nValidate form schemas for errors.\n\n```bash\nmug form validate # validate all forms\nmug form validate service-request # validate specific form\n```\n\nChecks: field types, required options for select fields, condition references, page IDs, access mode config.\n\n### mug form list\n\nList all forms in the workspace with their URLs.\n\n```bash\nmug form list\n```\n\n## Secrets\n\n### mug secret set \\<KEY=VALUE\\>\n\nStore a secret in `.mug/secrets`.\n\n```bash\nmug secret set AIRTABLE_API_KEY=pat_xxxxx\nmug secret set MUG_API_KEY=mug_xxxxx\nmug secret set WEBHOOK_SECRET=whsec_xxxxx --production # sync to production immediately\n```\n\nSecrets are:\n- Loaded automatically by `mug dev`\n- Sent to production by `mug deploy`\n- Never stored in `mug.json`\n\nFlags:\n- `--production` — sync the secret to production via the dispatch endpoint\n\n### mug secret list\n\nList stored secret keys (not values). Shows \"Local secrets (.mug/secrets):\" followed by indented key names. Prints a hint to use `mug secret set` if no secrets are configured.\n\n```bash\nmug secret list\n# Output:\n# Local secrets (.mug/secrets):\n# AIRTABLE_API_KEY\n# TWILIO_AUTH_TOKEN\n```\n\n### mug secret remove \\<KEY\\>\n\nRemove a secret from `.mug/secrets`.\n\n```bash\nmug secret remove AIRTABLE_API_KEY\n```\n\n## Auth\n\n### mug auth \\<provider\\>\n\nConnect a provider via OAuth. Supported providers depend on platform configuration.\n\n```bash\nmug auth airtable\n```\n\nOpens a browser for OAuth flow. Stores the credential in `.mug/secrets` on completion.\n\n## Demo Mode\n\nDemo mode lets you share deployed auth'd surfaces with stakeholders without requiring them to verify. The surface renders pre-authenticated as a specific identity.\n\n### mug demo enable \\<surface\\> --as \\<identity\\>\n\nEnable demo mode on a deployed surface.\n\n```bash\nmug demo enable employee-portal --as demo@example.com\nmug demo enable time-off-form --as jane@acme.com --expires 30d\nmug demo enable employee-portal --as demo@example.com --sms-to +15551234567\nmug demo enable employee-portal --as demo@example.com --no-workflows\nmug demo enable employee-portal --as demo@example.com --notify off\n```\n\nOptions:\n- `--as <identity>` — (required) email or phone to authenticate as\n- `--expires <duration>` — expiry duration (default: `7d`). Accepts `Nd` (days) or `Nh` (hours).\n- `--notify <mode>` — notification routing mode (default: `demo-user`):\n - `demo-user` — redirect all notifications to the demo identity (`--as`). Channels that don't match the identity type (e.g. SMS when identity is email) are suppressed unless overridden.\n - `dev` — redirect all notifications to the developer who ran the command. Email goes to your logged-in account email. SMS/Slack suppressed unless overridden.\n - `off` — suppress all notifications (workflow still runs, notifications logged but not sent).\n- `--email-to <address>` — override: redirect email notifications to this address.\n- `--sms-to <phone>` — override: redirect SMS notifications to this phone number.\n- `--slack-to <channel>` — override: redirect Slack notifications to this channel/user.\n- `--no-workflows` — suppress workflow execution entirely on surface submissions. The surface still renders and accepts input, but no workflow fires.\n\nPer-channel overrides take precedence over the `--notify` mode. Each surface can demo as a different identity. After expiry, the surface silently reverts to requiring auth.\n\n### mug demo disable \\<surface\\>\n\nImmediately disable demo mode on a surface.\n\n```bash\nmug demo disable employee-portal\n```\n\n### mug demo status\n\nShow all active demos for this workspace.\n\n```bash\nmug demo status\n```\n\n### Demo notifications and workflows\n\nNotifications are **automatically routed** during demo mode — no manual guards needed. The `--notify` mode and per-channel overrides control where notifications go:\n\n```bash\n# Default: emails go to demo identity, SMS suppressed (identity is email)\nmug demo enable employee-portal --as demo@example.com\n\n# Redirect SMS to your phone for live demo\nmug demo enable employee-portal --as demo@example.com --sms-to +15551234567\n\n# Suppress all notifications, workflows still run\nmug demo enable employee-portal --as demo@example.com --notify off\n\n# Show the form only, no workflows fire on submit\nmug demo enable employee-portal --as demo@example.com --no-workflows\n```\n\nSuppressed notifications are still logged in `mug logs` — you'll see \"suppressed (demo mode: demo-user)\" in the step output so you can verify the workflow would have fired.\n\n`ctx.isDemo` is still available for guarding non-notification side effects (destructive writes, external API calls):\n\n```typescript\nexport default workflow(\"handle-request\", async (ctx) => {\n // Notifications auto-routed by demo config — no guard needed\n await ctx.notify.sms({ to: params.manager_phone, message: \"New request\" });\n\n // Guard other side effects manually\n if (ctx.isDemo) return;\n await ctx.exec(\"operations\", \"UPDATE jobs SET status = 'approved' WHERE id = ?\", [params.job_id]);\n});\n```\n\nCreate a demo persona (e.g. `demo@example.com`) in your auth table with curated demo data. The demo viewer sees exactly what that user would see.\n\n## Workspace Management\n\n### mug create workspace \\<name\\>\n\nRegister a new workspace on the Mug platform.\n\n```bash\nmug create workspace \"Acme Inc\" # free tier, auto-generates subdomain \"acme-inc\"\nmug create workspace \"Acme Inc\" --subdomain acme # custom subdomain\nmug create workspace \"Acme Inc\" --tier starter # paid tier (opens Stripe Checkout)\n```\n\nOptions: `--subdomain <slug>` (auto-generated from name if omitted), `--tier <tier>` (free, starter, pro, business — default free). Updates `mug.json` with workspace ID and subdomain.\n\n### mug workspace status\n\nShow workspace metadata: name, ID, URL, custom domain, plan tier, role, version, last deploy.\n\n### mug workspace plan\n\nView current plan and change tier. Shows all tiers with prices, confirms selection. Calls Stripe Checkout for free→paid or paid→paid changes. Downgrade to free cancels subscription.\n\n### mug workspace invite \\<email\\>\n\nSend an admin invite to the workspace. Recipient gets an email and can accept via `mug account accept <id>`.\n\n### mug workspace transfer \\<email\\>\n\nTransfer workspace ownership. Sends an invite with owner role — ownership transfers when the recipient accepts. Requires confirmation.\n\n### mug workspace remove \\<email\\>\n\nRemove a member from the workspace.\n\n### mug workspace members\n\nList all members with role and join date. Also shows pending sent invites with invite IDs.\n\n### mug workspace cancel-invite \\<id\\>\n\nCancel a pending invite you sent. Get invite IDs from `mug workspace members` or `mug account invites`.\n\n### mug workspace check-subdomain \\<subdomain\\>\n\nCheck if a subdomain is available. Validates format (3-63 chars, lowercase alphanumeric + hyphens) and checks against reserved/taken subdomains.\n\n### mug workspace archive\n\nArchive workspace (365-day retention, restorable). Requires typing workspace name to confirm.\n\n### mug workspace restore\n\nRestore an archived workspace. Opens Stripe Checkout if the workspace was on a paid tier.\n\n### mug workspace delete\n\nPermanently delete an archived workspace. Workspace must be archived first. Requires typing workspace name to confirm. **Cannot be undone.**\n\n### mug workspace export\n\nExport workspace data as a `.tar` archive.\n\n```bash\nmug workspace export # show categories with file counts and sizes\nmug workspace export --all # download all categories\nmug workspace export --categories config,code # selective download\n```\n\n### mug account invites\n\nShow pending incoming invites (workspace name, role, inviter) and sent pending invites (workspace name, email, role).\n\n### mug account accept \\<id\\>\n\nAccept a workspace invite. Use `mug account invites` to see pending invite IDs.\n\n### mug account decline \\<id\\>\n\nDecline a workspace invite.\n\n### mug account email \\<new-email\\>\n\nChange account email. Sends verification codes to both your current email and the new email — you must enter both codes to confirm the change.\n\n## Agent Brain\n\n### mug brain \\<agent\\>\n\nInspect an agent's brain memory. Shows entity count, facts, outcomes, unresolved struggles, and session history.\n\n```bash\nmug brain dispatch-bot # overview with counts + recent struggles\nmug brain dispatch-bot struggles # unresolved struggles grouped by category\nmug brain dispatch-bot entities # all entities sorted by mention count\nmug brain dispatch-bot outcomes # recent outcomes with success rate\nmug brain dispatch-bot sessions # session history across workflows\nmug brain dispatch-bot search <q> # search the brain by keyword\n```\n\nReads the local `agents/<name>/brain.db`. Run `mug pull` to download runtime brain data from production.\n\n## Deployment\n\n### mug deploy\n\nBundle workspace code and deploy to Cloudflare Workers.\n\n```bash\nmug deploy\n```\n\nRequires `MUG_API_KEY` in `.mug/secrets`. Bundles TypeScript, validates, creates/updates the Worker with correct bindings, and uploads secrets.\n",
|
|
42
|
+
"notifications.md": "# Notifications — Full API Reference\n\nSend email and SMS notifications from workflows. Mug handles delivery via Resend (email) and Twilio (SMS), renders styled HTML email templates, and generates correct surface URLs for dev and production.\n\nFor a guided walkthrough, use the `/notify` skill. For full `ctx.notify.*` method signatures and error behavior, see [api.md — notifications](api.md#ctxnotifyemailoptions).\n\n## ctx.notify.email(options)\n\nSend a styled HTML email via Mug's notification service. For the full method signature, return type, and error behavior, see [api.md — ctx.notify.email](api.md#ctxnotifyemailoptions).\n\n```typescript\nawait ctx.notify.email({\n to: \"manager@company.com\",\n subject: `New request from ${name}`,\n message: `**${name}** submitted a request.\\n\\n- Type: ${type}\\n- Urgency: ${urgency}`,\n fromName: \"Acme Operations\",\n cta: { label: \"Review Request\", url: ctx.surfaceUrl(\"approvals\") },\n});\n```\n\n### Email template\n\nEmails are rendered as responsive HTML with:\n- Clean white layout\n- Sender name in the header\n- Message body with markdown formatting\n- Optional CTA button (centered, styled with workspace accent color)\n- \"Powered by Mug\" footer (replaced with workspace branding at Pro+ tiers)\n\nThe `text` version (for plain-text email clients) is the raw `message` string.\n\n### Markdown support in message body\n\n| Syntax | Renders as |\n|--------|-----------|\n| `**bold**` | **bold** |\n| `*italic*` | *italic* |\n| `- item` | bullet list |\n| `1. item` | numbered list |\n| `[text](url)` | clickable link |\n| Blank line | paragraph break |\n\n**Not supported:** headers (`#`), code blocks, tables, images. Keep email content simple — use the CTA button for primary actions.\n\n### Loading templates from files\n\nFor complex email bodies, store templates in `files/` and load them at runtime:\n\n```typescript\nconst template = await ctx.fileText(\"templates/weekly-report.html\");\nconst body = template\n .replace(\"{{name}}\", customer.name)\n .replace(\"{{total}}\", formatCurrency(total));\nawait ctx.notify.email({ to: customer.email, message: body, subject: \"Weekly Report\" });\n```\n\nDrop template files in `files/templates/` and run `mug sync` to upload them to production. See [api.md](api.md) for the full `ctx.file()` / `ctx.fileText()` reference.\n\n### Examples\n\nSimple notification:\n```typescript\nawait ctx.notify.email({\n to: \"user@example.com\",\n subject: \"Payment received\",\n message: \"We received your payment of **$250.00**. Thank you!\",\n});\n```\n\nWith CTA button:\n```typescript\nawait ctx.notify.email({\n to: \"manager@company.com\",\n subject: `New request from ${employeeName}`,\n message: `**${employeeName}** submitted a time-off request for ${startDate} to ${endDate}.`,\n cta: {\n label: \"Review Request\",\n url: ctx.surfaceUrl(\"approvals\"),\n },\n});\n```\n\nCustom sender name:\n```typescript\nawait ctx.notify.email({\n to: \"client@example.com\",\n subject: \"Your weekly report\",\n message: reportMarkdown,\n fromName: \"Acme Operations\",\n});\n```\n\n## ctx.notify.sms(options)\n\nSend an SMS message via Twilio.\n\n```typescript\nawait ctx.notify.sms({\n to: string; // phone number in E.164 format (+1234567890)\n message: string; // plain text message body\n});\n```\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `to` | string | yes | Phone number in E.164 format (e.g., `+1234567890`) |\n| `message` | string | yes | Plain text message. No markdown — SMS is plain text only |\n\nSMS is best for urgent, time-sensitive notifications. Keep messages under 160 characters when possible (longer messages are split into segments and cost more).\n\n```typescript\nawait ctx.notify.sms({\n to: \"+1234567890\",\n message: `Job assigned: ${job.address}. ETA: ${job.eta}. Reply ACCEPT or DECLINE.`,\n});\n```\n\n## ctx.notify.slack(options)\n\nSend a Slack message (requires Slack integration configured).\n\n```typescript\nawait ctx.notify.slack({\n to: string; // channel name or user ID\n message: string; // message body\n});\n```\n\n## ctx.surfaceUrl(surfaceId, path?)\n\nGenerate a URL to a workspace surface. See [api.md — ctx.surfaceUrl](api.md#ctxsurfaceurlsurfaceid-path) for the full signature. Always use this instead of hardcoding URLs in notification messages — it handles dev/prod automatically.\n\n```typescript\nctx.surfaceUrl(\"approvals\") // → localhost:8787/approvals (dev) or workspace.mug.work/approvals (prod)\nctx.surfaceUrl(\"portal\", `/row/${id}`) // → .../portal/row/42\n```\n\n## Local dev behavior\n\nIn local dev (`mug dev`), notifications send via real delivery services:\n\n- **Email**: sends via Resend using the `RESEND_API_KEY` from `.mug/secrets`. Real emails arrive in the recipient's inbox.\n- **SMS**: sends via Twilio using configured credentials from `.mug/secrets`.\n\n### Dev email redirect\n\n`mug init`, `mug create`, `mug sync`, and `mug dev` auto-set `dev.email` in `mug.json` to the logged-in user's email. All dev emails redirect to this address instead of real recipients.\n\n```json\n{\n \"dev\": {\n \"email\": \"developer@example.com\"\n }\n}\n```\n\nWhen set, **all email notifications redirect to this address**. The subject line is prefixed with the original recipient so you can see who would have received it: `[DEV → manager@company.com] New request from John Smith`. Changes to `mug.json` hot-reload — no restart needed.\n\nEvery notification is also logged to the console:\n```\n[email] Redirecting: manager@company.com → developer@example.com\n```\n\nIf `RESEND_API_KEY` is not set, emails log to console only (no delivery).\n\n## Notification metering\n\nMug-managed sends count against your plan's notification limits:\n\n| Tier | Email/month | SMS/month |\n|------|-------------|-----------|\n| Free | 100 | 50 |\n| Starter ($99) | 1,500 | 500 |\n| Pro ($299) | 5,000 | 1,000 |\n| Business ($599) | 15,000 | 2,500 |\n\nBYOK sends (using your own Resend/Twilio keys) bypass metering entirely.\n\n## BYOK — bring your own keys\n\nUse your own Resend or Twilio account for unlimited sends, custom sending domains, and your own deliverability reputation.\n\n```bash\nmug secret set RESEND_API_KEY=re_xxxxx\nmug secret set TWILIO_ACCOUNT_SID=AC_xxxxx\nmug secret set TWILIO_AUTH_TOKEN=xxxxx\nmug secret set TWILIO_PHONE_NUMBER=+1xxxxx\n```\n\nWhen BYOK keys are set, notifications route through your account instead of Mug's. Your keys, your bill, unlimited volume.\n\n## Branding\n\nEmail notifications automatically pick up workspace branding from `mug.json`:\n\n```json\n{\n \"branding\": {\n \"logo\": \"assets/logo.png\",\n \"logoSquare\": \"assets/icon.png\",\n \"accentColor\": \"#1a5276\"\n }\n}\n```\n\n- **logo** — displayed in the email header (max 40px height, 200px width). Replaces the default text-based workspace name.\n- **accentColor** — applied to CTA button backgrounds and the footer separator line.\n- When a logo is set, the email footer shows the workspace display name instead of \"Powered by Mug\".\n\nNo code changes needed — `ctx.notify.email()` reads branding from the workspace environment automatically. In dev, the dev proxy injects branding from `mug.json`. In production, branding is set via the `MUG_BRANDING` environment variable at deploy time.\n\n## CLI commands\n\n```bash\nmug secret set RESEND_API_KEY=re_xxxxx # configure email delivery\nmug secret set TWILIO_ACCOUNT_SID=AC_xxxxx # configure SMS delivery\nmug secret list # verify keys are set\nmug dev # test notifications locally\nmug run <workflow> # trigger workflow to send\nmug logs <workflow> # see notification step results\n```\n\n## Complete example\n\nFull approval notification flow — form submission notifies manager, approval notifies employee:\n\n```typescript\n// workflows/handle-request.ts — triggered by form submission\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"handle-request\", async (ctx) => {\n const p = ctx.params as Record<string, string>;\n\n // Insert the request\n await ctx.exec(\"main\", `INSERT INTO time_off_requests\n (id, employee_name, employee_email, type, start_date, end_date, hours, reason, approver_email, status, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'))`,\n [crypto.randomUUID(), p.employee_name, p.employee_email, p.type,\n p.start_date, p.end_date, p.hours, p.reason, \"manager@company.com\"]);\n\n // Email the manager with a link to the approval inbox\n await ctx.notify.email({\n to: \"manager@company.com\",\n subject: `Time-off request from ${p.employee_name}`,\n message: `**${p.employee_name}** is requesting **${p.type}** from ${p.start_date} to ${p.end_date} (${p.hours} hours).\\n\\nReason: ${p.reason}`,\n cta: { label: \"Review Request\", url: ctx.surfaceUrl(\"approvals\") },\n });\n\n return { status: \"pending\", notified: \"manager@company.com\" };\n});\n\n// workflows/handle-approval.ts — triggered by portal action button\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"handle-approval\", async (ctx) => {\n const p = ctx.params as Record<string, string>;\n const status = p.action === \"approve\" ? \"approved\" : \"denied\";\n\n // Update the request\n await ctx.exec(\"main\",\n \"UPDATE time_off_requests SET status = ?, reviewed_at = datetime('now') WHERE id = ?\",\n [status, p.id]);\n\n // Notify the employee with a link to their portal\n await ctx.notify.email({\n to: p.employee_email,\n subject: `Your time-off request was ${status}`,\n message: `Your **${p.type}** request for ${p.start_date} to ${p.end_date} has been **${status}**.`,\n cta: { label: \"View My Requests\", url: ctx.surfaceUrl(\"portal\") },\n });\n\n return { id: p.id, status, notified: p.employee_email };\n});\n```\n\nFor form creation (the request form that triggers notifications), see the `/form` skill.\nFor portals (the approval inbox and employee portal), see the `/portal` skill.\nFor workflow logic (AI classification, multi-source queries), see the `/workflow` skill.\n",
|
|
43
|
+
"ai.md": "# AI — Full API Reference\n\nMug provides AI capabilities via `ctx.ai()` in workflows. Smart routing automatically picks the best model for each task. Multi-provider support routes to OpenAI, Anthropic, Workers AI, or any Cloudflare AI Gateway provider. BYOK (Bring Your Own Key) lets you use your own API keys for unlimited AI at zero Mug credit cost.\n\nFor a guided walkthrough, use the `/ai` skill. For the full WorkspaceContext API, see [api.md](api.md).\n\n## ctx.ai(model, options)\n\n```typescript\nasync ai(\n model: string,\n options: {\n prompt: string;\n system?: string;\n maxTokens?: number; // default: 1024\n routing?: RoutingConfig; // per-call model overrides\n billing?: string; // \"mug-metered\" or BYOK key name\n }\n): Promise<{\n text: string;\n model: string;\n usage: { input_tokens: number; output_tokens: number };\n routing?: {\n tier: \"fast\" | \"balanced\" | \"powerful\";\n model: string;\n provider: string;\n reason: string;\n };\n}>\n```\n\n### Model parameter\n\nThe `model` parameter accepts four formats:\n\n| Format | Example | Behavior |\n|--------|---------|----------|\n| Tier name | `ctx.ai(\"fast\", { prompt, system })` | Uses workspace's configured model for that tier — **recommended** |\n| `\"auto\"` | `ctx.ai(\"auto\", { prompt, system })` | Mug picks the tier based on prompt complexity |\n| `\"provider/model\"` | `ctx.ai(\"openai/gpt-5.4-nano\", { prompt, system })` | Direct — calls a specific provider and model |\n| Legacy alias | `ctx.ai(\"sonnet\", { prompt, system })` | Backwards-compatible — resolves to `anthropic/claude-sonnet-4-6` |\n\n**Use tier names directly** — pick `\"fast\"` for classification/extraction, `\"balanced\"` for summarization/analysis, `\"powerful\"` for complex reasoning. `\"auto\"` routing uses prompt heuristics and is less predictable than choosing the tier yourself. The tier resolves to your workspace's configured model in `mug.json` `ai.routing`.\n\n**You must set `prompt` and `system`.** `prompt` is the user message (the data/question). `system` is the system prompt (instructions for how to respond). These are passed directly to the model — you have full control.\n\n## Smart routing\n\nWhen `model` is `\"auto\"`, Mug scores the request using three deterministic signals and routes to one of three tiers. Zero overhead — scoring runs in <1ms with no API calls.\n\n### Tiers\n\n| Tier | Default model | Pricing (per 1M tokens) | Use case |\n|------|---------------|------------------------|----------|\n| **fast** | openai/gpt-5.4-nano | $0.20 in / $1.25 out | Classification, extraction, formatting, simple decisions |\n| **balanced** | @cf/moonshotai/kimi-k2.6 | Workers AI neurons | Summarization, analysis, general-purpose, multi-step reasoning |\n| **powerful** | anthropic/claude-sonnet-4-6 | $3.00 in / $15.00 out | Complex reasoning, nuanced judgment, code generation |\n\n### Scoring signals\n\n1. **Token count** — estimated from prompt + system prompt length. Longer inputs → higher tier.\n2. **Keyword markers** — detects reasoning words (\"analyze\", \"step by step\", \"compare\") and code patterns (triple backticks, function/class definitions). Code → minimum balanced.\n3. **maxTokens** — constrained output (≤50) → fast bias. Long output (>2000) → powerful bias.\n\n### Routing response\n\nWhen using `\"auto\"`, the response includes a `routing` field:\n\n```typescript\nconst result = await ctx.ai(\"auto\", {\n prompt: \"Classify this ticket as billing, technical, or general\",\n system: \"Reply with exactly one word: billing, technical, or general.\",\n maxTokens: 10,\n});\n// result.routing = { tier: \"fast\", model: \"gpt-5.4-nano\", provider: \"openai\", reason: \"short-prompt,low-maxTokens\" }\n```\n\n### Per-call routing overrides\n\nOverride which models the tiers map to for a specific call:\n\n```typescript\nawait ctx.ai(\"balanced\", {\n prompt,\n system,\n routing: {\n fast: \"anthropic/claude-haiku-4-5\",\n balanced: \"anthropic/claude-sonnet-4-6\",\n powerful: \"anthropic/claude-opus-4-7\",\n },\n});\n```\n\n## Multi-provider\n\nEvery tier can map to any AI Gateway provider. Use `provider/model` format:\n\n- `\"openai/gpt-5.4-nano\"` — OpenAI\n- `\"anthropic/claude-sonnet-4-6\"` — Anthropic\n- `\"@cf/moonshotai/kimi-k2.6\"` — Workers AI (runs on Cloudflare, cheapest)\n- `\"google/gemini-3-flash\"` — Google AI Studio\n\n### Workspace defaults (mug.json)\n\nConfigure which models your workspace uses for each tier:\n\n```json\n{\n \"ai\": {\n \"routing\": {\n \"fast\": \"openai/gpt-5.4-nano\",\n \"balanced\": \"@cf/moonshotai/kimi-k2.6\",\n \"powerful\": \"anthropic/claude-sonnet-4-6\"\n }\n }\n}\n```\n\nPrecedence: **per-call `routing` > mug.json `ai.routing` > platform defaults**\n\n## Billing\n\n### mug-metered (default)\n\nBy default, all AI calls are billed through Mug's credits via Cloudflare Unified Billing. One Cloudflare invoice, no provider API keys needed. Credits are deducted from your workspace plan allocation.\n\n### BYOK (Bring Your Own Key)\n\nStore your own provider API key and use it for specific tiers. Zero Mug credit consumption — unlimited AI on your own dime.\n\n**Setup:**\n\n```bash\n# 1. Store your key\nmug secret set ai.anthropic=sk-ant-xxx\n\n# 2. Configure billing in mug.json\n```\n\n```json\n{\n \"ai\": {\n \"billing\": {\n \"default\": \"mug-metered\",\n \"fast\": \"mug-metered\",\n \"balanced\": \"mug-metered\",\n \"powerful\": \"ai.anthropic\"\n }\n }\n}\n```\n\nThis routes the powerful tier through your own Anthropic key while fast and balanced stay on Mug credits.\n\n### Per-call and per-workflow billing\n\nOverride billing at the call level or workflow level:\n\n```typescript\n// Per-call: use a specific key for this step\nawait ctx.ai(\"powerful\", {\n prompt,\n system,\n billing: \"ai.anthropic\",\n});\n\n// Per-call: force mug-metered even if workspace has BYOK\nawait ctx.ai(\"fast\", {\n prompt,\n system,\n billing: \"mug-metered\",\n});\n```\n\n```typescript\n// Per-workflow: all AI calls in this workflow use this key\nworkflow(\"sensitive-analysis\", async (ctx) => {\n await ctx.ai(\"balanced\", { prompt: \"...\", system: \"...\" });\n await ctx.ai(\"powerful\", { prompt: \"...\", system: \"...\" });\n}, { billing: \"ai.anthropic\" });\n```\n\nPrecedence: **per-call `billing` > per-workflow `billing` > mug.json `ai.billing[tier]` > mug.json `ai.billing.default` > `\"mug-metered\"`**\n\n## Architecture\n\n```\nctx.ai(\"fast\", { prompt, system })\n ↓\nWorker: resolve tier → fast\nWorker: resolveModel → openai/gpt-5.4-nano\nWorker: resolveBilling → \"mug-metered\"\n ↓\nAI Service (Cloudflare Worker)\n ↓\nAI Gateway (compat endpoint)\n → Unified Billing (CF_API_TOKEN) or BYOK (Secrets Store key)\n → Per-workspace Dynamic Route: budget limits, rate limits, fallback chains\n → Provider dispatch (OpenAI / Anthropic / Workers AI / etc.)\n ↓\nResponse: { text, model, usage, routing }\n```\n\n- **Unified Billing** — Cloudflare passes through provider pricing at cost. No markup. Mug loads credits into CF, spends across all providers with one invoice.\n- **Per-workspace Dynamic Routes** — each workspace gets `dynamic/ws-{workspace}` with budget limits (plan-tiered), rate limits, and fallback chains (fast → balanced → powerful).\n- **Analytics Engine** — every call logged with workspace, provider, model, tier, billing type, and token counts.\n\n## Error handling\n\n- **Invalid model**: throws if model name doesn't match any provider format or legacy alias.\n- **Rate limits**: AI Gateway retries automatically (2 retries, exponential backoff). If still rate-limited, throws with the provider's error.\n- **BYOK key not found**: if the Secrets Store key referenced in billing config doesn't exist, returns an auth error. Fix with `mug secret set <key-name>=<value>`.\n- **Budget exceeded**: if the workspace's Dynamic Route budget limit is reached, the gateway returns an error. Purchase overage packs or upgrade your plan.\n- **Network errors**: transient failures are retried by the gateway. Persistent failures throw.\n\nAll `ctx.ai()` errors can be caught with try/catch:\n\n```typescript\ntry {\n const result = await ctx.ai(\"fast\", { prompt, system });\n} catch (e) {\n console.error(`AI call failed: ${e.message}`);\n // Handle gracefully — skip, retry with different model, etc.\n}\n```\n\n## Cloudflare AI model catalog\n\nMug supports any model available through Cloudflare AI Gateway. Key models by provider:\n\n### Anthropic\n| Model | Input/1M | Output/1M | Notes |\n|-------|----------|-----------|-------|\n| claude-opus-4.7 | $5.00 | $25.00 | Most capable, 1M context |\n| claude-sonnet-4.6 | $3.00 | $15.00 | **Platform default: powerful tier** |\n| claude-haiku-4.5 | $1.00 | $5.00 | Fast, cost-efficient |\n\n### OpenAI\n| Model | Input/1M | Output/1M | Notes |\n|-------|----------|-----------|-------|\n| gpt-5.4-nano | $0.20 | $1.25 | **Platform default: fast tier**. Smallest, fastest |\n| gpt-4.1-mini | $0.40 | $1.60 | Fast, 1M context |\n| gpt-4.1 | $2.00 | $8.00 | Complex tasks, 1M context |\n\n### Workers AI (@cf/)\n| Model | Pricing | Notes |\n|-------|---------|-------|\n| @cf/moonshotai/kimi-k2.6 | Neurons | **Platform default: balanced tier**. 1T params, 262K context |\n| @cf/deepseek-ai/deepseek-r1-distill-qwen-32b | Neurons | Reasoning model |\n| @cf/meta/llama-3.1-8b-instruct-fast | Neurons | Fast, lightweight |\n\n### Google\n| Model | Input/1M | Output/1M | Notes |\n|-------|----------|-----------|-------|\n| gemini-3.1-pro | — | — | Most intelligent, 1M context |\n| gemini-3-flash | $0.50 | $3.00 | Fast, strong grounding |\n| gemini-3.1-flash-lite | — | — | Lightest, most cost-efficient |\n\nFor the complete catalog: [Cloudflare AI Models](https://developers.cloudflare.com/ai/models/)\n\n## CLI commands\n\n```bash\nmug secret set ai.anthropic=<key> # Store BYOK key for Anthropic\nmug secret set ai.openai=<key> # Store BYOK key for OpenAI\nmug secret list # Show configured secrets (including BYOK keys)\nmug secret remove ai.anthropic # Remove a BYOK key\n```\n\n## Complete example\n\n```typescript\nimport { workflow } from \"@mugwork/mug\";\n\nworkflow(\"daily-ticket-triage\", async (ctx) => {\n // Fetch open tickets\n const tickets = await ctx.query(\"helpdesk\", `\n SELECT id, subject, body, customer_email, priority\n FROM tickets WHERE status = 'open'\n `);\n\n for (const ticket of tickets) {\n // Classify — fast tier (cheap, deterministic)\n const category = await ctx.ai(\"fast\", {\n prompt: `Classify this support ticket:\\n\\nSubject: ${ticket.subject}\\n\\n${ticket.body}`,\n system: \"Reply with exactly one word: billing, technical, or general.\",\n maxTokens: 10,\n });\n\n // For complex tickets, generate a detailed response\n if (ticket.priority === \"high\") {\n const response = await ctx.ai(\"balanced\", {\n prompt: `Draft a response to this ${category.text} ticket:\\n\\n${ticket.body}`,\n system: \"Write a helpful, professional response. Be specific and actionable.\",\n });\n\n await ctx.notify.email({\n to: ticket.customer_email as string,\n message: response.text,\n subject: `Re: ${ticket.subject}`,\n });\n }\n\n // Update ticket\n await ctx.exec(\"helpdesk\", \"UPDATE tickets SET category = ?, status = 'triaged' WHERE id = ?\",\n [category.text, ticket.id as number]);\n }\n\n return { triaged: tickets.length };\n}, { billing: \"ai.anthropic\" }); // All AI in this workflow uses BYOK\n```\n\n## Search — Three-Layer Model\n\nMug provides three layers of AI-powered search over synced data. Each layer builds on the previous one — use the simplest layer that solves your problem.\n\n### Layer 1: FTS5 Keyword Search (free, works locally)\n\nEvery synced text column automatically gets a full-text search index. No configuration needed — happens during source sync. Query via standard FTS5 SQL:\n\n```typescript\nconst results = await ctx.query(\"servicetitan\",\n `SELECT j.* FROM jobs j JOIN jobs_fts ON j.rowid = jobs_fts.rowid\n WHERE jobs_fts MATCH ? ORDER BY rank LIMIT 10`,\n [\"leak roof\"]\n);\n```\n\n**When to use:** exact keyword matches, simple text search, local dev, free tier.\n\n### Layer 2: ctx.search() — Semantic Similarity (requires deploy)\n\nNatural language search that understands meaning, not just keywords. \"roof leak complaints\" finds records mentioning \"water damage from overhead\" even if those exact words don't appear.\n\n```typescript\nconst results = await ctx.search(\"customers who complained about water damage\", {\n source: \"jobs\", // optional: scope to one table\n limit: 10, // default 10, max 50\n});\n// Returns: { score, table, primaryKey, row }[]\n```\n\n**When to use:** fuzzy matching, semantic queries, finding related records, pre-filtering for agent workflows.\n\n### Layer 3: ctx.ask() — Full RAG (requires deploy)\n\nOne-call question answering. Searches for relevant data, feeds it to an LLM, returns a grounded natural language answer with source citations.\n\n```typescript\nconst result = await ctx.ask(\"What were the most common complaints last month?\", {\n source: \"jobs\",\n model: \"balanced\",\n system: \"You are an operations analyst for an HVAC company.\",\n});\n// result.answer = \"The most common complaints were...\"\n// result.sources = [{ score, table, primaryKey, row }, ...]\n```\n\n**When to use:** natural language questions, generating summaries, answering user queries in agent workflows, any time you need an answer (not just matching records).\n",
|
|
44
|
+
"demo.md": "# Demo Mode — Full API Reference\n\nShare deployed auth'd surfaces with stakeholders without requiring verification. For a guided walkthrough, use the `/demo` skill.\n\n## How it works\n\n`mug demo enable` creates a pre-authenticated session for a surface. Any visitor to the surface URL sees it as the specified identity — no email/phone verification required. The demo record is stored in Cloudflare KV with automatic expiry.\n\nDemo mode applies to deployed surfaces (on `*.mug.work`) **and the workspace home screen**. Local dev (`mug dev`) has its own notification safety net via `mug.json` dev overrides — the two systems are independent.\n\n## Home screen demo mode\n\nThe workspace home screen (`subdomain.mug.work/`) **requires authentication** — it is not public. Visitors must verify via email/phone before seeing any content. The home screen uses a three-tier auth flow: demo mode check → session cookie check → auth gate.\n\nTo demo the home screen, use `_home` as the surface ID:\n\n```bash\nmug demo enable _home --as demo@example.com\nmug demo disable _home\n```\n\nDemo mode on `_home` bypasses the home screen auth gate. The visitor sees the home screen as the specified identity, with access to the surfaces that identity can reach. **Demo mode on individual surfaces does not carry over to the home screen** — you must enable `_home` separately if you want the home screen itself to be demo-accessible.\n\n## CLI commands\n\n### mug demo enable \\<surface\\>\n\nEnable demo mode on a deployed surface.\n\n```bash\nmug demo enable employee-portal --as demo@example.com\nmug demo enable time-off-form --as jane@acme.com --expires 30d\nmug demo enable employee-portal --as demo@example.com --notify dev --sms-to +15551234567\nmug demo enable employee-portal --as demo@example.com --no-workflows\n```\n\n**Required:**\n- `--as <identity>` — email or phone number to authenticate as. Must exist in the surface's auth table if using `access.mode: \"auth\"`.\n\n**Optional:**\n- `--expires <duration>` — expiry duration (default: `7d`). Accepts `Nd` (days) or `Nh` (hours).\n- `--notify <mode>` — notification routing mode (default: `demo-user`). See Notification Modes below.\n- `--email-to <address>` — override: redirect email notifications to this address.\n- `--sms-to <phone>` — override: redirect SMS notifications to this phone number (E.164 format).\n- `--slack-to <channel>` — override: redirect Slack notifications to this channel or user.\n- `--no-workflows` — suppress workflow execution on surface submissions.\n\n### mug demo disable \\<surface\\>\n\nImmediately disable demo mode on a surface.\n\n```bash\nmug demo disable employee-portal\n```\n\n### mug demo status\n\nShow all active demos for the current workspace.\n\n```bash\nmug demo status\n```\n\n## Notification modes\n\nDemo mode automatically routes notifications — builders do not need `if (ctx.isDemo)` guards for `ctx.notify.*` calls.\n\n### demo-user (default)\n\nAll notifications redirect to the `--as` identity. Channel matching:\n- If identity is an email: `ctx.notify.email()` sends to that email. SMS and Slack are suppressed.\n- If identity is a phone: `ctx.notify.sms()` sends to that phone. Email and Slack are suppressed.\n- Per-channel overrides fill in non-matching channels.\n\n### dev\n\nAll notifications redirect to the developer who ran `mug demo enable`:\n- Email sends to the developer's account email (from `~/.mug/credentials`).\n- SMS and Slack are suppressed unless overridden with `--sms-to` or `--slack-to`.\n\n### off\n\nAll notifications suppressed. Still logged in workflow step output (visible in `mug logs`).\n\n## Per-channel overrides\n\nOverrides take precedence over the notification mode. They work with any `--notify` value.\n\n```bash\n# demo-user mode, but also send SMS to your phone\nmug demo enable portal --as demo@example.com --sms-to +15551234567\n\n# dev mode, but send Slack to a specific channel\nmug demo enable portal --as demo@example.com --notify dev --slack-to #demo-reviews\n\n# off mode, but still send email (for testing email rendering)\nmug demo enable portal --as demo@example.com --notify off --email-to test@example.com\n```\n\n## Workflow suppression\n\n`--no-workflows` prevents any workflow from firing when the demo surface is submitted or a portal action is triggered. The surface still renders, accepts form input, and shows the success state — but no server-side workflow executes.\n\nThis is orthogonal to notification modes. You can combine them:\n\n```bash\n# Show the form UI only — no workflow, no notifications\nmug demo enable time-off-form --as demo@example.com --no-workflows\n\n# Workflows run but notifications are off\nmug demo enable time-off-form --as demo@example.com --notify off\n```\n\nWhen workflows are suppressed, the API returns `{ \"status\": \"ok\", \"demo\": true, \"workflowSkipped\": true }`.\n\n## ctx.isDemo\n\n```typescript\nget isDemo(): boolean\n```\n\n`true` when the workflow was triggered from a surface in demo mode. Since notifications are automatically routed by demo config, `ctx.isDemo` is only needed for guarding non-notification side effects:\n\n```typescript\nworkflow(\"approve-request\", async (ctx) => {\n // Notifications auto-routed — no guard needed\n await ctx.notify.email({\n to: ctx.params.manager_email,\n subject: \"Request approved\",\n message: `${ctx.params.employee_name}'s request was approved.`,\n });\n\n await ctx.notify.sms({\n to: ctx.params.employee_phone,\n message: \"Your time-off request was approved!\",\n });\n\n // Guard destructive writes and external calls\n if (ctx.isDemo) return;\n await ctx.exec(\"main\", \"UPDATE requests SET status = 'approved' WHERE id = ?\", [ctx.params.request_id]);\n});\n```\n\n## Suppressed notification logging\n\nAll notifications — whether redirected or suppressed — are recorded as workflow steps in `mug logs`. Suppressed notifications show the reason:\n\n```\nnotify-email-1 | 2ms | to: manager@company.com → demo@example.com | delivery_ok\nnotify-sms-2 | 0ms | to: +15559876543 | suppressed (demo mode: demo-user)\n```\n\nThis lets you verify the complete workflow path without sending real notifications.\n\n## Demo mode vs local dev\n\n| Aspect | Demo mode (`mug demo enable`) | Local dev (`mug dev`) |\n|--------|-------------------------------|----------------------|\n| Where | Deployed surfaces on `*.mug.work` | `localhost:8787` |\n| Auth | Pre-authenticated via KV record | Dev banner identity cookie |\n| Notifications | Routed by `--notify` mode + overrides | Routed by `mug.json` dev overrides |\n| `ctx.isDemo` | `true` | `false` |\n| Workflows | Configurable (`--no-workflows`) | Always run |\n\nThe two systems are independent. A surface can be in demo mode in production while you develop locally with different settings.\n\n## KV record format\n\nStored at `demo:{workspace}:{surfaceId}` with automatic TTL expiry:\n\n```json\n{\n \"identity\": \"demo@example.com\",\n \"createdAt\": \"2026-05-14T10:00:00.000Z\",\n \"expiresAt\": \"2026-05-21T10:00:00.000Z\",\n \"notifyMode\": \"demo-user\",\n \"notifyOverrides\": { \"sms\": \"+15551234567\" },\n \"devEmail\": \"developer@example.com\",\n \"workflows\": true\n}\n```\n\n## Complete example\n\n```bash\n# 1. Create a demo persona with curated data\nmug sql main \"INSERT INTO employees (email, name, role, manager_email) VALUES ('demo@example.com', 'Demo User', 'Technician', 'demo-mgr@example.com')\"\nmug sql main \"INSERT INTO time_off_requests (employee_email, start_date, end_date, status) VALUES ('demo@example.com', '2026-06-01', '2026-06-05', 'pending')\"\n\n# 2. Enable demo — notifications go to you, SMS to your phone\nmug demo enable employee-portal --as demo@example.com --notify dev --sms-to +15551234567\n\n# 3. Share the URL with stakeholder\n# https://my-workspace.mug.work/employee-portal\n\n# 4. Check status\nmug demo status\n\n# 5. When done\nmug demo disable employee-portal\n```\n",
|
|
45
|
+
"billing.md": "# Billing & Usage — Full Reference\n\nMug workspaces are billed per-workspace with flat tiers plus usage overages. Every workspace starts on the Free tier.\n\n## Tiers\n\n| Tier | Price | Operations | Records | Storage | Email | SMS | AI Credits |\n|------|-------|-----------|---------|---------|-------|-----|------------|\n| Free | $0 | 20,000/mo | 10,000 | 100 MB | 100/mo | 50/mo | 2,500/mo |\n| Starter | $99/mo | 1,000,000/mo | 250,000 | 5 GB | 1,500/mo | 500/mo | 15,000/mo |\n| Pro | $299/mo | 10,000,000/mo | 2,500,000 | 50 GB | 5,000/mo | 1,000/mo | 50,000/mo |\n| Business | $599/mo | 50,000,000/mo | 25,000,000 | 500 GB | 15,000/mo | 2,500/mo | 100,000/mo |\n\nAnnual billing: 2 months free (~17% off). Volume discounts: 10-25% for multi-workspace accounts.\n\n## Six billing dimensions\n\n1. **Operations** — workflow step executions. Metered but not hard-blocked (soft limit with alerts).\n2. **Database records** — total rows across all workspace databases. Reconciled periodically.\n3. **File storage** — total bytes in `files/` (R2). Checked before upload.\n4. **Email sends** — emails sent via `ctx.notify.email()`. Hard limit — returns error when exceeded.\n5. **SMS sends** — SMS sent via `ctx.notify.sms()`. Hard limit — returns error when exceeded.\n6. **AI credits** — `Math.ceil(tokens / 1000)` per AI call. BYOK calls don't consume credits. Hard-blocked when remaining = 0.\n\n## What happens at the limit\n\nWhen a dimension reaches its limit:\n\n- **Email/SMS**: `ctx.notify.email()` and `ctx.notify.sms()` throw with `\"Usage limit exceeded for email: 100/100\"`. Catch this in workflows to handle gracefully.\n- **AI credits**: `ctx.ai()` throws with `\"AI credit limit exceeded: 2500/2500\"` when credits are fully consumed. BYOK calls (`billing: \"ai.anthropic\"`) bypass this entirely.\n- **File storage**: File uploads return a 429 error when storage is full.\n- **Operations**: Metered but not blocked — workflows continue running. Alerts fire at 80% and 100%.\n- **Database records**: Reconciled periodically. Not blocked in real-time.\n\nWorkspace owners receive email alerts at **80%** and **100%** of each dimension's base allotment.\n\n## Overage packs\n\nWhen you hit a limit, purchase a 25% overage pack instead of upgrading:\n\n| Tier | Pack price | What you get |\n|------|-----------|--------------|\n| Starter | $25 | 25% more of every dimension |\n| Pro | $75 | 25% more of every dimension |\n| Business | $150 | 25% more of every dimension |\n\n**Burn rate**: Starter/Pro packs burn at 2.5x (effectively ~10% usable headroom per pack). Business packs burn at 1x (full value). Unused packs roll forward to the next month.\n\nFree tier cannot purchase packs — upgrade first.\n\n```bash\nmug buy-pack # purchase via Stripe Checkout\n```\n\n## Schedule interval enforcement\n\nEach tier has a minimum schedule interval. Schedules configured below the floor are **silently clamped** on deploy:\n\n| Tier | Min interval |\n|------|-------------|\n| Free | Daily (once per day) |\n| Starter | 15 minutes |\n| Pro | 5 minutes |\n| Business | 1 minute |\n\nIf you set `\"schedule\": \"*/5 * * * *\"` on a Free tier workspace, it deploys as `\"0 0 * * *\"` (daily). The deploy response includes clamped schedule info. Upgrade the tier to unlock shorter intervals.\n\n## BYOK — Bring Your Own Keys\n\nBYOK lets you use your own API keys for AI, email, and SMS. BYOK usage **does not count** against Mug billing dimensions.\n\n```bash\n# AI — zero credit consumption\nmug secret set ai.anthropic=sk-ant-...\nmug secret set ai.openai=sk-...\n\n# Email — zero email send consumption\nmug secret set RESEND_API_KEY=re_...\n\n# SMS — zero SMS send consumption\nmug secret set TWILIO_ACCOUNT_SID=AC...\nmug secret set TWILIO_AUTH_TOKEN=...\nmug secret set TWILIO_PHONE_NUMBER=+1...\n```\n\nConfigure BYOK billing in `mug.json`:\n```json\n{\n \"ai\": {\n \"billing\": {\n \"default\": \"ai.anthropic\",\n \"fast\": \"mug-metered\",\n \"powerful\": \"ai.anthropic\"\n }\n }\n}\n```\n\nPer-call: `ctx.ai(\"auto\", { prompt, billing: \"ai.anthropic\" })`. Per-workflow: `workflow(\"name\", handler, { billing: \"ai.anthropic\" })`.\n\n## CLI commands\n\n```bash\nmug usage # view all 6 dimensions with progress bars\nmug usage --period 2026-04 # view a past billing period\nmug usage --json # structured output\n\nmug workspace plan # view or change plan tier (opens Stripe Checkout for paid tiers)\n\nmug buy-pack # purchase 25% overage pack\n\nmug billing # view billing settings\nmug billing --auto-packs on # auto-buy packs at 100%\nmug billing --max-packs 5 # cap auto-purchases at 5/month\nmug billing --email billing@co.com # set billing notification email\n```\n\n## Handling limit errors in workflows\n\n```typescript\nworkflow(\"bulk-notify\", async (ctx) => {\n const customers = await ctx.query(\"crm\", \"SELECT * FROM customers WHERE _mug_deleted_at IS NULL\");\n let sent = 0, skipped = 0;\n\n for (const c of customers) {\n try {\n await ctx.notify.email({\n to: c.email as string,\n subject: \"Monthly Update\",\n message: \"Your latest report is ready.\",\n });\n sent++;\n } catch (e) {\n if ((e as Error).message.includes(\"Usage limit exceeded\")) {\n skipped = customers.length - sent;\n break; // stop sending — limit reached\n }\n throw e;\n }\n }\n\n return { sent, skipped };\n});\n```\n",
|
|
46
|
+
"agents.md": "# Agents API Reference\n\nCustom AI agents run autonomous multi-step work with tools, brain memory, and structured output. Unlike `ctx.ai()` (single prompt → single response), agents iterate over multiple turns with tool access and persistent memory.\n\n## Agent Folder Structure\n\nEach agent is a folder in `agents/<name>/`:\n\n```\nagents/\n├── shared-skills/ # skills available to all agents\n│ └── skill-name/SKILL.md\n├── dispatch-bot/\n│ ├── agent.json # config\n│ ├── soul.md # core identity + instructions\n│ ├── skills/ # agent-specific skills\n│ │ └── scheduling-rules/SKILL.md\n│ └── brain.db # persistent memory (runtime-managed)\n```\n\n## agent.json\n\n```json\n{\n \"name\": \"dispatch-bot\",\n \"model\": \"claude-sonnet\",\n \"tools\": [\"query\", \"search\", \"notify\"],\n \"memory\": { \"entities\": true, \"outcomes\": true, \"struggles\": true },\n \"caps\": { \"maxTurns\": 30, \"maxCredits\": 200, \"maxDuration\": 300 },\n \"requireApproval\": [\"notify\"]\n}\n```\n\n### Fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `name` | string | Agent identifier (defaults to folder name) |\n| `model` | string or object | Fixed model (`\"claude-sonnet\"`) or dynamic routing (see below) |\n| `instructions` | string | Path to instruction file relative to agent folder (default: `\"soul.md\"`) |\n| `tools` | string[] | Workspace capabilities to grant (see Tool Grants) |\n| `memory` | object | Brain memory features to enable |\n| `caps` | object | Resource limits per session |\n| `requireApproval` | string[] | Tools that pause for human approval |\n\n### Model Options\n\n**Fixed model** — one model for all turns:\n```json\n{ \"model\": \"claude-sonnet\" }\n```\n\n**Dynamic routing** — Mug picks the tier per turn based on complexity:\n```json\n{\n \"model\": {\n \"fast\": \"claude-haiku\",\n \"balanced\": \"claude-sonnet\",\n \"powerful\": \"claude-opus\"\n }\n}\n```\n\nYou can assign 2 or 3 tiers. Any combination works: `fast`+`balanced`, `balanced`+`powerful`, or all three.\n\nAvailable models: `claude-sonnet`, `claude-haiku`, `claude-opus`, `gpt-4o`, `gpt-4o-mini`, `gpt-4.1-nano`, `gpt-4.1-mini`, `gpt-4.1`, or any `provider/model` string for AI Gateway routing.\n\n## soul.md\n\nThe agent's core identity and instructions. Always loaded into the system prompt. Write this as a markdown file in the agent folder:\n\n```markdown\n# Dispatch Bot\n\nYou are a dispatch coordinator for an HVAC company.\n\n## What you do\nAssign incoming service requests to available technicians based on location, skills, and schedule.\n\n## How you work\n1. Query the service requests database for unassigned tickets\n2. Check technician availability and zone assignments\n3. Match requests to the best available technician\n4. Notify the technician and update the ticket\n\n## Rules\n- Never double-book a technician\n- Emergency calls override scheduled maintenance\n- When you lack information, call flag_struggle — your admin will add skills to help\n- Always deliver results via deliver_output\n```\n\n## Skills\n\nAgent-specific skills live in `agents/<name>/skills/<skill-name>/SKILL.md`. Shared skills for all agents live in `agents/shared-skills/<skill-name>/SKILL.md`.\n\nSkills are auto-discovered from the folder structure. The agent receives a registry of available skills at session start and loads full content on demand.\n\nSKILL.md format:\n```markdown\n---\ndescription: Service territory zones and boundaries for technician routing\n---\n\n# Service Territories\n\nZone 1: Downtown core (zip codes 10001-10010)\n...\n```\n\n## Tool Grants\n\n| Grant | Tools provided | Description |\n|-------|---------------|-------------|\n| `query` | `query(database, sql)` | Read-only SQL against workspace databases |\n| `search` | `search_data(query, source?, limit?)` | Semantic similarity search against synced data |\n| `ask` | `ask_question(question, source?)` | Natural language Q&A (search + AI synthesis) |\n| `notify` | `send_email(to, subject, message)`, `send_sms(to, message)` | Send notifications |\n| `http` | `http_request(url, method, body, headers)` | External API calls |\n| `workspace` | Read/write workspace files | File access in agent sandbox |\n| `ai` | `ai_call(prompt, system?, model?, maxTokens?)` | Sub-AI calls (credits count against caps) |\n\nThe `deliver_output(result)` tool is always available. Brain memory tools (`remember`, `recall`, `log_outcome`, `flag_struggle`) are auto-enabled when memory is configured.\n\n## Brain Memory\n\nThe agent's persistent memory system. Stored in `brain.db` (SQLite) in the agent folder. Created empty on first deploy, populated by the agent at runtime.\n\n### Memory config\n\n```json\n{\n \"memory\": {\n \"entities\": true,\n \"outcomes\": true,\n \"struggles\": true\n }\n}\n```\n\nAll three default to off. Enable the ones your agent needs.\n\n### Memory tools\n\nWhen memory is configured, the agent gets these tools automatically:\n\n**`remember(content, entity?, entityType?)`** — Store a fact. If an entity is named, upserts the entity record and links the fact.\n\n**`recall(query)`** — Search memory for matching entities, facts, and struggles. Returns summaries and recent observations.\n\n**`log_outcome(action, result, effective?, entity?)`** — Record what the agent did and what happened. The `effective` flag tracks success rates over time.\n\n**`flag_struggle(category, description, context?)`** — Signal a knowledge gap or edge case. Categories: `knowledge_gap` (agent lacks information), `edge_case` (instructions don't cover this scenario).\n\n### Auto-detected struggles\n\nThe runtime automatically logs struggles without agent cooperation:\n- **cap_hit** — agent reached maxTurns, maxCredits, or maxDuration\n- **correction** — admin rejected an approval request\n- **fallback** — agent delivered output with no structured data\n\n### Session-start digest\n\nAt the start of each session, the agent's system prompt includes a digest of its brain:\n- Entity summaries (all known entities with compiled descriptions)\n- Recent facts (last 14 days)\n- Unresolved struggles (so the agent knows its limitations)\n- Effectiveness stats (session count, outcome success rate)\n\n### Admin workflow\n\nThe consultant reviews the agent's brain to improve it:\n\n```bash\nmug brain dispatch-bot # overview\nmug brain dispatch-bot struggles # what the agent can't do\nmug brain dispatch-bot entities # who/what the agent knows\nmug brain dispatch-bot outcomes # action success rates\nmug brain dispatch-bot sessions # cross-workflow activity\nmug brain dispatch-bot search <q> # search the brain\n```\n\nThe admin reads struggles, writes new skills or updates soul.md, and deploys. The agent improves.\n\n## Workflow Integration\n\n### `ctx.agent(name, options)`\n\n```typescript\nconst result = await ctx.agent(\"dispatch-bot\", {\n goal: string, // what the agent should accomplish\n context?: Record<string, unknown>, // contextual data passed to the agent\n sessionKey?: string, // custom session key for resume\n caps?: { // per-invocation cap overrides\n maxTurns?: number,\n maxCredits?: number,\n maxDuration?: number,\n },\n});\n```\n\n### AgentResult\n\n```typescript\ninterface AgentResult {\n response: string; // agent's text response\n output?: Record<string, unknown>; // structured output from deliver_output\n usage: {\n credits: number;\n turns: number;\n duration: number;\n };\n capped?: boolean;\n cappedReason?: string; // \"turn_limit\" | \"credit_limit\" | \"duration_limit\"\n status?: string; // \"complete\" | \"pending_approval\"\n pendingApproval?: {\n tool: string;\n args: Record<string, unknown>;\n };\n}\n```\n\n## Human-in-the-Loop Approval\n\nWhen `requireApproval` lists tool names, the agent pauses before executing those tools.\n\n```typescript\nconst result = await ctx.agent(\"ops-assistant\", {\n goal: \"Review tickets and send reminders\",\n sessionKey: \"weekly-review\",\n});\n\nif (result.status === \"pending_approval\" && result.pendingApproval) {\n const { tool, args } = result.pendingApproval;\n\n // Option A: auto-approve\n await ctx.agent(\"ops-assistant\", { goal: \"Continue — approved\", sessionKey: \"weekly-review\" });\n\n // Option B: human approval via email\n const callbackUrl = await ctx.waitForUrl(\"agent-approval\");\n await ctx.notify.email({\n to: \"manager@company.com\",\n subject: `Agent wants to ${tool}`,\n message: `**Tool:** ${tool}\\n**Args:** ${JSON.stringify(args)}`,\n cta: { label: \"Approve\", url: `${callbackUrl}?action=approve` },\n });\n const event = await ctx.waitFor(\"agent-approval\", { timeout: \"24 hours\" });\n if (!event.timedOut && event.payload?.action === \"approve\") {\n await ctx.agent(\"ops-assistant\", { goal: \"Continue — approved\", sessionKey: \"weekly-review\" });\n }\n}\n```\n\n## Cap Enforcement\n\nWhen any cap is reached:\n- Agent is forced to call `deliver_output` with partial results\n- `result.capped` is `true`, `result.cappedReason` explains why\n- A `cap_hit` struggle is auto-logged to the brain\n\n## Deploy\n\n`mug deploy` validates agent.json and provisions each agent:\n1. Reads `agent.json` from each folder in `agents/`\n2. Validates model, tools, caps\n3. Reads soul.md content and skill files\n4. Writes config + instructions + skills to the agent runtime\n5. Creates empty brain.db on first deploy (subsequent deploys preserve brain data)\n\n## Example\n\n`agents/invoice-analyzer/agent.json`:\n```json\n{\n \"name\": \"invoice-analyzer\",\n \"model\": \"claude-sonnet\",\n \"tools\": [\"query\", \"notify\"],\n \"memory\": { \"entities\": true, \"outcomes\": true, \"struggles\": true },\n \"caps\": { \"maxTurns\": 20, \"maxCredits\": 100 },\n \"requireApproval\": [\"notify\"]\n}\n```\n\n`agents/invoice-analyzer/soul.md`:\n```markdown\n# Invoice Analyzer\n\nYou analyze overdue invoices and draft reminder notifications.\n\n## How you work\n1. Query the invoices database for overdue items\n2. Classify urgency based on amount and days overdue\n3. Draft appropriate reminder (email for low urgency, SMS for high)\n4. Remember customer payment patterns for future reference\n\n## Rules\n- Always check your memory for customer history before drafting\n- Flag a struggle if you encounter an invoice type you don't recognize\n- Deliver a summary with overdue count, total amount, and actions taken\n```\n\n`workflows/weekly-invoice-review.ts`:\n```typescript\nexport default async function run(ctx) {\n const result = await ctx.agent(\"invoice-analyzer\", {\n goal: \"Review all invoices from the past week. Flag overdue ones and draft reminder emails.\",\n context: { overdueThreshold: 30 },\n });\n\n if (result.output?.overdueCount > 0) {\n await ctx.notify.email({\n to: \"finance@company.com\",\n subject: `${result.output.overdueCount} overdue invoices found`,\n message: result.response,\n });\n }\n}\n```\n",
|
|
47
|
+
"slack.md": "# Slack — Full API Reference\n\nFor a guided walkthrough, use the `/slack` skill.\n\n## slack.json Schema\n\nEvery workspace has a `slack.json` at the root. Created by `mug init` with `{\"enabled\": false}`.\n\n```typescript\ninterface SlackJsonConfig {\n enabled: boolean; // Enable Slack app for this workspace\n name?: string; // App display name (max 35 chars, default: workspace name)\n description?: string; // App description (max 140 chars)\n color?: string; // App background color (hex, e.g. \"#1a1a2e\")\n botName?: string; // Bot display name (max 80 chars, default: same as name)\n homeTab?: {\n enabled: boolean;\n sections?: HomeTabSection[];\n };\n messagesTab?: boolean; // Enable DM tab (users can message the bot)\n shortcuts?: ShortcutConfig[];\n unfurlDomains?: string[]; // Domains for rich link previews (max 5)\n agentView?: {\n description: string; // Agent/assistant description (max 300 chars)\n suggestedPrompts?: { title: string; message: string }[];\n };\n scopes?: string[]; // Additional OAuth scopes (most auto-inferred)\n commands?: Record<string, { description: string; usage_hint?: string }>;\n events?: string[]; // Additional event subscriptions\n}\n```\n\n## Home Tab\n\nData-driven dashboard inside the Slack app. Rendered per-user when they open the App Home.\n\n### HomeTabSection types\n\n```typescript\ntype HomeTabSection =\n | { type: \"query\"; title?: string; database: string; query: string; columns?: string[]; emptyMessage?: string }\n | { type: \"actions\"; title?: string; buttons: { text: string; workflow: string; style?: \"primary\" | \"danger\" }[] }\n | { type: \"text\"; title?: string; text?: string }\n | { type: \"divider\" };\n```\n\n### query\n\nRuns SQL against a workspace database, renders results as a table.\n\n```json\n{\n \"type\": \"query\",\n \"title\": \"Active Projects\",\n \"database\": \"projects\",\n \"query\": \"SELECT name, status, manager FROM projects WHERE status = 'active' ORDER BY name\",\n \"columns\": [\"name\", \"status\", \"manager\"],\n \"emptyMessage\": \"No active projects.\"\n}\n```\n\n- `columns` defaults to first 4 columns of the result if omitted\n- Results capped at 15 rows with \"Showing 15 of N rows\" overflow\n- `emptyMessage` shown when query returns zero rows (default: \"No data\")\n\n### actions\n\nRenders buttons that trigger workflows when clicked.\n\n```json\n{\n \"type\": \"actions\",\n \"title\": \"Quick Actions\",\n \"buttons\": [\n { \"text\": \"Run Daily Report\", \"workflow\": \"daily-report\", \"style\": \"primary\" },\n { \"text\": \"Sync All Sources\", \"workflow\": \"run-sync\" },\n { \"text\": \"Clear Alerts\", \"workflow\": \"clear-alerts\", \"style\": \"danger\" }\n ]\n}\n```\n\nButtons use `action_id: \"mug:<workflow>:run\"` — routed by the existing Slack interaction handler.\n\n### text\n\nRenders a header and/or markdown text.\n\n```json\n{ \"type\": \"text\", \"title\": \"Operations Dashboard\", \"text\": \"Updated every time you open this tab.\" }\n```\n\n- `title` renders as a Block Kit `header` block\n- `text` renders as a `section` block with `mrkdwn`\n- Both are optional (but at least one should be present)\n\n### divider\n\nVisual separator between sections.\n\n```json\n{ \"type\": \"divider\" }\n```\n\n### Limits\n\n- Max 100 blocks per Home Tab (Slack platform limit)\n- Sections render in order until the 100-block limit is reached, then truncate\n- Home Tab refreshes on every `app_home_opened` event (each time a user opens the app)\n\n## Shortcuts\n\nAppear in Slack's lightning bolt menu (global) or message context menu (message).\n\n```typescript\ninterface ShortcutConfig {\n name: string; // Shortcut display name\n callbackId: string; // Unique identifier (max 255 chars)\n description: string; // Description (max 150 chars)\n type: \"global\" | \"message\";\n workflow?: string; // Direct workflow mapping (optional — can also use workflow triggers)\n}\n```\n\n### Example\n\n```json\n{\n \"shortcuts\": [\n {\n \"name\": \"Create Dispatch\",\n \"callbackId\": \"create_dispatch\",\n \"description\": \"Create a new dispatch job\",\n \"type\": \"global\",\n \"workflow\": \"create-dispatch\"\n },\n {\n \"name\": \"Summarize Thread\",\n \"callbackId\": \"summarize_thread\",\n \"description\": \"AI summary of this conversation\",\n \"type\": \"message\",\n \"workflow\": \"summarize-thread\"\n }\n ]\n}\n```\n\n### Workflow params\n\nGlobal shortcut workflow receives:\n- `ctx.params.callbackId` — the shortcut's callback ID\n- `ctx.params.triggerId` — for opening modals via `ctx.slack.openModal()`\n- `ctx.params.userId`, `ctx.params.userName`\n\nMessage shortcut workflow additionally receives:\n- `ctx.params.messageTs` — timestamp of the message\n- `ctx.params.channelId` — channel where the message is\n- `ctx.params.messageText` — text content of the message\n\n## Unfurl Domains\n\nWhen a URL matching a configured domain is posted in Slack, the app renders a rich preview showing the surface title, type, and an \"Open\" button.\n\n```json\n{\n \"unfurlDomains\": [\"acme.mug.work\"]\n}\n```\n\nMax 5 domains. Auto-adds `links:read` and `links:write` scopes and `link_shared` event.\n\n## Messages Tab\n\n```json\n{\n \"messagesTab\": true\n}\n```\n\nEnables the DM tab in the Slack app. Users can message the bot directly. DMs arrive as `message.im` events and route to the inbound Slack workflow handler. Auto-adds `im:history` scope.\n\n## Agent View\n\n```json\n{\n \"agentView\": {\n \"description\": \"AI assistant for operations questions\",\n \"suggestedPrompts\": [\n { \"title\": \"Schedule\", \"message\": \"Show me this week's schedule\" },\n { \"title\": \"Status\", \"message\": \"What's the status of active jobs?\" }\n ]\n }\n}\n```\n\nConfigures the agent/assistant view in Slack. Auto-adds `assistant:write` scope.\n\n## Deploy Process\n\n`mug deploy` reads `slack.json` and:\n\n1. Generates a Slack manifest (v2) from the config\n2. Auto-infers OAuth scopes from enabled features\n3. Merges workflow-level Slack triggers (slash commands, events) from `.ts` files\n4. Compares scopes with last deploy — warns if new scopes require re-authorization\n5. Creates or updates the Slack app via manifest API (if config token available)\n6. Stores homeTab and shortcuts config in R2 for runtime rendering\n\n### Scope auto-inference\n\n| Feature | Scopes added |\n|---------|-------------|\n| Always | `chat:write` |\n| Slash commands or shortcuts | `commands` |\n| Home Tab | `users:read`, `users:read.email` |\n| Messages Tab | `im:history` |\n| Unfurl domains | `links:read`, `links:write` |\n| Agent view | `assistant:write` |\n| Message events | `channels:history`, `groups:history` |\n\n### Legacy migration\n\nIf `mug.json` has a `slack` key and no `slack.json` exists, `mug sync` creates `slack.json` from the legacy config and prints a deprecation notice.\n\n## Workflow Triggers\n\nSlack triggers are defined in workflow `.ts` files, not in `slack.json`:\n\n```typescript\nimport { workflow } from \"@mugwork/mug\";\n\n// Slash command\nworkflow(\"dispatch\", handler, {\n trigger: { type: \"slack_command\", command: \"/dispatch\", description: \"Create a dispatch\" },\n});\n\n// Event subscription\nworkflow(\"classify\", handler, {\n trigger: { type: \"slack_event\", event: \"message\" },\n});\n```\n\nThese merge into the manifest automatically at deploy time.\n\n## ctx.* Methods\n\nSee `.mug/docs/api.md` for full signatures.\n\n- `ctx.notify.slack({ to, message, blocks?, thread_ts? })` — send Block Kit messages\n- `ctx.slack.updateMessage({ channel, ts, text?, blocks? })` — update a message after interaction\n- `ctx.slack.openModal({ triggerId, view })` — open a modal from a slash command or shortcut\n\n## Slack Data\n\nAfter app install, these tables auto-sync every 6 hours:\n- `slack_users` — `user_id`, `email`, `display_name`\n- `slack_channels` — `channel_id`, `name`\n"
|
|
48
|
+
};
|