chore: import upstream snapshot with attribution
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:43:05 +08:00
commit 426e9eeabd
41828 changed files with 9656266 additions and 0 deletions
@@ -0,0 +1,160 @@
/**
* GET_AD_CAMPAIGN_ATTRIBUTION — copy/install first-party conversion attribution.
*
* Returns the signed conversion pixel and webhook instructions for a campaign.
* Read-only: the backend may lazily mint the campaign secret/token, but this
* action never records a conversion or changes spend.
*/
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import { getCloudClient, resolveCloudApiKey } from "../client.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can fetch campaign attribution install instructions.";
const NO_CAMPAIGN_MESSAGE =
"Which campaign should I prepare attribution instructions for? Send the campaign id.";
function readOpt(options: unknown): Record<string, unknown> | null {
if (!options || typeof options !== "object") return null;
const o = options as Record<string, unknown>;
const nested = o.parameters;
return nested && typeof nested === "object"
? (nested as Record<string, unknown>)
: o;
}
function extractCampaignId(message: Memory, options: unknown): string | null {
const rec = readOpt(options);
const candidate =
rec?.campaignId ??
rec?.campaign_id ??
rec?.id ??
message.content?.campaignId;
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
return null;
}
export const getAdCampaignAttributionAction: Action = {
name: "GET_AD_CAMPAIGN_ATTRIBUTION",
similes: [
"GET_CONVERSION_PIXEL",
"GET_ATTRIBUTION_PIXEL",
"GET_CAMPAIGN_WEBHOOK",
"INSTALL_CONVERSION_TRACKING",
],
description:
"Fetch the signed conversion pixel and webhook install instructions for an Eliza Cloud advertising campaign by campaign id.",
descriptionCompressed:
"Fetch signed conversion pixel/webhook install instructions for a campaign.",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["GET_AD_CAMPAIGN_ATTRIBUTION"],
});
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const campaignId = extractCampaignId(message, options);
if (!campaignId) {
await callback?.({
text: NO_CAMPAIGN_MESSAGE,
actions: ["GET_AD_CAMPAIGN_ATTRIBUTION"],
});
return {
success: false,
text: "No campaign id supplied.",
userFacingText: NO_CAMPAIGN_MESSAGE,
data: { reason: "no_campaign_id" },
};
}
try {
const attribution = await client.getAdCampaignAttribution(campaignId);
const reply = [
`Campaign ${attribution.campaignId} attribution is ready.`,
"The signed pixel snippet and webhook token were fetched and kept out of connector chat.",
`Webhook: POST ${attribution.webhookEndpoint}`,
"Copy the signed install payload from the trusted Cloud UI/API response.",
].join("\n");
await callback?.({
text: reply,
actions: ["GET_AD_CAMPAIGN_ATTRIBUTION"],
});
return {
success: true,
text: `Fetched attribution instructions for campaign ${attribution.campaignId}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: { attribution },
};
} catch (err) {
logger.warn(
`[GET_AD_CAMPAIGN_ATTRIBUTION] failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
const msg =
"I couldn't fetch campaign attribution instructions right now — the Cloud API returned an error.";
await callback?.({
text: msg,
actions: ["GET_AD_CAMPAIGN_ATTRIBUTION"],
});
return {
success: false,
text: "Failed to fetch campaign attribution instructions.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: {
text: "get conversion pixel for campaign camp_123",
campaignId: "camp_123",
},
},
{
name: "{{agent}}",
content: {
text: 'Campaign camp_123 attribution is ready.\nPixel: <img src="https://..." width="1" height="1" style="display:none" alt="" />\nWebhook: POST https://...',
actions: ["GET_AD_CAMPAIGN_ATTRIBUTION"],
},
},
],
],
};
@@ -0,0 +1,338 @@
/**
* Advertising campaign actions (#11599).
*
* SET_AD_CAMPAIGN_DAYPARTING updates delivery windows through the Cloud API.
* DUPLICATE_AD_CAMPAIGN copies a campaign config without spend/provider state.
*/
import type {
CampaignDaypartingSchedule,
CampaignPerformanceReportResponse,
CreateCampaignReportShareResponse,
DuplicateAdCampaignInput,
} from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import { getCloudClient, resolveCloudApiKey } from "../client.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can manage ad campaigns.";
function readOpt(options: unknown): Record<string, unknown> {
if (!options || typeof options !== "object") return {};
const record = options as Record<string, unknown>;
return record.parameters && typeof record.parameters === "object"
? (record.parameters as Record<string, unknown>)
: record;
}
function readCampaignId(options: Record<string, unknown>): string | null {
const value = options.campaignId ?? options.id;
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function readDayparting(
options: Record<string, unknown>,
): CampaignDaypartingSchedule | null {
const value = options.dayparting ?? options.schedule;
if (!value || typeof value !== "object") return null;
return value as CampaignDaypartingSchedule;
}
function readBoolean(options: Record<string, unknown>, key: string): boolean {
return options[key] === true || options[key] === "true";
}
function formatReportSummary(
report: CampaignPerformanceReportResponse["report"],
): string {
const s = report.summary;
return [
`Campaign "${report.campaign.name}" (${report.campaign.status})`,
`Spend: ${report.campaign.budgetCurrency} ${s.spend.toFixed(2)} of ${report.campaign.budgetAmount.toFixed(2)}`,
`Impressions: ${s.impressions}`,
`Clicks: ${s.clicks}`,
`Conversions: ${s.conversions}`,
`CTR: ${s.ctr.toFixed(2)}%`,
`CPC: ${report.campaign.budgetCurrency} ${s.cpc.toFixed(2)}`,
`CPM: ${report.campaign.budgetCurrency} ${s.cpm.toFixed(2)}`,
].join("\n");
}
export const setAdCampaignDaypartingAction: Action = {
name: "SET_AD_CAMPAIGN_DAYPARTING",
similes: [
"SCHEDULE_AD_CAMPAIGN",
"SET_AD_DELIVERY_WINDOWS",
"UPDATE_AD_DAYPARTING",
],
description:
"Set a Cloud advertising campaign's dayparting delivery schedule. Requires structured campaignId and dayparting { timezone, windows } parameters.",
descriptionCompressed: "Set dayparting delivery windows for an ad campaign.",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
_message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["SET_AD_CAMPAIGN_DAYPARTING"],
});
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const rec = readOpt(options);
const campaignId = readCampaignId(rec);
const dayparting = readDayparting(rec);
if (!campaignId || !dayparting) {
const msg =
"I need a campaign id and a dayparting schedule with timezone and windows before I can update delivery.";
await callback?.({ text: msg, actions: ["SET_AD_CAMPAIGN_DAYPARTING"] });
return {
success: false,
text: "Missing campaign id or dayparting schedule.",
userFacingText: msg,
data: { reason: "missing_input" },
};
}
try {
const result = await client.updateAdCampaignDayparting(campaignId, {
dayparting,
});
const windowCount = result.dayparting?.windows.length ?? 0;
const reply = `Updated campaign ${campaignId} to use ${windowCount} dayparting window(s) in ${result.dayparting?.timezone}.`;
await callback?.({
text: reply,
actions: ["SET_AD_CAMPAIGN_DAYPARTING"],
});
return {
success: true,
text: `Updated dayparting for campaign ${campaignId}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: { result },
};
} catch (err) {
logger.warn(
`[SET_AD_CAMPAIGN_DAYPARTING] failed: ${err instanceof Error ? err.message : String(err)}`,
);
const msg = "I couldn't update that campaign schedule right now.";
await callback?.({ text: msg, actions: ["SET_AD_CAMPAIGN_DAYPARTING"] });
return {
success: false,
text: "Failed to update campaign dayparting.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: {
text: "run campaign 11111111-1111-4111-8111-111111111111 weekdays 9 to 5 Pacific",
},
},
{
name: "{{agent}}",
content: {
text: "Updated campaign 11111111-1111-4111-8111-111111111111 to use 1 dayparting window(s) in America/Los_Angeles.",
actions: ["SET_AD_CAMPAIGN_DAYPARTING"],
},
},
],
],
};
export const duplicateAdCampaignAction: Action = {
name: "DUPLICATE_AD_CAMPAIGN",
similes: ["COPY_AD_CAMPAIGN", "CLONE_AD_CAMPAIGN"],
description:
"Duplicate a Cloud advertising campaign config and creatives into a new draft. Requires structured campaignId; optional name sets the copy name.",
descriptionCompressed: "Duplicate an ad campaign into a draft copy.",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
_message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["DUPLICATE_AD_CAMPAIGN"],
});
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const rec = readOpt(options);
const campaignId = readCampaignId(rec);
if (!campaignId) {
const msg = "I need the campaign id before I can duplicate it.";
await callback?.({ text: msg, actions: ["DUPLICATE_AD_CAMPAIGN"] });
return {
success: false,
text: "Missing campaign id.",
userFacingText: msg,
data: { reason: "missing_campaign_id" },
};
}
const input: DuplicateAdCampaignInput = {};
if (typeof rec.name === "string" && rec.name.trim()) {
input.name = rec.name.trim();
}
try {
const result = await client.duplicateAdCampaign(campaignId, input);
const reply = `Created draft campaign "${result.campaign.name}" from ${campaignId} with ${result.creativesCopied} creative(s) copied.`;
await callback?.({ text: reply, actions: ["DUPLICATE_AD_CAMPAIGN"] });
return {
success: true,
text: `Duplicated campaign ${campaignId}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: { result },
};
} catch (err) {
logger.warn(
`[DUPLICATE_AD_CAMPAIGN] failed: ${err instanceof Error ? err.message : String(err)}`,
);
const msg = "I couldn't duplicate that campaign right now.";
await callback?.({ text: msg, actions: ["DUPLICATE_AD_CAMPAIGN"] });
return {
success: false,
text: "Failed to duplicate campaign.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
};
export const exportAdCampaignReportAction: Action = {
name: "EXPORT_AD_CAMPAIGN_REPORT",
similes: ["GET_AD_CAMPAIGN_REPORT", "SHARE_AD_CAMPAIGN_REPORT"],
description:
"Export a Cloud advertising campaign performance report. Requires structured campaignId; optional share=true creates a public expiring report link.",
descriptionCompressed: "Export or share an ad campaign performance report.",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
_message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["EXPORT_AD_CAMPAIGN_REPORT"],
});
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const rec = readOpt(options);
const campaignId = readCampaignId(rec);
if (!campaignId) {
const msg = "I need the campaign id before I can export its report.";
await callback?.({ text: msg, actions: ["EXPORT_AD_CAMPAIGN_REPORT"] });
return {
success: false,
text: "Missing campaign id.",
userFacingText: msg,
data: { reason: "missing_campaign_id" },
};
}
try {
const report = await client.getAdCampaignPerformanceReport(campaignId);
let share: CreateCampaignReportShareResponse["share"] | null = null;
if (readBoolean(rec, "share")) {
share = (
await client.createAdCampaignReportShare(campaignId, {
expiresInHours:
typeof rec.expiresInHours === "number" ? rec.expiresInHours : 168,
})
).share;
}
const reply = share
? `${formatReportSummary(report.report)}\nShare link: ${share.publicUrl}\nExpires: ${share.expiresAt}`
: formatReportSummary(report.report);
await callback?.({ text: reply, actions: ["EXPORT_AD_CAMPAIGN_REPORT"] });
return {
success: true,
text: `Exported report for campaign ${campaignId}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: { report: report.report, share },
};
} catch (err) {
logger.warn(
`[EXPORT_AD_CAMPAIGN_REPORT] failed: ${err instanceof Error ? err.message : String(err)}`,
);
const msg = "I couldn't export that campaign report right now.";
await callback?.({ text: msg, actions: ["EXPORT_AD_CAMPAIGN_REPORT"] });
return {
success: false,
text: "Failed to export campaign report.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
};
@@ -0,0 +1,246 @@
/**
* Ad inventory agent actions (#10687) — let an agent monetize an app with ads.
*
* CREATE_AD_SLOT — define an ad placement on one of the user's apps so it earns
* when ads are served into it.
* LIST_AD_SLOTS — list the org's ad slots + their impressions/clicks/revenue.
*/
import type { AdSlotFormat } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
extractAppReference,
getCloudClient,
resolveApp,
resolveCloudApiKey,
} from "../client.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can set up ad slots.";
const FORMATS: AdSlotFormat[] = ["banner", "native", "interstitial", "feed"];
function readOpt(options: unknown): Record<string, unknown> | null {
if (!options || typeof options !== "object") return null;
const o = options as Record<string, unknown>;
const nested = o.parameters;
return nested && typeof nested === "object"
? (nested as Record<string, unknown>)
: o;
}
export const createAdSlotAction: Action = {
name: "CREATE_AD_SLOT",
similes: [
"ADD_AD_SLOT",
"MONETIZE_WITH_ADS",
"SELL_AD_SPACE",
"CREATE_AD_PLACEMENT",
],
description:
"Create an ad slot on one of the user's Eliza Cloud apps so it can earn from serving ads. Use when the user wants to monetize an app with ads / sell ad space.",
descriptionCompressed:
"Create an ad slot on an app to earn from serving ads.",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["CREATE_AD_SLOT"] });
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const reference = extractAppReference(message, options);
const { app, available } = reference
? await resolveApp(client, reference)
: { app: null, available: [] as string[] };
if (!app) {
const msg =
available.length === 0
? "You don't have any apps yet — create one first, then I can add an ad slot."
: `Which app? Your apps are: ${available.join(", ")}.`;
await callback?.({ text: msg, actions: ["CREATE_AD_SLOT"] });
return {
success: false,
text: "App not found.",
userFacingText: msg,
data: { reason: "not_found" },
};
}
const rec = readOpt(options) ?? {};
// Use `slotName` (not `name`, which the planner uses to reference the app).
const name =
typeof rec.slotName === "string" && rec.slotName.trim()
? rec.slotName.trim()
: "Ad slot";
const format = (
FORMATS.includes(rec.format as AdSlotFormat) ? rec.format : "banner"
) as AdSlotFormat;
const floorCpm =
typeof rec.floorCpm === "number" && rec.floorCpm > 0
? rec.floorCpm
: undefined;
try {
const { slot, adTagToken } = await client.createAdSlot({
appId: app.id,
name,
format,
floorCpm,
});
const reply = `Created ad slot "${slot.name}" (${slot.format}) on "${app.name}". It'll earn when ads are served into it.`;
await callback?.({ text: reply, actions: ["CREATE_AD_SLOT"] });
return {
success: true,
text: `Created ad slot for ${app.name}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
slot: { id: slot.id, name: slot.name, format: slot.format },
app: { id: app.id, name: app.name },
// The ad tag needs this signed token to call the public serve
// endpoint (null when the deployment has no ad-tag secret).
adTagToken: adTagToken ?? null,
},
};
} catch (err) {
logger.warn(
`[CREATE_AD_SLOT] failed: ${err instanceof Error ? err.message : String(err)}`,
);
const msg =
"I couldn't create that ad slot right now — the Cloud API returned an error.";
await callback?.({ text: msg, actions: ["CREATE_AD_SLOT"] });
return {
success: false,
text: "Failed to create ad slot.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: { text: "monetize Acme Bot with a banner ad slot" },
},
{
name: "{{agent}}",
content: {
text: 'Created ad slot "Ad slot" (banner) on "Acme Bot". It\'ll earn when ads are served into it.',
actions: ["CREATE_AD_SLOT"],
},
},
],
],
};
export const listAdSlotsAction: Action = {
name: "LIST_AD_SLOTS",
similes: ["SHOW_AD_SLOTS", "MY_AD_INVENTORY", "AD_REVENUE"],
description:
"List the user's Eliza Cloud ad slots with impressions, clicks, and revenue. Use when the user asks about their ad inventory or ad earnings.",
descriptionCompressed:
"List the user's ad slots + their impressions/clicks/revenue.",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
_message: Memory,
_state?: State,
_options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["LIST_AD_SLOTS"] });
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
try {
const { slots } = await client.listAdSlots();
const reply =
slots.length === 0
? "You don't have any ad slots yet. Ask me to create one on an app to start earning from ads."
: `You have ${slots.length} ad slot(s):\n${slots
.map(
(s) =>
`${s.name} (${s.format}, ${s.status}) — ${s.total_impressions} impressions, ${s.total_clicks} clicks, $${Number(s.total_revenue).toFixed(4)} earned`,
)
.join("\n")}`;
await callback?.({ text: reply, actions: ["LIST_AD_SLOTS"] });
return {
success: true,
text: `Listed ${slots.length} ad slots.`,
userFacingText: reply,
verifiedUserFacing: true,
data: { count: slots.length },
};
} catch (err) {
logger.warn(
`[LIST_AD_SLOTS] failed: ${err instanceof Error ? err.message : String(err)}`,
);
const msg = "I couldn't list your ad slots right now.";
await callback?.({ text: msg, actions: ["LIST_AD_SLOTS"] });
return {
success: false,
text: "Failed to list ad slots.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: { text: "how much have my ad slots earned?" },
},
{
name: "{{agent}}",
content: {
text: "You have 1 ad slot(s):\n• Header (banner, active) — 1200 impressions, 34 clicks, $1.6800 earned",
actions: ["LIST_AD_SLOTS"],
},
},
],
],
};
@@ -0,0 +1,114 @@
/**
* BACKUP_APP (#10204) — export a portable, secret-free config snapshot of one of
* the user's Cloud apps so it can be saved and recreated later. Read-only.
*/
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
extractAppReference,
getCloudClient,
resolveApp,
resolveCloudApiKey,
} from "../client.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY.";
export const backupAppAction: Action = {
name: "BACKUP_APP",
similes: ["EXPORT_APP", "SAVE_APP_CONFIG", "APP_BACKUP", "EXPORT_APP_CONFIG"],
description:
"Export a portable config snapshot (backup) of one of the user's Eliza Cloud apps so it can be saved and recreated later. Use when the user wants to back up / export an app's configuration.",
descriptionCompressed: "Export a config backup snapshot of a Cloud app.",
contexts: ["settings", "apps"],
contextGate: { anyOf: ["settings", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["BACKUP_APP"] });
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const reference = extractAppReference(message, options);
const { app, available } = reference
? await resolveApp(client, reference)
: { app: null, available: [] as string[] };
if (!app) {
const msg =
available.length === 0
? "You don't have any apps to back up yet."
: `Which app should I back up? Your apps are: ${available.join(", ")}.`;
await callback?.({ text: msg, actions: ["BACKUP_APP"] });
return {
success: false,
text: "App not found.",
userFacingText: msg,
data: { reason: "not_found" },
};
}
try {
const { backup } = await client.exportAppBackup(app.id);
const reply = `Backed up "${app.name}" — a config snapshot (v${backup.version}, no secrets) you can save and restore later. Monetization: ${backup.monetization.enabled ? `on, ${backup.monetization.inference_markup_percentage}% inference markup` : "off"}.`;
await callback?.({ text: reply, actions: ["BACKUP_APP"] });
return {
success: true,
text: `Exported backup for ${app.name}.`,
userFacingText: reply,
verifiedUserFacing: true,
// The full snapshot is returned so the caller can persist it (fact/file).
data: { backup, app: { id: app.id, name: app.name } },
};
} catch (err) {
logger.warn(
`[BACKUP_APP] failed: ${err instanceof Error ? err.message : String(err)}`,
);
const msg = "I couldn't export that backup right now.";
await callback?.({ text: msg, actions: ["BACKUP_APP"] });
return {
success: false,
text: "Backup failed.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{ name: "{{user}}", content: { text: "back up my Acme Bot app" } },
{
name: "{{agent}}",
content: {
text: 'Backed up "Acme Bot" — a config snapshot (v1, no secrets) you can save and restore later. Monetization: off.',
actions: ["BACKUP_APP"],
},
},
],
],
};
@@ -0,0 +1,560 @@
/**
* BOOK_INFLUENCER (#10687) — the agent hires an influencer to promote, with a
* two-phase money confirm.
*
* 1. First ask NEVER moves money: it resolves the influencer + amount + brief,
* persists a pending confirmation (safety.ts), and asks the user to confirm.
* 2. On a later turn carrying the planner's structured `confirm: true` for that
* pending prompt, it funds the escrowed booking via `client.createBooking`
* (the advertiser's own org credits are debited into escrow; released to the
* influencer on approval, refunded on rejection — no external keys).
*
* Guardrails shared with the other gated actions:
* - at most ONE pending booking per room (a fresh pending re-prompts; a stale
* one is replaced), and a pending older than CONFIRM_TTL_MS refuses the
* bare confirm (safety.ts),
* - a budget needs an explicit currency cue — a bare number in the message is
* never treated as dollars,
* - influencer names resolve through the ambiguity-aware matcher (client.ts);
* ties ask the user instead of booking a lookalike profile,
* - the pending is deleted only AFTER the fund call resolves (#11844): its
* taskId is the sole holder of the escrow idempotency key
* (`influencer-confirm-<taskId>`), so on a transport-level failure the
* pending is kept and marked `recovery: true` — a re-confirm re-sends the
* SAME key and the server resumes/dedupes the exact booking (its funding
* resume) instead of funding a second escrow.
*/
import type { InfluencerProfileDto } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
getCloudClient,
matchByReference,
type ReferenceMatch,
resolveCloudApiKey,
resolveCloudSiteBaseUrl,
} from "../client.js";
import { cloudErrorInfo } from "../domain-intent.js";
import {
CONFIRM_TTL_MS,
confirmationRoomId,
confirmTargetMismatchMessage,
conflictingConfirmAmount,
conflictingConfirmTarget,
deleteCloudAppConfirmation,
findPendingCloudAppConfirmation,
markCloudAppConfirmationRecovery,
pendingExpired,
persistCloudAppConfirmation,
readStructuredConfirmation,
} from "../safety.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY.";
const NO_PENDING_MESSAGE =
"I don't have a pending influencer-booking confirmation for this room. Tell me who to book and the budget first, and I'll ask for confirmation.";
const CANCELED_MESSAGE = "Okay, I won't book that influencer.";
const ERROR_MESSAGE =
"I couldn't fund that booking right now — the Cloud API returned an error.";
function readOpt(options: unknown): Record<string, unknown> {
if (!options || typeof options !== "object") return {};
const o = options as Record<string, unknown>;
const nested = o.parameters;
return nested && typeof nested === "object"
? (nested as Record<string, unknown>)
: o;
}
function usd(n: number): string {
return `$${n.toFixed(2)}`;
}
/**
* The planner-extracted USD budget: `options.parameters.amount` first (the real
* planner path nests validated args, same as readStructuredConfirmation), then
* the top-level `amount` (direct handler calls / scenario turns).
*/
function optionAmount(options: unknown): number | null {
if (!options || typeof options !== "object") return null;
const opts = options as Record<string, unknown>;
const nested =
opts.parameters && typeof opts.parameters === "object"
? (opts.parameters as Record<string, unknown>)
: undefined;
for (const rec of [nested, opts]) {
const v = rec?.amount;
if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
if (typeof v === "string") {
const n = Number(v.replace(/[$,]/g, "").trim());
if (Number.isFinite(n) && n > 0) return n;
}
}
return null;
}
/**
* Parse the USD budget: planner option first, else an amount in the text WITH
* an explicit currency cue ("$50", "50 dollars", "50 usd", "50 bucks"). A bare
* number is NOT a budget — "book Nova, she has 80000 followers" must never
* stage an $80,000 escrow.
*/
function parseAmount(options: unknown, body: string): number | null {
const fromOptions = optionAmount(options);
if (fromOptions !== null) return fromOptions;
const m =
/\$\s*(\d+(?:\.\d+)?)/.exec(body) ??
/(\d+(?:\.\d+)?)\s*(?:dollars?|usd|bucks?)\b/i.exec(body);
if (m) {
const n = Number(m[1]);
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
export const bookInfluencerAction: Action = {
name: "BOOK_INFLUENCER",
similes: [
"HIRE_INFLUENCER",
"SPONSOR_INFLUENCER",
"PAY_INFLUENCER",
"PROMOTE_WITH_INFLUENCER",
],
description:
"Book (hire) an influencer on Eliza Cloud to promote — funds an escrowed offer from the org's credits. MONEY: the first ask only confirms intent; the booking is funded on explicit confirmation. Use when the user wants to hire/sponsor/pay an influencer.",
descriptionCompressed:
"Book an influencer to promote (escrowed; two-step confirm).",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
parameters: [
{
name: "profileId",
description: "Influencer profile id to book.",
required: false,
schema: { type: "string" },
},
{
name: "influencer",
description: "Influencer display name to book (resolved via browse).",
required: false,
schema: { type: "string" },
},
{
name: "amount",
description: "USD budget for the booking.",
required: false,
schema: { type: "number" },
},
{
name: "brief",
description: "What the influencer should post / the campaign brief.",
required: false,
schema: { type: "string" },
},
{
name: "confirm",
description:
"Follow-up: true confirms the pending booking, false cancels.",
required: false,
schema: { type: "boolean" },
},
],
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["BOOK_INFLUENCER"] });
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const roomId = confirmationRoomId(runtime, message);
const confirmation = readStructuredConfirmation(options);
const pending = await findPendingCloudAppConfirmation(
runtime,
roomId,
"BOOK_INFLUENCER",
);
// ---- Phase 2: a confirm/cancel came in ----
if (confirmation !== null) {
if (
!pending ||
typeof pending.metadata.amount !== "number" ||
!pending.metadata.brief
) {
await callback?.({
text: NO_PENDING_MESSAGE,
actions: ["BOOK_INFLUENCER"],
});
return {
success: false,
text: "No pending booking.",
userFacingText: NO_PENDING_MESSAGE,
data: { reason: "no_pending_confirmation" },
};
}
const isRecovery = pending.metadata.recovery === true;
if (confirmation === false) {
await deleteCloudAppConfirmation(runtime, pending.taskId);
if (isRecovery) {
// The earlier fund attempt failed at the transport level, so the
// escrow may already be held server-side. Never claim "nothing
// happened" — tell the user where to check and how to get a refund.
const bookingsUrl = `${resolveCloudSiteBaseUrl(runtime)}/dashboard/marketing/influencers`;
const msg =
`Okay — I won't retry that booking. Heads up: my earlier attempt to fund ${pending.metadata.appName} for ${usd(pending.metadata.amount)} didn't confirm either way, ` +
`so the booking may already exist with the budget held in escrow. Check your bookings at ${bookingsUrl} — if it's there you can cancel it for a full refund.`;
await callback?.({ text: msg, actions: ["BOOK_INFLUENCER"] });
return {
success: true,
text: `Recovery retry for ${pending.metadata.appName} canceled; the earlier attempt may have funded the escrow.`,
userFacingText: msg,
verifiedUserFacing: true,
data: { booked: false, canceled: true, recovery: true },
};
}
await callback?.({
text: CANCELED_MESSAGE,
actions: ["BOOK_INFLUENCER"],
});
return {
success: true,
text: CANCELED_MESSAGE,
userFacingText: CANCELED_MESSAGE,
verifiedUserFacing: true,
data: { booked: false, canceled: true },
};
}
// Frozen-snapshot guard: a confirm whose own params name a DIFFERENT
// influencer or budget must never fund the frozen booking the user is no
// longer talking about. (`appId`/`appName` carry the profile id + display
// name for this action.)
const profileConflict = conflictingConfirmTarget(
options,
{ name: pending.metadata.appName, id: pending.metadata.appId },
["profileId", "influencer"],
);
const budgetConflict = conflictingConfirmAmount(
options,
pending.metadata.amount,
);
if (profileConflict !== null || budgetConflict !== null) {
const requested =
profileConflict ??
`${usd(budgetConflict ?? 0)} (not ${usd(pending.metadata.amount)})`;
let msg: string;
if (isRecovery) {
msg =
`Your confirmation names "${requested}", but the pending recovery retry is for "${pending.metadata.appName}" at ${usd(pending.metadata.amount)}. ` +
`I did not retry or start a new booking, and I kept the recovery pending so its same escrow key survives. ` +
`Reply to confirm again to safely complete or replay the earlier attempt, or cancel to leave it.`;
} else {
await deleteCloudAppConfirmation(runtime, pending.taskId);
msg = confirmTargetMismatchMessage(
requested,
`booking of ${usd(pending.metadata.amount)}`,
pending.metadata.appName,
);
}
await callback?.({ text: msg, actions: ["BOOK_INFLUENCER"] });
return {
success: false,
text: `Confirm named "${requested}" but the pending booking was ${pending.metadata.appName} for ${usd(pending.metadata.amount)}; refused.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
reason: "confirm_target_mismatch",
booked: false,
requested,
pendingTarget: {
id: pending.metadata.appId,
name: pending.metadata.appName,
},
amount: pending.metadata.amount,
...(isRecovery ? { recovery: true } : {}),
},
};
}
// A recovery retry never expires (safety.ts): it resumes/replays money
// already committed under the same key rather than staging a new charge.
if (pendingExpired(pending)) {
await deleteCloudAppConfirmation(runtime, pending.taskId);
const msg =
`That booking request for ${pending.metadata.appName} is more than ${Math.round(CONFIRM_TTL_MS / 60000)} minutes old, so I didn't fund anything. ` +
`Ask me to book ${pending.metadata.appName} again and I'll re-confirm the details.`;
await callback?.({ text: msg, actions: ["BOOK_INFLUENCER"] });
return {
success: false,
text: `Pending booking of ${pending.metadata.appName} expired before confirmation.`,
userFacingText: msg,
verifiedUserFacing: true,
data: { reason: "confirmation_expired", booked: false },
};
}
try {
const result = await client.createBooking({
profileId: pending.metadata.appId,
brief: pending.metadata.brief,
amount: pending.metadata.amount,
// Stable per-confirmation key: the server dedupes/resumes on it, so
// a retry of this confirm can never fund a second escrow. The
// pending task is that key's ONLY holder — it must outlive any
// transport failure of this call (#11844).
idempotencyKey: `influencer-confirm-${pending.taskId}`,
});
// The server resolved the fund call at the business level (success or
// a clean rejection with no money held) — only now is the pending
// (and the idempotency key its taskId carries) done with.
await deleteCloudAppConfirmation(runtime, pending.taskId);
if (!result.success) {
const msg = result.error
? `I couldn't fund that booking: ${result.error}`
: ERROR_MESSAGE;
await callback?.({ text: msg, actions: ["BOOK_INFLUENCER"] });
return {
success: false,
text: "Booking failed.",
userFacingText: msg,
data: { reason: "error" },
};
}
const reply = isRecovery
? `Booked ${pending.metadata.appName} for ${usd(pending.metadata.amount)} — the retry completed the earlier attempt, so you were charged exactly once. The budget is held in escrow and released when you approve their deliverable.`
: `Booked ${pending.metadata.appName} for ${usd(pending.metadata.amount)} — the budget is held in escrow and released when you approve their deliverable.`;
await callback?.({ text: reply, actions: ["BOOK_INFLUENCER"] });
return {
success: true,
text: `Booked ${pending.metadata.appName}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
booked: true,
booking: { id: result.booking?.id },
amount: pending.metadata.amount,
...(isRecovery ? { recovery: true } : {}),
},
};
} catch (err) {
const info = cloudErrorInfo(err);
logger.warn(
`[BOOK_INFLUENCER] createBooking failed (${info.status ?? "transport"}/${info.code ?? "-"}): ${info.message}`,
);
if (info.status !== null && info.status < 500) {
// The server answered with a definite rejection — no escrow was
// held (a failed debit retires the funding row server-side), so the
// confirm is settled and the pending can go.
await deleteCloudAppConfirmation(runtime, pending.taskId);
if (info.status === 402) {
const billingUrl = `${resolveCloudSiteBaseUrl(runtime)}/dashboard/billing`;
const msg =
`Not enough credits to book ${pending.metadata.appName} for ${usd(pending.metadata.amount)} — nothing was funded. ` +
`Add credits and ask me again: ${billingUrl}`;
await callback?.({ text: msg, actions: ["BOOK_INFLUENCER"] });
return {
success: false,
text: "Insufficient credits for the booking.",
userFacingText: msg,
verifiedUserFacing: true,
data: { reason: "insufficient_credits", booked: false },
};
}
const msg = `I couldn't fund that booking: ${info.message}. Nothing was funded.`;
await callback?.({ text: msg, actions: ["BOOK_INFLUENCER"] });
return {
success: false,
text: `Booking rejected: ${info.message}`,
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error", booked: false },
};
}
// Transport failure or 5xx: the outcome is UNKNOWN — the escrow may
// be fully funded (response lost) or stranded mid-funding. KEEP the
// pending and mark it recovery: its taskId is the sole holder of the
// idempotency key, so a re-confirm re-sends the SAME key and the
// server's funding resume finishes or replays the exact booking —
// never a second hold (#11844).
await markCloudAppConfirmationRecovery(runtime, pending);
const msg =
`I couldn't confirm whether the booking of ${pending.metadata.appName} for ${usd(pending.metadata.amount)} was funded — the Cloud API didn't answer. ` +
`Reply to confirm again and I'll retry safely: the retry completes or replays this same booking and can never charge you twice. Or cancel and I'll leave it.`;
await callback?.({ text: msg, actions: ["BOOK_INFLUENCER"] });
return {
success: false,
text: `Fund call for ${pending.metadata.appName} failed in transit; kept the pending for a same-key retry.`,
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error", booked: false, recovery: true },
};
}
}
// ---- Phase 1: first ask — resolve target + persist a pending confirmation ----
// Never stack pendings: while a fresh one is waiting, re-prompt for it so a
// later bare confirm can only ever fund the booking the user was shown.
if (pending && !pendingExpired(pending)) {
const label = `${pending.metadata.appName}${
typeof pending.metadata.amount === "number"
? ` for ${usd(pending.metadata.amount)}`
: ""
}`;
const stillMsg =
pending.metadata.recovery === true
? `My earlier attempt to fund the booking of ${label} didn't confirm either way. ` +
`Reply to confirm and I'll retry safely — it completes or replays that same booking and can never charge you twice. Or cancel to leave it.`
: `The booking of ${label} is still waiting for confirmation. ` +
`Reply with a clear confirmation or cancellation.`;
await callback?.({ text: stillMsg, actions: ["BOOK_INFLUENCER"] });
return {
success: true,
text: `Awaiting structured confirmation to book ${pending.metadata.appName}.`,
userFacingText: stillMsg,
verifiedUserFacing: true,
data: {
booked: false,
confirmationRequired: true,
profileId: pending.metadata.appId,
amount: pending.metadata.amount,
},
};
}
if (pending) {
// Expired leftover: purge it so at most one pending booking exists per
// room and a stale ask can never come back to life.
await deleteCloudAppConfirmation(runtime, pending.taskId);
}
const rec = readOpt(options);
const body = message.content?.text ?? "";
const amount = parseAmount(options, body);
const brief =
typeof rec.brief === "string" && rec.brief.trim()
? rec.brief.trim()
: "Promote our product";
// Resolve the influencer profile: id directly, or by name via the same
// ambiguity-aware matcher the app actions use (exact id → exact name →
// whole-word-in-sentence → fragment; ties = ambiguous, ask the user).
let profileId =
typeof rec.profileId === "string" && rec.profileId.trim()
? rec.profileId.trim()
: null;
let profileName =
typeof rec.influencer === "string" ? rec.influencer.trim() : "";
if (!profileId) {
const ref =
(typeof rec.influencer === "string" && rec.influencer.trim()) ||
body.trim();
if (ref) {
let match: ReferenceMatch<InfluencerProfileDto>;
try {
const { profiles } = await client.listInfluencers();
match = matchByReference(profiles, ref, (p) => ({
id: p.id,
names: [p.display_name],
}));
} catch (err) {
logger.warn(
`[BOOK_INFLUENCER] listInfluencers failed while resolving "${ref}": ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({
text: ERROR_MESSAGE,
actions: ["BOOK_INFLUENCER"],
});
return {
success: false,
text: "Failed to resolve influencer.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
if (match.item) {
profileId = match.item.id;
profileName = match.item.display_name;
} else if (match.candidates.length > 1) {
const names = match.candidates.map((p) => p.display_name);
const msg = `Which influencer do you mean? "${ref}" matches ${names.length}: ${names.join(", ")}. Reply with the exact name so I book the right one.`;
await callback?.({ text: msg, actions: ["BOOK_INFLUENCER"] });
return {
success: false,
text: `Ambiguous influencer reference "${ref}" (${names.length} matches).`,
userFacingText: msg,
data: { reason: "ambiguous", reference: ref, candidates: names },
};
}
}
}
if (!profileId || !amount) {
const msg = !profileId
? "Which influencer should I book? Tell me their name (I can browse the marketplace) and a budget."
: "What budget should I book with? Tell me an amount in USD.";
await callback?.({ text: msg, actions: ["BOOK_INFLUENCER"] });
return {
success: false,
text: "Missing influencer or amount.",
userFacingText: msg,
data: { reason: "missing_input" },
};
}
await persistCloudAppConfirmation(runtime, {
roomId,
action: "BOOK_INFLUENCER",
appId: profileId,
appName: profileName || "the influencer",
amount,
brief,
});
const prompt = `This will book ${profileName || "the influencer"} for ${usd(amount)} (brief: "${brief}"). The budget is held in escrow from your Cloud credits and released to them when you approve the deliverable — refunded if you cancel or reject. Reply to confirm booking ${profileName || "the influencer"} for ${usd(amount)}.`;
await callback?.({ text: prompt, actions: ["BOOK_INFLUENCER"] });
return {
success: true,
text: `Awaiting confirmation to book ${profileName || "the influencer"} for ${usd(amount)}.`,
userFacingText: prompt,
verifiedUserFacing: true,
data: { confirmationRequired: true, profileId, amount },
};
},
examples: [
[
{
name: "{{user}}",
content: { text: "hire Nova to promote my app for $200" },
},
{
name: "{{agent}}",
content: {
text: 'This will book Nova for $200.00 (brief: "Promote our product"). The budget is held in escrow from your Cloud credits and released to them when you approve the deliverable — refunded if you cancel or reject. Reply to confirm booking Nova for $200.00.',
actions: ["BOOK_INFLUENCER"],
},
},
],
],
};
@@ -0,0 +1,932 @@
/**
* BUY_APP_DOMAIN — MONEY-OUT. Buys a domain through the Cloudflare registrar
* and attaches it to a Cloud app. Handled with maximum care.
*
* ── Safety model (documented per PR_EVIDENCE) ────────────────────────────────
* 1. Two-phase confirm (structured `confirm` + pending task in safety.ts).
* The first ask NEVER buys — it runs a read-only availability + price
* check and returns a confirmation prompt naming the exact domain, app,
* charge, and renewal price. Money moves only when a later turn carries
* structured `confirm: true` for that pending prompt, and the purchase
* uses the app + domain FROZEN at quote time (never re-parsed prose).
* 2. The confirmed PRICE is enforced, not just quoted: the confirm turn
* re-checks availability and refuses to buy when the current price no
* longer matches the confirmed cents — it re-quotes instead, so the org
* is never debited an amount the user did not confirm. Quotes also
* expire ({@link CONFIRM_TTL_MS}) and are re-quoted rather than charged.
* 3. Interrupted purchases are never lost or lied about. The server's 502
* `persist_failed_recoverable` means charged + registered but not
* attached; a retried buy finishes it FREE. That state is persisted as a
* durable fact (domain-facts.ts), so canceling the staged recovery, an
* expired session, or a later fresh "buy X" all still route to the free
* recovery — and every reply about it states that the charge stands.
* Recovery confirmations carry no new charge, so they never expire; if
* the domain turns out to be freshly AVAILABLE at recovery time, the
* handler re-quotes at the current price instead of silently buying.
* 4. The server is the real gate: `POST /apps/:id/domains/buy` debits fail-
* closed (402 before any registration), is idempotent per org+domain (a
* retry replays the earlier success instead of re-charging), and refunds
* exactly once if the registrar fails after the debit. This handler maps
* each outcome to an honest reply — it never says "bought" on a failure
* and never says "not charged" when money may have moved.
*
* The CTA ({@link buildConnectorCta}) carries ONLY a label + https URL —
* money/credentials never transit the connector.
*/
import type {
BuyAppDomainResponse,
CheckAppDomainResponse,
} from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
getCloudClient,
resolveCloudApiKey,
resolveCloudSiteBaseUrl,
} from "../client.js";
import {
hasInterruptedDomainPurchase,
recordInterruptedDomainPurchase,
removeInterruptedDomainPurchase,
} from "../domain-facts.js";
import {
cloudErrorInfo,
extractDomainReferences,
resolveDomainTargetApp,
usdFromCents,
} from "../domain-intent.js";
import { invalidateAppsCache } from "../providers/cloud-apps.js";
import {
buildConnectorCta,
CONFIRM_TTL_MS,
type ConnectorCta,
confirmationRoomId,
confirmTargetMismatchMessage,
conflictingConfirmDomain,
conflictingConfirmTarget,
deleteCloudAppConfirmation,
findPendingCloudAppConfirmation,
pendingExpired,
persistCloudAppConfirmation,
readStructuredConfirmation,
} from "../safety.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can buy domains.";
const NO_DOMAIN_MESSAGE =
"Which domain do you want to buy? Give me the full name, e.g. yourbrand.com.";
const NO_APPS_MESSAGE =
"You don't have any Cloud apps yet — a domain attaches to an app, so create one first and then I can buy the domain for it.";
const ERROR_MESSAGE =
"I couldn't process that domain purchase right now — the Cloud API returned an error. Nothing was purchased. Try again in a moment.";
const NO_PENDING_CONFIRMATION_MESSAGE =
"I don't have a pending domain purchase to confirm for this room. Tell me which domain to buy first, and I'll quote the price and ask for confirmation.";
const CANCELED_MESSAGE = "Canceled. No domain was purchased.";
// A quoted price is honored for CONFIRM_TTL_MS; after that we re-quote. The
// TTL + expiry check now live in safety.ts so every gated action shares them.
export { CONFIRM_TTL_MS } from "../safety.js";
function usd(n: number): string {
return `$${n.toFixed(2)}`;
}
function domainsCta(
runtime: IAgentRuntime,
app: { id: string; name: string },
): ConnectorCta {
const url = `${resolveCloudSiteBaseUrl(runtime)}/dashboard/apps/${app.id}?tab=domains`;
try {
return buildConnectorCta(`Open "${app.name}"'s domains`, url, "link");
} catch (err) {
// A malformed base URL must never block guidance.
logger.warn(
`[BUY_APP_DOMAIN] Could not build CTA: ${
err instanceof Error ? err.message : String(err)
}`,
);
return { label: "Open your app's domains", url, kind: "link" };
}
}
/**
* Run the purchase, absorbing exactly one server-directed retry: a 409 with
* code `idempotency_retry` means a stale claim from a dead worker was just
* reaped and the server asks the client to simply re-send.
*/
async function executeBuy(
buy: () => Promise<BuyAppDomainResponse>,
): Promise<{ res: BuyAppDomainResponse } | { err: unknown }> {
try {
return { res: await buy() };
} catch (err) {
const info = cloudErrorInfo(err);
if (info.status === 409 && info.code === "idempotency_retry") {
try {
return { res: await buy() };
} catch (retryErr) {
return { err: retryErr };
}
}
return { err };
}
}
/** Stage a purchase confirmation and return the prompt reply. */
async function stagePurchaseConfirmation(
runtime: IAgentRuntime,
callback: HandlerCallback | undefined,
args: {
roomId: string;
app: { id: string; name: string; slug?: string };
domain: string;
priceUsdCents: number;
renewalUsdCents: number;
cta: ConnectorCta;
/** Extra sentence prepended to the standard prompt (e.g. "price changed"). */
preamble?: string;
defaultedApp?: boolean;
},
): Promise<ActionResult> {
const { roomId, app, domain, priceUsdCents, renewalUsdCents, cta } = args;
await persistCloudAppConfirmation(runtime, {
roomId,
action: "BUY_APP_DOMAIN",
appId: app.id,
appName: app.name,
appSlug: app.slug,
amount: priceUsdCents / 100,
amountUsdCents: priceUsdCents,
domain,
cta,
});
const prompt =
`${args.preamble ? `${args.preamble} ` : ""}` +
`Buying ${domain} for "${app.name}" (${app.id}) will charge ${usdFromCents(priceUsdCents)} ` +
`from your Eliza Cloud credit balance now, and it auto-renews at ${usdFromCents(renewalUsdCents)}/yr ` +
`(manage or cancel on the dashboard). To go ahead, reply that you confirm buying ${domain}. ` +
`Or use the dashboard: ${cta.url}`;
await callback?.({ text: prompt, actions: ["BUY_APP_DOMAIN"] });
return {
success: true,
text: `Awaiting structured confirmation to buy ${domain} for ${app.name} at ${usdFromCents(priceUsdCents)}.`,
userFacingText: prompt,
verifiedUserFacing: true,
data: {
app: { id: app.id, name: app.name, slug: app.slug },
domain,
amount: priceUsdCents / 100,
renewalUsdCents,
purchased: false,
confirmationRequired: true,
...(args.defaultedApp !== undefined
? { defaultedApp: args.defaultedApp }
: {}),
cta,
},
};
}
/** Stage a no-charge recovery confirmation and return the prompt reply. */
async function stageRecoveryConfirmation(
runtime: IAgentRuntime,
callback: HandlerCallback | undefined,
args: {
roomId: string;
app: { id: string; name: string; slug?: string };
domain: string;
cta: ConnectorCta;
reason: string;
},
): Promise<ActionResult> {
const { roomId, app, domain, cta } = args;
await persistCloudAppConfirmation(runtime, {
roomId,
action: "BUY_APP_DOMAIN",
appId: app.id,
appName: app.name,
appSlug: app.slug,
domain,
recovery: true,
cta,
});
const msg =
`${domain} was charged and registered to you, but the final attach to "${app.name}" didn't complete. ` +
`Reply that you confirm and I'll finish the setup — you will NOT be charged again.`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: `Purchase of ${domain} registered but not attached; staged a no-charge recovery retry.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
reason: args.reason,
purchased: true,
attached: false,
confirmationRequired: true,
recovery: true,
domain,
cta,
},
};
}
export const buyAppDomainAction: Action = {
name: "BUY_APP_DOMAIN",
similes: [
"BUY_DOMAIN",
"PURCHASE_DOMAIN",
"REGISTER_DOMAIN",
"GET_A_DOMAIN",
"BUY_CUSTOM_DOMAIN",
],
description:
"Buy a domain through Eliza Cloud (Cloudflare registrar) and attach it to a Cloud app. MONEY-OUT: charged from the org credit balance — the first ask only quotes the price and asks for confirmation. Use when the user asks to buy, purchase, or register a domain.",
descriptionCompressed:
"Buy + attach a domain to a Cloud app (money-out; two-step confirm).",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
parameters: [
{
name: "domain",
description: "The domain to buy, e.g. yourbrand.com.",
required: false,
schema: { type: "string" },
},
{
name: "appName",
description: "Name, slug, or id of the Cloud app the domain attaches to.",
required: false,
schema: { type: "string" },
},
{
name: "confirm",
description:
"Follow-up confirmation. Set true only when the user is confirming the pending domain-purchase prompt; set false when canceling.",
required: false,
schema: { type: "boolean" },
},
],
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const roomId = confirmationRoomId(runtime, message);
const confirmation = readStructuredConfirmation(options);
const pending = await findPendingCloudAppConfirmation(
runtime,
roomId,
"BUY_APP_DOMAIN",
);
if (confirmation !== null) {
if (!pending || typeof pending.metadata.domain !== "string") {
await callback?.({
text: NO_PENDING_CONFIRMATION_MESSAGE,
actions: ["BUY_APP_DOMAIN"],
});
return {
success: false,
text: "No pending domain-purchase confirmation.",
userFacingText: NO_PENDING_CONFIRMATION_MESSAGE,
data: { reason: "no_pending_confirmation", purchased: false },
};
}
await deleteCloudAppConfirmation(runtime, pending.taskId);
const isRecovery = pending.metadata.recovery === true;
const target = {
id: pending.metadata.appId,
name: pending.metadata.appName,
slug: pending.metadata.appSlug,
};
const domain = pending.metadata.domain;
const cta = pending.metadata.cta ?? domainsCta(runtime, target);
if (confirmation === false) {
if (isRecovery) {
// The purchase already happened — canceling only skips the attach.
// Never claim "no domain was purchased"; the debit stands and the
// domain is registered to the org.
const msg =
`Okay — I won't finish the setup now. Keep in mind ${domain} was already charged and registered to you; ` +
`only the attach to "${target.name}" is missing. Say "buy ${domain}" again anytime and I'll complete it ` +
`without a new charge, or finish on the dashboard: ${cta.url}`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: true,
text: `Recovery of ${domain} canceled; the earlier charge and registration stand.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
reason: "recovery_canceled",
purchased: true,
attached: false,
canceled: true,
domain,
cta,
},
};
}
await callback?.({
text: CANCELED_MESSAGE,
actions: ["BUY_APP_DOMAIN"],
});
return {
success: true,
text: CANCELED_MESSAGE,
userFacingText: CANCELED_MESSAGE,
verifiedUserFacing: true,
data: { purchased: false, canceled: true },
};
}
// Frozen-snapshot guard: a confirm whose own params name a DIFFERENT
// domain or app must never fund the frozen purchase the user is no
// longer talking about.
const domainConflict = conflictingConfirmDomain(options, domain);
const appConflict = conflictingConfirmTarget(options, {
name: target.name,
id: target.id,
aliases: target.slug ? [target.slug] : [],
});
if (domainConflict !== null || appConflict !== null) {
const requested = domainConflict ?? appConflict ?? "";
const msg = confirmTargetMismatchMessage(
requested,
`purchase of ${domain}`,
domainConflict !== null ? domain : target.name,
);
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: `Confirm named "${requested}" but the pending purchase was ${domain} for ${target.name}; refused.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
reason: "confirm_target_mismatch",
purchased: false,
requested,
domain,
pendingTarget: { id: target.id, name: target.name },
},
};
}
if (pendingExpired(pending)) {
const msg =
`That quote for ${domain} is more than ${Math.round(CONFIRM_TTL_MS / 60000)} minutes old and prices can change, so I didn't charge anything. ` +
`Ask me to buy ${domain} again and I'll get a fresh quote.`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: `Pending purchase of ${domain} expired before confirmation.`,
userFacingText: msg,
verifiedUserFacing: true,
data: { reason: "confirmation_expired", purchased: false, domain },
};
}
// Re-verify availability + price at purchase time. The server debits
// the CURRENT price, so buying without this check could charge an
// amount the user never confirmed.
let recheck: CheckAppDomainResponse;
try {
recheck = await client.checkAppDomain(target.id, { domain });
} catch (err) {
logger.warn(
`[BUY_APP_DOMAIN] confirm-time checkAppDomain(${target.id}, ${domain}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
const msg = `I couldn't re-verify ${domain} with Eliza Cloud just now, so I didn't buy anything. Ask me to buy ${domain} again in a moment.`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: `Confirm-time re-check failed for ${domain}; refused to buy blind.`,
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "precheck_failed", purchased: false, domain },
};
}
let proceedToBuy = false;
if (isRecovery) {
if (recheck.available) {
// The domain is genuinely registrable now — that would be a NEW
// charge at the current price, which the user has not confirmed.
const priceUsdCents = recheck.price?.totalUsdCents;
if (typeof priceUsdCents !== "number" || priceUsdCents <= 0) {
await callback?.({
text: ERROR_MESSAGE,
actions: ["BUY_APP_DOMAIN"],
});
return {
success: false,
text: `Recovery re-check for ${domain} returned available with no price; refusing to buy.`,
userFacingText: ERROR_MESSAGE,
data: { reason: "no_price", purchased: false, domain },
};
}
return stagePurchaseConfirmation(runtime, callback, {
roomId,
app: target,
domain,
priceUsdCents,
renewalUsdCents: recheck.renewal?.totalUsdCents ?? priceUsdCents,
cta,
preamble: `${domain} is showing as openly available to register now, so finishing it would be a NEW purchase rather than a free recovery.`,
});
}
proceedToBuy = true; // still unavailable → the free recovery path
} else if (!recheck.available) {
if (
await hasInterruptedDomainPurchase(
runtime,
message,
target.id,
domain,
)
) {
proceedToBuy = true; // our own charged+registered orphan — buy recovers it free
} else {
const msg = `${domain} is no longer available to register — it may have just been taken. You were NOT charged.`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: `${domain} became unavailable between quote and confirm; no purchase.`,
userFacingText: msg,
verifiedUserFacing: true,
data: { reason: "unavailable", purchased: false, domain },
};
}
} else {
const priceUsdCents = recheck.price?.totalUsdCents;
const confirmedUsdCents = pending.metadata.amountUsdCents;
if (typeof priceUsdCents !== "number" || priceUsdCents <= 0) {
await callback?.({
text: ERROR_MESSAGE,
actions: ["BUY_APP_DOMAIN"],
});
return {
success: false,
text: `Confirm-time re-check for ${domain} returned no price; refusing to buy.`,
userFacingText: ERROR_MESSAGE,
data: { reason: "no_price", purchased: false, domain },
};
}
if (
typeof confirmedUsdCents !== "number" ||
priceUsdCents !== confirmedUsdCents
) {
// Price moved (or the confirmed amount is missing) — never charge a
// figure the user did not see. Re-quote at the current price.
return stagePurchaseConfirmation(runtime, callback, {
roomId,
app: target,
domain,
priceUsdCents,
renewalUsdCents: recheck.renewal?.totalUsdCents ?? priceUsdCents,
cta,
preamble:
typeof confirmedUsdCents === "number"
? `The price of ${domain} changed from ${usdFromCents(confirmedUsdCents)} to ${usdFromCents(priceUsdCents)} since I quoted you, so I didn't charge anything.`
: `I couldn't verify the price you confirmed for ${domain}, so I didn't charge anything.`,
});
}
proceedToBuy = true;
}
if (!proceedToBuy) {
// Unreachable by construction — every branch above returns or sets it.
await callback?.({ text: ERROR_MESSAGE, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: "Internal confirm-turn state error; no purchase attempted.",
userFacingText: ERROR_MESSAGE,
data: { reason: "error", purchased: false, domain },
};
}
const outcome = await executeBuy(() =>
client.buyAppDomain(target.id, { domain }),
);
if ("err" in outcome) {
const info = cloudErrorInfo(outcome.err);
logger.warn(
`[BUY_APP_DOMAIN] buyAppDomain(${target.id}, ${domain}) failed (${info.status ?? "?"}/${info.code ?? "-"}): ${info.message}`,
);
if (info.status === 402) {
const billingUrl = `${resolveCloudSiteBaseUrl(runtime)}/dashboard/billing`;
const msg =
`Not enough credits to buy ${domain} — nothing was purchased. ` +
`Add credits and ask me again: ${billingUrl}`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: "Insufficient credits for the domain purchase.",
userFacingText: msg,
verifiedUserFacing: true,
data: {
reason: "insufficient_credits",
purchased: false,
domain,
cta: { label: "Add credits", url: billingUrl, kind: "link" },
},
};
}
if (info.status === 409 && info.code === "idempotency_in_progress") {
const msg = `A purchase of ${domain} is already in progress. Give it a minute, then ask me to list your domains to confirm it landed.`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: `Purchase of ${domain} already in progress server-side.`,
userFacingText: msg,
verifiedUserFacing: true,
data: { reason: "in_progress", purchased: false, domain, cta },
};
}
if (info.status === 409) {
const msg = `Couldn't buy ${domain}: ${info.message}. You were not charged.`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: `Domain purchase rejected: ${info.message}`,
userFacingText: msg,
verifiedUserFacing: true,
data: { reason: "rejected", purchased: false, domain, cta },
};
}
if (info.status === 502 && info.code === "persist_failed_recoverable") {
// Charged + registered, but the attach didn't complete. Persist the
// durable marker FIRST (it is what keeps the free recovery reachable
// after cancels/restarts), then stage the recovery confirmation.
await recordInterruptedDomainPurchase(
runtime,
message,
{ id: target.id, name: target.name },
domain,
);
return stageRecoveryConfirmation(runtime, callback, {
roomId,
app: target,
domain,
cta,
reason: "persist_failed_recoverable",
});
}
if (info.status === 502) {
const msg = `The registrar couldn't complete the purchase of ${domain}: ${info.message}. The charge was automatically refunded in full.`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: `Registrar failed for ${domain}; server refunded the debit.`,
userFacingText: msg,
verifiedUserFacing: true,
data: { reason: "registrar_failed", purchased: false, domain, cta },
};
}
const msg =
`Something went wrong buying ${domain} — the purchase may or may not have completed. ` +
`Check your app's Domains tab before retrying: ${cta.url}`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: `Domain purchase errored with an unknown outcome for ${domain}.`,
userFacingText: msg,
error:
outcome.err instanceof Error
? outcome.err
: new Error(String(outcome.err)),
data: { reason: "error", purchased: false, domain, cta },
};
}
const res = outcome.res;
if (res.success === false) {
const msg = `Couldn't buy ${domain}: ${res.error ?? "the request was rejected"}. Nothing was purchased.`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: "Domain purchase rejected by the Cloud API.",
userFacingText: msg,
data: { reason: "rejected", purchased: false, domain, cta },
};
}
// The app row (custom domain / URL) just changed server-side, and any
// interrupted-purchase marker for this domain is now resolved.
invalidateAppsCache(runtime);
await removeInterruptedDomainPurchase(
runtime,
message,
target.id,
domain,
);
const zoneNote = res.pendingZoneProvisioning
? " DNS is still being set up — it can take a few minutes to go live."
: " It's connecting to your app now.";
const noCharge =
res.alreadyRegistered === true || res.recoveredFromRegistrar === true;
const reply = noCharge
? `${domain} was already registered to you — I attached it to "${target.name}" without charging you again.${zoneNote}`
: `Bought ${domain} for "${target.name}" — charged ${
res.debited
? usdFromCents(res.debited.totalUsdCents)
: typeof pending.metadata.amount === "number"
? usd(pending.metadata.amount)
: "the quoted price"
} from your credit balance.${zoneNote}`;
await callback?.({ text: reply, actions: ["BUY_APP_DOMAIN"] });
return {
success: true,
text: `Purchased ${domain} for ${target.name}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: target.id, name: target.name },
domain,
purchased: true,
charged: !noCharge,
pendingZoneProvisioning: res.pendingZoneProvisioning === true,
debitedUsdCents: res.debited?.totalUsdCents ?? null,
cta,
},
};
}
if (pending && !pendingExpired(pending)) {
const requested = extractDomainReferences(message, options);
const otherDomain =
requested.length === 1 && requested[0] !== pending.metadata.domain
? requested[0]
: null;
const msg = otherDomain
? `I'm still waiting on the pending purchase of ${pending.metadata.domain ?? "a domain"} for "${pending.metadata.appName}". ` +
`Reply with a clear confirmation or cancellation first — then I can look at ${otherDomain}.`
: `The purchase of ${pending.metadata.domain ?? "that domain"} for "${pending.metadata.appName}" is still waiting for confirmation. ` +
`Reply with a clear confirmation or cancellation.`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: true,
text: `Awaiting structured confirmation to buy ${pending.metadata.domain}.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
app: {
id: pending.metadata.appId,
name: pending.metadata.appName,
},
domain: pending.metadata.domain,
...(otherDomain ? { deferredDomain: otherDomain } : {}),
purchased: false,
confirmationRequired: true,
cta: pending.metadata.cta,
},
};
}
if (pending) {
// Expired quote lying around — discard it and fall through to a fresh ask.
await deleteCloudAppConfirmation(runtime, pending.taskId);
}
const domains = extractDomainReferences(message, options);
if (domains.length === 0) {
await callback?.({
text: NO_DOMAIN_MESSAGE,
actions: ["BUY_APP_DOMAIN"],
});
return {
success: false,
text: "No domain reference supplied.",
userFacingText: NO_DOMAIN_MESSAGE,
data: { reason: "no_domain" },
};
}
if (domains.length > 1) {
const msg = `One domain at a time for purchases — which one do you want: ${domains.join(", ")}?`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: `Multiple domains named (${domains.length}); refusing to guess.`,
userFacingText: msg,
data: { reason: "multiple_domains", domains },
};
}
const domain = domains[0];
let resolved: Awaited<ReturnType<typeof resolveDomainTargetApp>>;
try {
resolved = await resolveDomainTargetApp(client, message, options);
} catch (err) {
logger.warn(
`[BUY_APP_DOMAIN] failed to resolve app: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: "Failed to resolve the target Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
if (!resolved.app) {
if (resolved.available.length === 0) {
await callback?.({
text: NO_APPS_MESSAGE,
actions: ["BUY_APP_DOMAIN"],
});
return {
success: false,
text: "User has no Cloud apps to attach a domain to.",
userFacingText: NO_APPS_MESSAGE,
data: { reason: "no_apps" },
};
}
const candidates =
resolved.ambiguous && resolved.ambiguous.length > 1
? resolved.ambiguous
: resolved.available;
const msg = `Which app should ${domain} attach to? ${
resolved.ambiguous
? `That matches ${candidates.length}: ${candidates.join(", ")}.`
: `Your apps are: ${candidates.join(", ")}.`
} Reply with the exact name.`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: resolved.ambiguous
? `Ambiguous app reference for the ${domain} purchase.`
: `No app matched for the ${domain} purchase.`,
userFacingText: msg,
data: {
reason: resolved.ambiguous ? "ambiguous" : "not_found",
domain,
candidates,
},
};
}
const app = resolved.app;
// Read-only pre-check: never stage a purchase the server would reject.
let check: CheckAppDomainResponse;
try {
check = await client.checkAppDomain(app.id, { domain });
} catch (err) {
logger.warn(
`[BUY_APP_DOMAIN] checkAppDomain(${app.id}, ${domain}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: "Availability pre-check failed.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error", domain },
};
}
if (!check.available) {
// Already attached to this app? Say so instead of "taken".
let alreadyAttached = false;
try {
const { domains: attached } = await client.listAppDomains(app.id);
alreadyAttached = (attached ?? []).some((d) => d.domain === domain);
} catch {
// best-effort — fall through to the other unavailable branches
}
if (alreadyAttached) {
// Any interrupted-purchase marker is stale once the attach exists.
await removeInterruptedDomainPurchase(runtime, message, app.id, domain);
const msg = `${domain} is already attached to "${app.name}" — nothing to buy.`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: true,
text: `${domain} already attached to ${app.name}.`,
userFacingText: msg,
verifiedUserFacing: true,
data: { reason: "already_attached", purchased: false, domain },
};
}
// A domain WE charged + registered but never attached also reads as
// "unavailable" — route it to the free recovery instead of dead-ending.
if (
await hasInterruptedDomainPurchase(runtime, message, app.id, domain)
) {
return stageRecoveryConfirmation(runtime, callback, {
roomId,
app,
domain,
cta: domainsCta(runtime, app),
reason: "recovery_staged",
});
}
const msg = `${domain} isn't available to register. Want me to check some alternatives?`;
await callback?.({ text: msg, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: `${domain} not available to register.`,
userFacingText: msg,
verifiedUserFacing: true,
data: { reason: "unavailable", purchased: false, domain },
};
}
const priceUsdCents = check.price?.totalUsdCents;
if (typeof priceUsdCents !== "number" || priceUsdCents <= 0) {
// Never stage a money confirmation without a concrete price.
await callback?.({ text: ERROR_MESSAGE, actions: ["BUY_APP_DOMAIN"] });
return {
success: false,
text: `Availability check for ${domain} returned no price; refusing to quote.`,
userFacingText: ERROR_MESSAGE,
data: { reason: "no_price", domain },
};
}
return stagePurchaseConfirmation(runtime, callback, {
roomId,
app,
domain,
priceUsdCents,
renewalUsdCents: check.renewal?.totalUsdCents ?? priceUsdCents,
cta: domainsCta(runtime, app),
defaultedApp: resolved.defaulted === true,
});
},
examples: [
[
{
name: "{{user}}",
content: { text: "buy coolbrand.com for Acme Bot" },
},
{
name: "{{agent}}",
content: {
text: 'Buying coolbrand.com for "Acme Bot" (…) will charge $13.99 from your Eliza Cloud credit balance now, and it auto-renews at $13.99/yr (manage or cancel on the dashboard). To go ahead, reply that you confirm buying coolbrand.com.',
actions: ["BUY_APP_DOMAIN"],
},
},
],
[
{
name: "{{user}}",
content: { text: "yes, I confirm — buy it" },
},
{
name: "{{agent}}",
content: {
text: 'Bought coolbrand.com for "Acme Bot" — charged $13.99 from your credit balance. DNS is still being set up — it can take a few minutes to go live.',
actions: ["BUY_APP_DOMAIN"],
},
},
],
],
};
export default buyAppDomainAction;
@@ -0,0 +1,275 @@
/**
* CHECK_APP_DOMAIN — READ-ONLY availability + price quote for a domain.
*
* Wraps `POST /api/v1/apps/:id/domains/check` (a dry run: the server never
* charges and never registers on this route). Reports availability, the
* purchase price, and the annual renewal price the renewal cron will
* re-charge, then points the user at BUY_APP_DOMAIN for the actual purchase.
*
* The check endpoint is app-scoped but its answer is app-agnostic, so when no
* app reference matches this action quietly falls back to any app of the
* user's (sole app first) instead of interrogating the user — the app only
* matters when buying.
*/
import type { AppDto, CheckAppDomainResponse } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import { getCloudClient, resolveCloudApiKey } from "../client.js";
import {
extractDomainReferences,
resolveDomainTargetApp,
usdFromCents,
} from "../domain-intent.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can check domains.";
const NO_DOMAIN_MESSAGE =
"Which domain should I check? Give me the full name, e.g. yourbrand.com.";
const NO_APPS_MESSAGE =
"You don't have any Cloud apps yet — domains attach to an app, so create one first and I can check and buy domains for it.";
const ERROR_MESSAGE =
"I couldn't check that domain right now — the Cloud API returned an error. Try again in a moment.";
/** Read-only checks stay cheap: quote at most this many domains per ask. */
const MAX_DOMAINS_PER_CHECK = 3;
interface DomainQuote {
domain: string;
available: boolean;
priceUsdCents: number | null;
renewalUsdCents: number | null;
}
function quoteLine(quote: DomainQuote): string {
if (!quote.available) {
return `${quote.domain} is not available.`;
}
if (quote.priceUsdCents === null) {
return `${quote.domain} is available.`;
}
const renewal =
quote.renewalUsdCents !== null
? ` (renews at ${usdFromCents(quote.renewalUsdCents)}/yr)`
: "";
return `${quote.domain} is available — ${usdFromCents(quote.priceUsdCents)}/yr${renewal}.`;
}
function toQuote(res: CheckAppDomainResponse): DomainQuote {
return {
domain: res.domain,
available: res.available === true,
priceUsdCents:
typeof res.price?.totalUsdCents === "number"
? res.price.totalUsdCents
: null,
renewalUsdCents:
typeof res.renewal?.totalUsdCents === "number"
? res.renewal.totalUsdCents
: null,
};
}
export const checkAppDomainAction: Action = {
name: "CHECK_APP_DOMAIN",
similes: [
"CHECK_DOMAIN",
"DOMAIN_AVAILABLE",
"DOMAIN_PRICE",
"SEARCH_DOMAIN",
"IS_DOMAIN_AVAILABLE",
],
description:
"Check whether a domain is available to register and what it costs per year (purchase + renewal). Read-only — never charges or registers. Use when the user asks if a domain is available, free, taken, or how much it costs.",
descriptionCompressed:
"Check a domain's availability + yearly price (read-only).",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
parameters: [
{
name: "domain",
description: "The domain to check, e.g. yourbrand.com.",
required: false,
schema: { type: "string" },
},
{
name: "appName",
description:
"Optional name, slug, or id of the Cloud app the domain is for.",
required: false,
schema: { type: "string" },
},
],
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["CHECK_APP_DOMAIN"] });
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const domains = extractDomainReferences(message, options);
if (domains.length === 0) {
await callback?.({
text: NO_DOMAIN_MESSAGE,
actions: ["CHECK_APP_DOMAIN"],
});
return {
success: false,
text: "No domain reference supplied.",
userFacingText: NO_DOMAIN_MESSAGE,
data: { reason: "no_domain" },
};
}
// The quote is app-agnostic — any app of the user's satisfies the
// app-scoped route, so fall back through match → sole app → first app
// rather than asking "which app?" for a read-only price check.
let app: AppDto | null;
try {
const resolved = await resolveDomainTargetApp(client, message, options);
app = resolved.app ?? resolved.apps[0] ?? null;
} catch (err) {
logger.warn(
`[CHECK_APP_DOMAIN] failed to resolve an app: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["CHECK_APP_DOMAIN"] });
return {
success: false,
text: "Failed to resolve a Cloud app for the domain check.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
if (!app) {
await callback?.({
text: NO_APPS_MESSAGE,
actions: ["CHECK_APP_DOMAIN"],
});
return {
success: false,
text: "User has no Cloud apps to scope the domain check to.",
userFacingText: NO_APPS_MESSAGE,
data: { reason: "no_apps" },
};
}
// Per-domain checks are independent — a failure on one must not discard
// the quotes already fetched for the others.
const toCheck = domains.slice(0, MAX_DOMAINS_PER_CHECK);
const quotes: DomainQuote[] = [];
const failed: string[] = [];
for (const domain of toCheck) {
try {
const res = await client.checkAppDomain(app.id, { domain });
quotes.push(toQuote(res));
} catch (err) {
logger.warn(
`[CHECK_APP_DOMAIN] checkAppDomain(${app.id}, ${domain}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
failed.push(domain);
}
}
if (quotes.length === 0) {
await callback?.({ text: ERROR_MESSAGE, actions: ["CHECK_APP_DOMAIN"] });
return {
success: false,
text: "Domain availability check failed.",
userFacingText: ERROR_MESSAGE,
data: { reason: "error", failed },
};
}
const lines = quotes.map(quoteLine);
for (const domain of failed) {
lines.push(
`✖ Couldn't check ${domain} right now — try again in a moment.`,
);
}
const firstAvailable = quotes.find((q) => q.available);
if (firstAvailable) {
lines.push(
`Say "buy ${firstAvailable.domain}" and I'll set it up (you confirm the price first).`,
);
}
if (domains.length > toCheck.length) {
lines.push(
`(I checked the first ${toCheck.length} — ask again for the rest.)`,
);
}
const reply = lines.join("\n");
await callback?.({ text: reply, actions: ["CHECK_APP_DOMAIN"] });
return {
success: true,
text: `Checked ${quotes.length} domain(s): ${quotes
.map((q) => `${q.domain}=${q.available ? "available" : "taken"}`)
.join(", ")}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: app.id, name: app.name, slug: app.slug },
quotes,
...(failed.length > 0 ? { failed } : {}),
},
};
},
examples: [
[
{
name: "{{user}}",
content: { text: "is coolbrand.com available?" },
},
{
name: "{{agent}}",
content: {
text: '✔ coolbrand.com is available — $13.99/yr (renews at $13.99/yr).\nSay "buy coolbrand.com" and I\'ll set it up (you confirm the price first).',
actions: ["CHECK_APP_DOMAIN"],
},
},
],
[
{
name: "{{user}}",
content: { text: "how much does myapp.io cost for Acme Bot?" },
},
{
name: "{{agent}}",
content: {
text: "✔ myapp.io is available — $34.99/yr (renews at $34.99/yr).",
actions: ["CHECK_APP_DOMAIN"],
},
},
],
],
};
export default checkAppDomainAction;
@@ -0,0 +1,330 @@
/**
* CREATE_APP — "build me an app called X".
*
* Parses the app name + description + monetization intent from the user's text
* (planner options win when present), calls the typed `client.createApp({...})`,
* and replies with the created draft app and an offer to deploy it.
*
* Monetization intent is passed through, but the server never enables
* monetization at create time — it creates the app with monetization off,
* persists any requested markup as a pricing default, and returns the review
* requirement in `warnings`, which this action relays to the user (#11863).
*
* Security: the create response returns the app's plaintext API key ONCE. We do
* NOT echo it into the chat reply (credentials must never transit a connector) —
* the key lives in the user's dashboard.
*
* A brand-new app has no public URL yet, so we register it with the draft
* sentinel `app_url` the server recognizes; DEPLOY_APP later assigns the real
* `production_url`.
*/
import type { CreateAppInput } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import { getCloudClient, resolveCloudApiKey } from "../client.js";
import { invalidateAppsCache } from "../providers/cloud-apps.js";
/**
* Draft sentinel URL the server recognizes as "not yet deployed" (suppresses the
* launch alert + the custom-domain DNS exact-match guard skips it). Must match
* the server/fixtures sentinel exactly — `https://placeholder.invalid` — or a
* draft app surfaces a fake URL and a pre-deploy domain buy would CNAME to it.
*/
export const DRAFT_APP_URL = "https://placeholder.invalid";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can create apps for you.";
const NO_NAME_MESSAGE =
"Sure — what should I call the app? Give me a name and I'll create it on Eliza Cloud.";
const ERROR_MESSAGE =
"I couldn't create that app right now — the Cloud API returned an error. Try again in a moment.";
export interface CreateAppIntent {
name: string | null;
description?: string;
monetization: boolean;
markupPercentage?: number;
}
const OPTION_NAME_KEYS = ["name", "appName", "app", "title"] as const;
const OPTION_DESCRIPTION_KEYS = ["description", "desc", "about"] as const;
const OPTION_MONETIZATION_KEYS = [
"monetization",
"monetize",
"monetization_enabled",
"monetizationEnabled",
"paid",
] as const;
const OPTION_MARKUP_KEYS = [
"markup",
"markupPercentage",
"inference_markup_percentage",
"inferenceMarkupPercentage",
] as const;
// Free-text name extraction. Bounded captures so a name never swallows a clause.
// The lookahead ends a name at punctuation, a continuation clause, or common
// trailing filler ("please", "now", "thanks", …) so "named Zephyr please" → "Zephyr".
const NAME_STOP =
"(?=$|[.,;!?\\n]|\\s+(?:that|which|with|to|for|and|please|now|thanks|thank|asap|today|right now|for me)\\b)";
const NAME_PATTERNS: RegExp[] = [
new RegExp(
`(?:app|bot|project|tool|site|game|agent)\\s+(?:called|named|titled)\\s+["“']?([^"”'.,\\n]{1,60}?)["”']?${NAME_STOP}`,
"i",
),
new RegExp(
`\\b(?:called|named|titled)\\s+["“']?([^"”'.,\\n]{1,60}?)["”']?${NAME_STOP}`,
"i",
),
/\bname\s*[:=]\s*["“']?([^"”'.,\n]{1,60}?)["”']?(?=$|[.,;!?\n])/i,
/["“']([^"”'\n]{2,60})["”']/,
];
function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function asBool(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (["true", "yes", "on", "1"].includes(v)) return true;
if (["false", "no", "off", "0"].includes(v)) return false;
}
return null;
}
function asNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const n = Number(value.replace(/%/g, "").trim());
if (Number.isFinite(n)) return n;
}
return null;
}
function parseNameFromText(text: string): string | null {
for (const pattern of NAME_PATTERNS) {
const match = pattern.exec(text);
const captured = match?.[1]?.trim();
if (captured && captured.length >= 1 && captured.length <= 100) {
return captured.replace(/\s+/g, " ");
}
}
return null;
}
/** Parse name/description/monetization intent from planner options + raw text. */
export function parseCreateAppIntent(
text: string,
options?: unknown,
): CreateAppIntent {
const opts =
options && typeof options === "object"
? (options as Record<string, unknown>)
: {};
const firstOption = (keys: readonly string[]): unknown => {
for (const key of keys) {
if (opts[key] !== undefined) return opts[key];
}
return undefined;
};
let name: string | null = null;
for (const key of OPTION_NAME_KEYS) {
const v = asString(opts[key]);
if (v) {
name = v;
break;
}
}
if (!name) name = parseNameFromText(text ?? "");
let description: string | undefined;
for (const key of OPTION_DESCRIPTION_KEYS) {
const v = asString(opts[key]);
if (v) {
description = v;
break;
}
}
const monetizationOpt = asBool(firstOption(OPTION_MONETIZATION_KEYS));
const monetization =
monetizationOpt ??
/\b(monetiz\w*|charge(?:s|d)?\s+(?:users|for)|paid\s+app|make\s+money|earn\b|revenue|premium|subscription)\b/i.test(
text ?? "",
);
let markupPercentage = asNumber(firstOption(OPTION_MARKUP_KEYS)) ?? undefined;
if (markupPercentage === undefined && monetization) {
const m = /(\d+(?:\.\d+)?)\s*%/.exec(text ?? "");
if (m) {
const n = Number(m[1]);
if (Number.isFinite(n)) markupPercentage = n;
}
}
if (
markupPercentage !== undefined &&
(markupPercentage < 0 || markupPercentage > 1000)
) {
markupPercentage = undefined; // out of the server's 01000 range; drop it
}
return { name, description, monetization, markupPercentage };
}
function buildCreateBody(intent: CreateAppIntent): CreateAppInput {
const body: CreateAppInput = {
name: intent.name as string,
app_url: DRAFT_APP_URL,
// Create a TEMPLATE app (no GitHub repo). The agent has no build-from-repo
// flow, and build-from-repo is intentionally OFF, so a repo-backed app would
// have no image and DEPLOY_APP would throw "build-from-repo is disabled / no
// image to deploy". With skipGitHubRepo the server stamps a first-party,
// allowlisted template image onto metadata.imageTag at create time, so the
// create -> deploy loop resolves a real image instead of failing.
skipGitHubRepo: true,
};
if (intent.description) body.description = intent.description;
// NEVER request monetization at create time: the server hard-rejects
// `monetization_enabled: true` with `app_review_required` (a new app has not
// passed review, so no app would be created at all). Apps are created
// monetization-disabled; the user enables it after the app passes review.
// Pricing defaults are still persisted so they're ready once monetization is
// switched on post-approval.
if (intent.markupPercentage !== undefined) {
body.inference_markup_percentage = intent.markupPercentage;
}
return body;
}
/**
* Guidance appended to the success reply when the user asked for a monetized
* app. The app is created un-monetized (the server gates enabling monetization
* on passing review), so we tell the user the exact two-step path to turn it on.
*/
const MONETIZATION_REVIEW_GUIDANCE =
"I created it with monetization OFF — new apps have to pass review first. " +
"To monetize it, submit it for review (POST /api/v1/apps/:id/review) and, " +
"once it's approved, enable monetization from the app's monetization settings.";
export const createAppAction: Action = {
name: "CREATE_APP",
similes: ["BUILD_APP", "MAKE_APP", "NEW_APP", "CREATE_CLOUD_APP"],
description:
"Create a new Eliza Cloud app for the user from a name (and optional description / monetization intent). Use when the user asks to build, make, create, or start a new app.",
descriptionCompressed: "Create a new Eliza Cloud app from the user's intent.",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["CREATE_APP"] });
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const intent = parseCreateAppIntent(message.content?.text ?? "", options);
if (!intent.name) {
await callback?.({ text: NO_NAME_MESSAGE, actions: ["CREATE_APP"] });
return {
success: false,
text: "No app name supplied.",
userFacingText: NO_NAME_MESSAGE,
data: { reason: "no_name" },
};
}
try {
const body = buildCreateBody(intent);
const { app, warnings } = await client.createApp(body);
// A new app now exists — drop the provider cache so it shows up this turn.
invalidateAppsCache(runtime);
const lines = [`Created "${app.name}" on Eliza Cloud (status: draft).`];
if (intent.description) lines.push(intent.description);
if (intent.monetization) {
lines.push(MONETIZATION_REVIEW_GUIDANCE);
}
if (warnings && warnings.length > 0) {
lines.push(`Note: ${warnings.join(" ")}`);
}
lines.push(`Want me to deploy it now? Just say "deploy ${app.name}".`);
const reply = lines.join("\n");
await callback?.({ text: reply, actions: ["CREATE_APP"] });
return {
success: true,
text: `Created Eliza Cloud app ${app.name}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: app.id, name: app.name, slug: app.slug },
monetization: app.monetization_enabled,
monetizationRequested: intent.monetization,
reviewStatus: app.review_status,
},
};
} catch (err) {
logger.warn(
`[CREATE_APP] Failed to create app "${intent.name}": ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["CREATE_APP"] });
return {
success: false,
text: "Failed to create Eliza Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: { text: "build me an app called Acme Bot" },
},
{
name: "{{agent}}",
content: {
text: 'Created "Acme Bot" on Eliza Cloud (status: draft).\nWant me to deploy it now? Just say "deploy Acme Bot".',
actions: ["CREATE_APP"],
},
},
],
],
};
export default createAppAction;
@@ -0,0 +1,376 @@
/**
* DELETE_APP — DESTRUCTIVE. Two-phase confirm, connector-agnostic.
*
* Deleting an app tears down its container AND its tenant database — irreversible.
* So this action NEVER deletes on the first ask:
* 1. First turn ("delete my Acme app"): resolve the app, return a confirmation
* prompt naming the exact app + what's destroyed. `deleteApp` is NOT called.
* 2. Follow-up turn carrying structured `confirm: true` for the pending
* prompt: `client.deleteApp(id)` runs exactly once.
*
* The handler never parses raw user prose for confirmation. The planner supplies
* a structured boolean and the action consumes a pending confirmation task.
*/
import type { AppDto } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import { removeAppDeployFact } from "../app-facts.js";
import {
extractAppReference,
getCloudClient,
resolveApp,
resolveCloudApiKey,
} from "../client.js";
import { invalidateAppsCache } from "../providers/cloud-apps.js";
import {
confirmationPrompt,
confirmationRoomId,
confirmTargetMismatchMessage,
conflictingConfirmTarget,
deleteCloudAppConfirmation,
findPendingCloudAppConfirmation,
persistCloudAppConfirmation,
readStructuredConfirmation,
} from "../safety.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can manage your apps.";
const NO_REFERENCE_MESSAGE =
"Which app would you like to delete? Tell me its name.";
const ERROR_MESSAGE =
"I couldn't delete that app right now — the Cloud API returned an error. Try again in a moment.";
const NO_PENDING_CONFIRMATION_MESSAGE =
"I don't have a pending delete confirmation for this room. Tell me which app to delete first, and I'll ask for confirmation.";
const CANCELED_MESSAGE = "Canceled. No Cloud app was deleted.";
/** What `deleteApp` destroys — surfaced verbatim in the confirmation prompt. */
const DESTROYED_RESOURCES = ["its running container", "its tenant database"];
function notFoundMessage(reference: string, available: string[]): string {
const base = `I couldn't find an app matching "${reference}".`;
if (available.length === 0) {
return `${base} You don't have any apps on Eliza Cloud yet.`;
}
return `${base} Your apps are: ${available.join(", ")}.`;
}
function confirmTargetFor(app: AppDto): {
name: string;
id: string;
aliases: string[];
} {
return { name: app.name, id: app.id, aliases: [app.slug] };
}
export const deleteAppAction: Action = {
name: "DELETE_APP",
similes: ["REMOVE_APP", "DELETE_MY_APP", "DESTROY_APP", "DELETE_CLOUD_APP"],
description:
"Delete an Eliza Cloud app. DESTRUCTIVE: tears down the app's container and tenant database. Requires an explicit confirmation — the first ask only confirms intent. Use when the user asks to delete, remove, or destroy an app.",
descriptionCompressed: "Delete a Cloud app (destructive; two-step confirm).",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
parameters: [
{
name: "appName",
description: "Name, slug, or id of the Cloud app to delete.",
required: false,
schema: { type: "string" },
},
{
name: "confirm",
description:
"Follow-up confirmation. Set true only when the user is confirming the pending delete prompt for this app; set false when canceling.",
required: false,
schema: { type: "boolean" },
},
],
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["DELETE_APP"] });
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const roomId = confirmationRoomId(runtime, message);
const confirmation = readStructuredConfirmation(options);
const pending = await findPendingCloudAppConfirmation(
runtime,
roomId,
"DELETE_APP",
);
if (confirmation !== null) {
if (!pending) {
await callback?.({
text: NO_PENDING_CONFIRMATION_MESSAGE,
actions: ["DELETE_APP"],
});
return {
success: false,
text: "No pending delete confirmation.",
userFacingText: NO_PENDING_CONFIRMATION_MESSAGE,
data: { reason: "no_pending_confirmation", deleted: false },
};
}
await deleteCloudAppConfirmation(runtime, pending.taskId);
if (confirmation === false) {
await callback?.({ text: CANCELED_MESSAGE, actions: ["DELETE_APP"] });
return {
success: true,
text: CANCELED_MESSAGE,
userFacingText: CANCELED_MESSAGE,
verifiedUserFacing: true,
data: { deleted: false, canceled: true },
};
}
const target = {
id: pending.metadata.appId,
name: pending.metadata.appName,
slug: pending.metadata.appSlug ?? pending.metadata.appName,
};
// Frozen-target guard: a confirm whose own params name a DIFFERENT app
// must never delete the frozen one the user is no longer talking about.
const conflict = conflictingConfirmTarget(options, {
name: target.name,
id: target.id,
aliases: [target.slug],
});
if (conflict !== null) {
const msg = confirmTargetMismatchMessage(
conflict,
"delete",
target.name,
);
await callback?.({ text: msg, actions: ["DELETE_APP"] });
return {
success: false,
text: `Confirm named "${conflict}" but the pending delete was for ${target.name}; refused.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
reason: "confirm_target_mismatch",
deleted: false,
requested: conflict,
pendingTarget: { id: target.id, name: target.name },
},
};
}
try {
const result = await client.deleteApp(target.id);
// Any delete attempt can change the app inventory — force the provider to
// re-fetch so it never serves the just-deleted app from its 60s cache.
invalidateAppsCache(runtime);
// The DELETE route runs cleanup with continueOnError and returns HTTP 200
// with { success:false, errors } on PARTIAL failure (e.g. container
// teardown failed because the node was unreachable). Don't claim
// everything is gone in that case — the tenant DB / container may survive.
if (result.success === false || (result.errors?.length ?? 0) > 0) {
const detail = result.errors?.length
? ` (${result.errors.join("; ")})`
: "";
const partial =
`I hit a problem deleting "${target.name}" — some resources may not ` +
`have been fully torn down${detail}. Check your Eliza Cloud dashboard ` +
`to confirm what remains.`;
await callback?.({ text: partial, actions: ["DELETE_APP"] });
return {
success: false,
text: result.message || `Partial delete for ${target.name}.`,
userFacingText: partial,
data: {
app: { id: target.id, name: target.name, slug: target.slug },
deleted: false,
partial: true,
errors: result.errors ?? [],
cleaned: result.cleaned,
},
};
}
// Clean success: purge the durable "app is live" fact so the agent stops
// recalling the deleted app as live at its old URL.
await removeAppDeployFact(runtime, message, target.id);
const reply = `Deleted "${target.name}". Its container and tenant database are gone.`;
await callback?.({ text: reply, actions: ["DELETE_APP"] });
return {
success: true,
text: result.message || `Deleted ${target.name}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: target.id, name: target.name, slug: target.slug },
deleted: true,
cleaned: result.cleaned,
},
};
} catch (err) {
logger.warn(
`[DELETE_APP] deleteApp(${target.id}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["DELETE_APP"] });
return {
success: false,
text: "Failed to delete Eliza Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error", deleted: false },
};
}
}
if (pending) {
const msg =
`Deletion for "${pending.metadata.appName}" is still waiting for confirmation. ` +
`Reply with a clear confirmation or cancellation.`;
await callback?.({ text: msg, actions: ["DELETE_APP"] });
return {
success: true,
text: `Awaiting structured confirmation to delete ${pending.metadata.appName}.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
app: {
id: pending.metadata.appId,
name: pending.metadata.appName,
slug: pending.metadata.appSlug,
},
deleted: false,
confirmationRequired: true,
},
};
}
const reference = extractAppReference(message, options);
if (!reference) {
await callback?.({ text: NO_REFERENCE_MESSAGE, actions: ["DELETE_APP"] });
return {
success: false,
text: "No app reference supplied.",
userFacingText: NO_REFERENCE_MESSAGE,
data: { reason: "no_reference" },
};
}
let app: AppDto | null;
let available: string[];
let ambiguous: string[] | undefined;
try {
({ app, available, ambiguous } = await resolveApp(client, reference));
} catch (err) {
logger.warn(
`[DELETE_APP] Failed to resolve app "${reference}": ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["DELETE_APP"] });
return {
success: false,
text: "Failed to resolve Eliza Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
if (!app) {
const candidates = ambiguous && ambiguous.length > 1 ? ambiguous : null;
const msg = candidates
? `Which app do you mean? "${reference}" matches ${candidates.length}: ${candidates.join(", ")}. Reply with the exact name so I don't delete the wrong one.`
: notFoundMessage(reference, available);
await callback?.({ text: msg, actions: ["DELETE_APP"] });
return {
success: false,
text: candidates
? `Ambiguous reference "${reference}" (${candidates.length} matches).`
: `No app matched "${reference}".`,
userFacingText: msg,
data: {
reason: candidates ? "ambiguous" : "not_found",
reference,
...(candidates ? { candidates } : {}),
},
};
}
const target = app;
const confirmTarget = confirmTargetFor(target);
await persistCloudAppConfirmation(runtime, {
roomId,
action: "DELETE_APP",
appId: target.id,
appName: target.name,
appSlug: target.slug,
});
const prompt = confirmationPrompt(confirmTarget, DESTROYED_RESOURCES);
await callback?.({ text: prompt, actions: ["DELETE_APP"] });
return {
success: true,
text: `Awaiting structured confirmation to delete ${target.name}.`,
userFacingText: prompt,
verifiedUserFacing: true,
data: {
app: { id: target.id, name: target.name, slug: target.slug },
deleted: false,
confirmationRequired: true,
},
};
},
examples: [
[
{ name: "{{user}}", content: { text: "delete my Acme Bot app" } },
{
name: "{{agent}}",
content: {
text: 'This will delete "Acme Bot" (…). This permanently destroys its running container and its tenant database. This can\'t be undone. To go ahead, reply that you confirm delete Acme Bot.',
actions: ["DELETE_APP"],
},
},
],
[
{ name: "{{user}}", content: { text: "I confirm deleting Acme Bot" } },
{
name: "{{agent}}",
content: {
text: 'Deleted "Acme Bot". Its container and tenant database are gone.',
actions: ["DELETE_APP"],
},
},
],
],
};
export default deleteAppAction;
@@ -0,0 +1,260 @@
/**
* DEPLOY_APP — "ship / go live with app X".
*
* Resolves the app, kicks off the deploy (`client.deployApp(id)` → 202), then
* runs the COMPLETION GATE (deploy-gate.ts): poll `getAppDeployStatus` until
* READY, then probe the authoritative `production_url` + `/health` for a 2xx.
* Only when both pass do we report the app live. Building/timeout/error/
* unreachable each produce a clear, honest failure — we never claim "live"
* without the reachability proof.
*
* On a verified-live deploy we also write the idempotent facts cache so the
* agent recalls the app later (convenience, not the gate).
*
* The completion gate is injectable, so tests pin the status progression and
* reachability decisions here while live staging deploy coverage remains owned
* by the cloud API e2e lane.
*/
import type { AppDto } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import { recordAppDeployFact } from "../app-facts.js";
import {
extractAppReference,
getCloudClient,
resolveApp,
resolveCloudApiKey,
} from "../client.js";
import {
DEFAULT_DEPLOY_GATE_CONFIG,
type DeployGateConfig,
runDeployGate,
} from "../deploy-gate.js";
import { invalidateAppsCache } from "../providers/cloud-apps.js";
import { probeReachable } from "../reachability.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can deploy your apps.";
const NO_REFERENCE_MESSAGE =
"Which app would you like to deploy? Tell me its name.";
const ERROR_MESSAGE =
"I couldn't start that deploy right now — the Cloud API returned an error. Try again in a moment.";
function notFoundMessage(reference: string, available: string[]): string {
const base = `I couldn't find an app matching "${reference}".`;
if (available.length === 0) {
return `${base} You don't have any apps on Eliza Cloud yet — ask me to create one first.`;
}
return `${base} Your apps are: ${available.join(", ")}.`;
}
async function reportLive(
runtime: IAgentRuntime,
message: Memory,
app: AppDto,
url: string,
callback?: HandlerCallback,
): Promise<ActionResult> {
const reply = `"${app.name}" is live at ${url} 🎉`;
// Best-effort, idempotent facts cache — never fails the deploy.
const fact = await recordAppDeployFact(runtime, message, app, url);
// The app's deployment status just changed — refresh the provider cache so it
// reflects the new live URL/status within the same conversation.
invalidateAppsCache(runtime);
await callback?.({ text: reply, actions: ["DEPLOY_APP"] });
return {
success: true,
text: `Deployed ${app.name} — live at ${url}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: app.id, name: app.name, slug: app.slug },
url,
phase: "ready",
factWritten: fact.written,
factUpdated: fact.updated,
},
};
}
export const deployAppAction: Action = {
name: "DEPLOY_APP",
similes: ["SHIP_APP", "GO_LIVE", "DEPLOY_CLOUD_APP", "LAUNCH_APP"],
description:
"Deploy an existing Eliza Cloud app and confirm it is live (waits for the build to finish and verifies the public URL responds). Use when the user asks to deploy, ship, launch, or go live with an app.",
descriptionCompressed: "Deploy a Cloud app and verify it is live.",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["DEPLOY_APP"] });
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const reference = extractAppReference(message, options);
if (!reference) {
await callback?.({ text: NO_REFERENCE_MESSAGE, actions: ["DEPLOY_APP"] });
return {
success: false,
text: "No app reference supplied.",
userFacingText: NO_REFERENCE_MESSAGE,
data: { reason: "no_reference" },
};
}
let app: AppDto | null;
let available: string[];
try {
({ app, available } = await resolveApp(client, reference));
} catch (err) {
logger.warn(
`[DEPLOY_APP] Failed to resolve app "${reference}": ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["DEPLOY_APP"] });
return {
success: false,
text: "Failed to resolve Eliza Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
if (!app) {
const msg = notFoundMessage(reference, available);
await callback?.({ text: msg, actions: ["DEPLOY_APP"] });
return {
success: false,
text: `No app matched "${reference}".`,
userFacingText: msg,
data: { reason: "not_found", reference },
};
}
const target = app;
try {
await client.deployApp(target.id);
} catch (err) {
logger.warn(
`[DEPLOY_APP] deployApp(${target.id}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["DEPLOY_APP"] });
return {
success: false,
text: "Failed to start deploy.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
// Acknowledge the long-running build before the gate blocks.
await callback?.({
text: `Deploying "${target.name}"… this can take a minute. I'll confirm once it's live.`,
actions: ["DEPLOY_APP"],
});
const config: DeployGateConfig = DEFAULT_DEPLOY_GATE_CONFIG;
const result = await runDeployGate(
{
// Thread the gate's per-poll abort signal into the HTTP request so a
// stalled connection is torn down at the requestTimeoutMs budget.
getStatus: (signal) => client.getAppDeployStatus(target.id, { signal }),
getApp: (signal) => client.getApp(target.id, { signal }),
probe: (url) =>
probeReachable(url, { timeoutMs: config.probeTimeoutMs }),
// A transient poll failure never aborts the gate — log and keep polling.
onPollError: (err, attempt) =>
logger.warn(
`[DEPLOY_APP] status poll ${attempt}/${config.maxAttempts} for ${
target.id
} failed (deploy continues server-side; will keep polling): ${
err instanceof Error ? err.message : String(err)
}`,
),
},
config,
);
if (result.phase === "ready" && result.url) {
return reportLive(runtime, message, target, result.url, callback);
}
// Honest failure — never claim "live" without the reachability proof.
let reply: string;
if (result.phase === "error") {
reply = `"${target.name}"'s deploy failed${
result.error ? `: ${result.error}` : ""
}. Nothing is live yet — want me to retry?`;
} else if (result.phase === "timeout") {
reply = `"${target.name}" is still building after a while (last status: ${result.status}). It may finish shortly — ask me "is ${target.name} live?" in a bit.`;
} else {
// unreachable
reply = result.url
? `"${target.name}" finished building, but ${result.url} isn't answering yet, so I won't call it live. Give it a moment and ask me to check the deploy status.`
: `"${target.name}" finished building, but it has no public URL yet, so I can't confirm it's live.`;
}
await callback?.({ text: reply, actions: ["DEPLOY_APP"] });
return {
success: false,
text: `Deploy of ${target.name} not confirmed live (${result.phase}).`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: target.id, name: target.name, slug: target.slug },
phase: result.phase,
status: result.status,
url: result.url,
attempts: result.attempts,
reason: result.phase,
},
};
},
examples: [
[
{ name: "{{user}}", content: { text: "deploy my Acme Bot app" } },
{
name: "{{agent}}",
content: {
text: '"Acme Bot" is live at https://acme.elizacloud.ai 🎉',
actions: ["DEPLOY_APP"],
},
},
],
],
};
export default deployAppAction;
@@ -0,0 +1,399 @@
/**
* DEPLOY_FRONTEND — "host / publish the frontend for app X".
*
* Publishes a static site to an app's managed frontend host: reads a built site
* directory (the `dist`/`build` output the agent produced) — or an inline
* `files` array — packages the files (text as utf8, binary as base64), and
* POSTs them to `/api/v1/apps/:id/frontend` via the SDK. The server
* content-addresses the files to R2, finalizes a manifest, and activates the
* deployment, which is then served with SEO + page analytics at the app's
* frontend host / custom domain.
*
* This is the seam that lets an agent ship a FULL app on Cloud (frontend +
* optional backend container) instead of pointing at an external URL.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import type {
DeployAppFrontendInput,
FrontendUploadFileInput,
} from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
extractAppReference,
getCloudClient,
resolveApp,
resolveCloudApiKey,
} from "../client.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can host your app's frontend.";
const NO_REFERENCE_MESSAGE =
"Which app's frontend should I publish? Tell me the app name and the built-site directory.";
const NO_SOURCE_MESSAGE =
"I need the built site to publish — give me the directory of your build output (e.g. ./dist) or the files.";
const OUTSIDE_ROOT_MESSAGE =
"I can only publish build output from the configured frontend build root.";
const ERROR_MESSAGE =
"I couldn't publish that frontend right now — the Cloud API returned an error. Try again in a moment.";
// Caps mirror the server (`app-frontend-hosting.ts`): reject before upload.
const MAX_FILES = 2000;
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
const SKIP_DIRS = new Set(["node_modules", ".git", ".turbo", ".cache"]);
const SKIP_FILES = new Set([".DS_Store", "Thumbs.db"]);
const TEXT_EXTS = new Set([
"html",
"htm",
"js",
"mjs",
"css",
"json",
"webmanifest",
"map",
"txt",
"xml",
"svg",
]);
interface FrontendIntent {
directory: string | null;
files: FrontendUploadFileInput[] | null;
entrypoint?: string;
spaFallback?: boolean;
}
const DIRECTORY_KEYS = [
"directory",
"dir",
"path",
"buildDir",
"build_dir",
"source",
];
const FRONTEND_BUILD_ROOT_SETTING = "ELIZAOS_CLOUD_FRONTEND_BUILD_ROOT";
function readOptionRecord(options: unknown): Record<string, unknown> | null {
if (!options || typeof options !== "object") return null;
const opts = options as Record<string, unknown>;
// Validated planner params arrive nested under `parameters`; fall back to top-level.
const nested = opts.parameters;
if (nested && typeof nested === "object")
return nested as Record<string, unknown>;
return opts;
}
function parseIntent(options: unknown): FrontendIntent {
const rec = readOptionRecord(options);
const intent: FrontendIntent = { directory: null, files: null };
if (!rec) return intent;
for (const key of DIRECTORY_KEYS) {
const v = rec[key];
if (typeof v === "string" && v.trim().length > 0) {
intent.directory = v.trim();
break;
}
}
if (Array.isArray(rec.files)) {
intent.files = rec.files as FrontendUploadFileInput[];
}
if (typeof rec.entrypoint === "string") intent.entrypoint = rec.entrypoint;
if (typeof rec.spaFallback === "boolean")
intent.spaFallback = rec.spaFallback;
return intent;
}
function isInsideRoot(root: string, target: string): boolean {
const rel = path.relative(root, target);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
async function resolveFrontendBuildRoot(
runtime: IAgentRuntime,
): Promise<string> {
const configured = runtime.getSetting?.(FRONTEND_BUILD_ROOT_SETTING);
const root =
typeof configured === "string" && configured.trim().length > 0
? configured.trim()
: process.cwd();
const absRoot = path.isAbsolute(root)
? path.resolve(root)
: path.resolve(process.cwd(), root);
return fs.realpath(absRoot);
}
async function resolveBuildDirectory(
runtime: IAgentRuntime,
requestedDirectory: string,
): Promise<string> {
const root = await resolveFrontendBuildRoot(runtime);
const requested = path.isAbsolute(requestedDirectory)
? path.resolve(requestedDirectory)
: path.resolve(root, requestedDirectory);
const realRequested = await fs.realpath(requested);
if (!isInsideRoot(root, realRequested)) {
throw new Error(OUTSIDE_ROOT_MESSAGE);
}
return realRequested;
}
/** Walk a build directory into upload files (text as utf8, binary as base64). */
async function readDirectoryAsFiles(
root: string,
): Promise<FrontendUploadFileInput[]> {
const files: FrontendUploadFileInput[] = [];
let totalBytes = 0;
async function walk(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
await walk(path.join(dir, entry.name));
continue;
}
if (
!entry.isFile() ||
SKIP_FILES.has(entry.name) ||
entry.name.startsWith(".")
)
continue;
const abs = path.join(dir, entry.name);
const rel = path.relative(root, abs).split(path.sep).join("/");
const bytes = await fs.readFile(abs);
totalBytes += bytes.byteLength;
if (files.length + 1 > MAX_FILES) {
throw new Error(
`Too many files (> ${MAX_FILES}). Trim the build output.`,
);
}
if (totalBytes > MAX_TOTAL_BYTES) {
throw new Error(
`Build exceeds the ${MAX_TOTAL_BYTES / (1024 * 1024)}MB frontend cap.`,
);
}
const ext = rel.split(".").pop()?.toLowerCase() ?? "";
if (TEXT_EXTS.has(ext)) {
files.push({
path: rel,
content: bytes.toString("utf8"),
encoding: "utf8",
});
} else {
files.push({
path: rel,
content: bytes.toString("base64"),
encoding: "base64",
});
}
}
}
await walk(root);
return files;
}
export const deployFrontendAction: Action = {
name: "DEPLOY_FRONTEND",
similes: [
"HOST_FRONTEND",
"PUBLISH_SITE",
"PUBLISH_FRONTEND",
"DEPLOY_SITE",
"HOST_SITE",
],
description:
"Publish a static frontend (built site directory or files) to an Eliza Cloud app's managed host, served with SEO + analytics. Use when the user asks to host, publish, or deploy the app's website/frontend.",
descriptionCompressed:
"Publish an app's static frontend to Eliza Cloud managed hosting.",
contexts: ["settings", "apps"],
contextGate: { anyOf: ["settings", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["DEPLOY_FRONTEND"] });
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const reference = extractAppReference(message, options);
if (!reference) {
await callback?.({
text: NO_REFERENCE_MESSAGE,
actions: ["DEPLOY_FRONTEND"],
});
return {
success: false,
text: "No app reference supplied.",
userFacingText: NO_REFERENCE_MESSAGE,
data: { reason: "no_reference" },
};
}
const { app, available } = await resolveApp(client, reference);
if (!app) {
const msg =
available.length === 0
? "You don't have any apps on Eliza Cloud yet — ask me to create one first."
: `I couldn't find an app matching "${reference}". Your apps are: ${available.join(", ")}.`;
await callback?.({ text: msg, actions: ["DEPLOY_FRONTEND"] });
return {
success: false,
text: "App not found.",
userFacingText: msg,
data: { reason: "not_found" },
};
}
const intent = parseIntent(options);
let files: FrontendUploadFileInput[];
try {
if (intent.files && intent.files.length > 0) {
files = intent.files;
} else if (intent.directory) {
files = await readDirectoryAsFiles(
await resolveBuildDirectory(runtime, intent.directory),
);
} else {
await callback?.({
text: NO_SOURCE_MESSAGE,
actions: ["DEPLOY_FRONTEND"],
});
return {
success: false,
text: "No build directory or files provided.",
userFacingText: NO_SOURCE_MESSAGE,
data: { reason: "no_source" },
};
}
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
await callback?.({
text: `I couldn't read the build: ${detail}`,
actions: ["DEPLOY_FRONTEND"],
});
return {
success: false,
text: "Failed to read build output.",
userFacingText: `I couldn't read the build: ${detail}`,
error: err instanceof Error ? err : new Error(detail),
data: { reason: "read_failed" },
};
}
if (files.length === 0) {
await callback?.({
text: NO_SOURCE_MESSAGE,
actions: ["DEPLOY_FRONTEND"],
});
return {
success: false,
text: "No files to publish.",
userFacingText: NO_SOURCE_MESSAGE,
data: { reason: "empty" },
};
}
try {
const body: DeployAppFrontendInput = {
files,
entrypoint: intent.entrypoint,
spaFallback: intent.spaFallback,
buildMeta: { source: "agent" },
};
const { deployment } = await client.deployAppFrontend(app.id, body);
// Don't claim "live" unless the deployment actually activated. The
// publish can succeed into a "ready" (built, not serving) state where
// activation failed or is pending — reporting that as live is a lie the
// user acts on. Only status === "active" is truthfully live.
const size = `${deployment.file_count} files, ${(deployment.total_bytes / 1024).toFixed(0)} KB`;
const isLive = deployment.status === "active";
const reply = isLive
? [
`Published "${app.name}" frontend — v${deployment.version} is now live (${size}).`,
`Preview it under your app's frontend host. Attach a custom domain to serve it publicly.`,
].join("\n")
: [
`Published "${app.name}" frontend v${deployment.version} (${size}) — it's built but NOT yet live (status: ${deployment.status}).`,
`Activation hasn't completed; try again shortly, or check the app's frontend deployments if it stays this way.`,
].join("\n");
await callback?.({ text: reply, actions: ["DEPLOY_FRONTEND"] });
return {
success: true,
text: `Published frontend v${deployment.version} for ${app.name}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: app.id, name: app.name },
deployment: {
id: deployment.id,
version: deployment.version,
status: deployment.status,
files: deployment.file_count,
bytes: deployment.total_bytes,
},
},
};
} catch (err) {
logger.warn(
`[DEPLOY_FRONTEND] Failed to publish frontend for "${app.name}": ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["DEPLOY_FRONTEND"] });
return {
success: false,
text: "Failed to publish frontend.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: { text: "publish the frontend for Acme Bot from ./dist" },
},
{
name: "{{agent}}",
content: {
text: 'Published "Acme Bot" frontend — v1 is now live (12 files, 340 KB).\nPreview it under your app\'s frontend host. Attach a custom domain to serve it publicly.',
actions: ["DEPLOY_FRONTEND"],
},
},
],
],
};
export default deployFrontendAction;
@@ -0,0 +1,187 @@
/**
* GET_APP_DEPLOY_STATUS — "is my app live? / what's the deploy status?".
*
* Resolves the app, reads `client.getAppDeployStatus(id)`, and formats the
* public lifecycle (DRAFT / BUILDING / DEPLOYING / READY / ERROR) plus the URL.
* Read-only.
*/
import type { AppDeployStatusResponse, AppDto } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
appUrl,
extractAppReference,
getCloudClient,
resolveApp,
resolveCloudApiKey,
} from "../client.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can check your deploys.";
const NO_REFERENCE_MESSAGE =
"Which app's deploy status would you like? Tell me its name.";
const ERROR_MESSAGE =
"I couldn't check that deploy status right now — the Cloud API returned an error. Try again in a moment.";
function notFoundMessage(reference: string, available: string[]): string {
const base = `I couldn't find an app matching "${reference}".`;
if (available.length === 0) {
return `${base} You don't have any apps on Eliza Cloud yet.`;
}
return `${base} Your apps are: ${available.join(", ")}.`;
}
/** Format the public deploy status into a human reply. */
export function formatDeployStatus(
app: AppDto,
statusRes: AppDeployStatusResponse,
): string {
const raw = (statusRes.status ?? "").trim();
const s = raw.toUpperCase();
const url = statusRes.vercelUrl ?? appUrl(app);
switch (s) {
case "DRAFT":
return `"${app.name}" hasn't been deployed yet (draft). Say "deploy ${app.name}" to ship it.`;
case "BUILDING":
case "DEPLOYING":
return `"${app.name}" is building right now — I'll have a live URL once it finishes.`;
case "READY":
case "DEPLOYED":
return url
? `"${app.name}" is live at ${url}.`
: `"${app.name}" is deployed.`;
case "ERROR":
case "FAILED":
return `"${app.name}"'s last deploy failed${
statusRes.error ? `: ${statusRes.error}` : ""
}. Want me to retry?`;
default:
return `"${app.name}" deploy status: ${raw || "unknown"}.`;
}
}
export const getAppDeployStatusAction: Action = {
name: "GET_APP_DEPLOY_STATUS",
similes: [
"IS_MY_APP_LIVE",
"DEPLOY_STATUS",
"APP_DEPLOY_STATUS",
"IS_APP_DEPLOYED",
],
description:
"Report the deployment status of an Eliza Cloud app (draft / building / live / failed) and its URL. Use when the user asks whether an app is live, deployed, or done building.",
descriptionCompressed:
"Report an app's deploy status (draft/building/live/failed).",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["GET_APP_DEPLOY_STATUS"],
});
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const reference = extractAppReference(message, options);
if (!reference) {
await callback?.({
text: NO_REFERENCE_MESSAGE,
actions: ["GET_APP_DEPLOY_STATUS"],
});
return {
success: false,
text: "No app reference supplied.",
userFacingText: NO_REFERENCE_MESSAGE,
data: { reason: "no_reference" },
};
}
try {
const { app, available } = await resolveApp(client, reference);
if (!app) {
const msg = notFoundMessage(reference, available);
await callback?.({ text: msg, actions: ["GET_APP_DEPLOY_STATUS"] });
return {
success: false,
text: `No app matched "${reference}".`,
userFacingText: msg,
data: { reason: "not_found", reference },
};
}
const statusRes = await client.getAppDeployStatus(app.id);
const reply = formatDeployStatus(app, statusRes);
await callback?.({ text: reply, actions: ["GET_APP_DEPLOY_STATUS"] });
return {
success: true,
text: `Deploy status for ${app.name}: ${statusRes.status}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: app.id, name: app.name, slug: app.slug },
status: statusRes.status,
url: statusRes.vercelUrl ?? appUrl(app),
},
};
} catch (err) {
logger.warn(
`[GET_APP_DEPLOY_STATUS] Failed for "${reference}": ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({
text: ERROR_MESSAGE,
actions: ["GET_APP_DEPLOY_STATUS"],
});
return {
success: false,
text: "Failed to check deploy status.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{ name: "{{user}}", content: { text: "is my Acme Bot app live yet?" } },
{
name: "{{agent}}",
content: {
text: '"Acme Bot" is live at https://acme.elizacloud.ai.',
actions: ["GET_APP_DEPLOY_STATUS"],
},
},
],
],
};
export default getAppDeployStatusAction;
@@ -0,0 +1,257 @@
/**
* GET_APP_EARNINGS — "how much have I earned from app X?" READ-ONLY.
*
* Resolves the app, calls the typed `client.getAppEarnings(id)` (wrapping
* `GET /api/v1/apps/:id/earnings`), and formats the earnings summary:
* withdrawable balance, pending balance, lifetime earnings, total withdrawn, and
* the payout threshold. Degrades gracefully when there is no key, no earnings, or
* monetization is off. No mutating calls, no money movement.
*/
import type { AppDto } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
extractAppReference,
getCloudClient,
resolveApp,
resolveCloudApiKey,
} from "../client.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can check your earnings.";
const NO_REFERENCE_MESSAGE =
"Which app's earnings would you like to see? Tell me its name.";
const ERROR_MESSAGE =
"I couldn't fetch those earnings right now — the Cloud API returned an error. Try again in a moment.";
/** The earnings fields the summary block reads. */
export interface EarningsView {
withdrawableBalance: number;
pendingBalance: number;
totalLifetimeEarnings: number;
totalWithdrawn: number;
payoutThreshold: number;
}
function asNum(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const n = Number(value);
if (Number.isFinite(n)) return n;
}
return null;
}
/** Pull the earnings summary out of the (loosely-typed) earnings envelope. */
export function extractEarningsView(
earnings: Record<string, unknown> | undefined,
): EarningsView | null {
const summary =
earnings && typeof earnings.summary === "object" && earnings.summary
? (earnings.summary as Record<string, unknown>)
: null;
if (!summary) return null;
return {
withdrawableBalance: asNum(summary.withdrawableBalance) ?? 0,
pendingBalance: asNum(summary.pendingBalance) ?? 0,
totalLifetimeEarnings: asNum(summary.totalLifetimeEarnings) ?? 0,
totalWithdrawn: asNum(summary.totalWithdrawn) ?? 0,
payoutThreshold: asNum(summary.payoutThreshold) ?? 0,
};
}
function monetizationEnabled(
monetization: Record<string, unknown> | undefined,
): boolean {
return monetization?.enabled === true;
}
/** Human earnings block for the reply (exported for tests). */
export function formatEarnings(
app: AppDto,
earnings: Record<string, unknown> | undefined,
monetization: Record<string, unknown> | undefined,
): string {
const view = extractEarningsView(earnings);
const enabled = monetizationEnabled(monetization) || app.monetization_enabled;
if (!view || view.totalLifetimeEarnings === 0) {
if (!enabled) {
return `"${app.name}" isn't earning yet — monetization is off. Turn it on and I'll start tracking earnings.`;
}
return `"${app.name}" has no earnings yet. Once users run paid inference through it, earnings will show up here.`;
}
const usd = (n: number): string => `$${n.toFixed(2)}`;
const lines = [
`"${app.name}" earnings:`,
`• Withdrawable now: ${usd(view.withdrawableBalance)}`,
];
if (view.pendingBalance > 0) {
lines.push(`• Pending (clearing): ${usd(view.pendingBalance)}`);
}
lines.push(`• Lifetime: ${usd(view.totalLifetimeEarnings)}`);
if (view.totalWithdrawn > 0) {
lines.push(`• Withdrawn so far: ${usd(view.totalWithdrawn)}`);
}
if (view.payoutThreshold > 0) {
const ready = view.withdrawableBalance >= view.payoutThreshold;
lines.push(
ready
? `You can withdraw now (minimum payout ${usd(view.payoutThreshold)}).`
: `Minimum payout is ${usd(view.payoutThreshold)} — keep earning to reach it.`,
);
}
return lines.join("\n");
}
function notFoundMessage(reference: string, available: string[]): string {
const base = `I couldn't find an app matching "${reference}".`;
if (available.length === 0) {
return `${base} You don't have any apps on Eliza Cloud yet.`;
}
return `${base} Your apps are: ${available.join(", ")}.`;
}
export const getAppEarningsAction: Action = {
name: "GET_APP_EARNINGS",
similes: [
"HOW_MUCH_HAVE_I_EARNED",
"MY_EARNINGS",
"APP_EARNINGS",
"SHOW_EARNINGS",
"CHECK_EARNINGS",
],
description:
"Show how much an Eliza Cloud app has earned — withdrawable balance, pending balance, lifetime earnings, and amount withdrawn. Read-only. Use when the user asks how much they've earned or about an app's revenue/earnings.",
descriptionCompressed: "Show a Cloud app's earnings (read-only).",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["GET_APP_EARNINGS"] });
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const reference = extractAppReference(message, options);
if (!reference) {
await callback?.({
text: NO_REFERENCE_MESSAGE,
actions: ["GET_APP_EARNINGS"],
});
return {
success: false,
text: "No app reference supplied.",
userFacingText: NO_REFERENCE_MESSAGE,
data: { reason: "no_reference" },
};
}
let app: AppDto | null;
let available: string[];
try {
({ app, available } = await resolveApp(client, reference));
} catch (err) {
logger.warn(
`[GET_APP_EARNINGS] Failed to resolve app "${reference}": ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["GET_APP_EARNINGS"] });
return {
success: false,
text: "Failed to resolve Eliza Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
if (!app) {
const msg = notFoundMessage(reference, available);
await callback?.({ text: msg, actions: ["GET_APP_EARNINGS"] });
return {
success: false,
text: `No app matched "${reference}".`,
userFacingText: msg,
data: { reason: "not_found", reference },
};
}
const target = app;
try {
const res = await client.getAppEarnings(target.id);
const reply = formatEarnings(target, res.earnings, res.monetization);
const view = extractEarningsView(res.earnings);
await callback?.({ text: reply, actions: ["GET_APP_EARNINGS"] });
return {
success: true,
text: `Fetched earnings for ${target.name}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: target.id, name: target.name, slug: target.slug },
withdrawableBalance: view?.withdrawableBalance ?? 0,
lifetimeEarnings: view?.totalLifetimeEarnings ?? 0,
},
};
} catch (err) {
logger.warn(
`[GET_APP_EARNINGS] getAppEarnings(${target.id}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["GET_APP_EARNINGS"] });
return {
success: false,
text: "Failed to fetch earnings.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: { text: "how much have I earned from Acme Bot?" },
},
{
name: "{{agent}}",
content: {
text: '"Acme Bot" earnings:\n• Withdrawable now: $42.00\n• Lifetime: $58.00\nYou can withdraw now (minimum payout $25.00).',
actions: ["GET_APP_EARNINGS"],
},
},
],
],
};
export default getAppEarningsAction;
@@ -0,0 +1,163 @@
/**
* GET_APP — "tell me about app X".
*
* Resolves an app by id or name from the user's text (or planner-supplied
* options), then formats a detail block. Id-shaped references take the direct
* `client.getApp(id)` path; names resolve via `client.listApps()` +
* find-by-name. Read-only: no mutating calls.
*/
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
extractAppReference,
findAppByReference,
formatAppDetail,
getCloudClient,
looksLikeAppId,
resolveCloudApiKey,
} from "../client.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can look up your apps.";
const NO_REFERENCE_MESSAGE =
"Which app would you like to know about? Tell me its name and I'll pull up the details.";
const ERROR_MESSAGE =
"I couldn't fetch that app's details right now — the Cloud API returned an error. Try again in a moment.";
function notFoundMessage(reference: string, available: string[]): string {
const base = `I couldn't find an app matching "${reference}".`;
if (available.length === 0) {
return `${base} You don't have any apps on Eliza Cloud yet.`;
}
return `${base} Your apps are: ${available.join(", ")}.`;
}
export const getAppAction: Action = {
name: "GET_APP",
similes: [
"APP_DETAILS",
"SHOW_APP",
"TELL_ME_ABOUT_APP",
"APP_INFO",
"DESCRIBE_APP",
],
description:
"Show details about one specific Eliza Cloud app the user owns (URL, deployment status, credits used, earnings, users). Use when the user asks about a particular app by name or id.",
descriptionCompressed: "Show details for one Eliza Cloud app by name/id.",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["GET_APP"] });
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const reference = extractAppReference(message, options);
if (!reference) {
await callback?.({ text: NO_REFERENCE_MESSAGE, actions: ["GET_APP"] });
return {
success: false,
text: "No app reference supplied.",
userFacingText: NO_REFERENCE_MESSAGE,
data: { reason: "no_reference" },
};
}
try {
// Id-shaped reference → direct single-app fetch.
if (looksLikeAppId(reference)) {
const { app } = await client.getApp(reference);
if (app) {
const detail = formatAppDetail(app);
await callback?.({ text: detail, actions: ["GET_APP"] });
return {
success: true,
text: `Fetched app ${app.name}.`,
userFacingText: detail,
verifiedUserFacing: true,
data: { app: { id: app.id, name: app.name, slug: app.slug } },
};
}
}
// Name/slug reference → list + find.
const { apps } = await client.listApps();
const found = findAppByReference(apps ?? [], reference);
if (!found) {
const names = (apps ?? []).map((a) => a.name);
const msg = notFoundMessage(reference, names);
await callback?.({ text: msg, actions: ["GET_APP"] });
return {
success: false,
text: `No app matched "${reference}".`,
userFacingText: msg,
data: { reason: "not_found", reference },
};
}
const detail = formatAppDetail(found);
await callback?.({ text: detail, actions: ["GET_APP"] });
return {
success: true,
text: `Fetched app ${found.name}.`,
userFacingText: detail,
verifiedUserFacing: true,
data: { app: { id: found.id, name: found.name, slug: found.slug } },
};
} catch (err) {
logger.warn(
`[GET_APP] Failed to fetch app "${reference}": ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["GET_APP"] });
return {
success: false,
text: "Failed to fetch Eliza Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{ name: "{{user}}", content: { text: "tell me about my Acme Bot app" } },
{
name: "{{agent}}",
content: {
text: "Acme Bot (acme-bot)\nURL: https://acme.elizacloud.ai\nStatus: deployed\nCredits used: $12.40",
actions: ["GET_APP"],
},
},
],
],
};
export default getAppAction;
@@ -0,0 +1,210 @@
/**
* Influencer marketplace agent actions (#10687).
*
* CREATE_INFLUENCER_PROFILE — the agent publishes an influencer profile so it
* can be booked (and earn) by advertisers.
* LIST_INFLUENCERS — browse active influencer profiles (advertiser
* discovery, before booking).
*/
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import { getCloudClient, resolveCloudApiKey } from "../client.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY.";
function readOpt(options: unknown): Record<string, unknown> {
if (!options || typeof options !== "object") return {};
const o = options as Record<string, unknown>;
const nested = o.parameters;
return nested && typeof nested === "object"
? (nested as Record<string, unknown>)
: o;
}
export const createInfluencerProfileAction: Action = {
name: "CREATE_INFLUENCER_PROFILE",
similes: [
"BECOME_INFLUENCER",
"PUBLISH_INFLUENCER_PROFILE",
"OFFER_INFLUENCER_SERVICES",
],
description:
"Publish an influencer profile on Eliza Cloud so the agent/user can be booked by advertisers and earn. Use when the user wants to become / list as an influencer or offer promotion services.",
descriptionCompressed: "Publish an influencer profile to be booked + earn.",
contexts: ["settings", "finance"],
contextGate: { anyOf: ["settings", "finance"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
_message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["CREATE_INFLUENCER_PROFILE"],
});
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const rec = readOpt(options);
const displayName =
typeof rec.displayName === "string" && rec.displayName.trim()
? rec.displayName.trim()
: runtime.character?.name || "Creator";
const niche = typeof rec.niche === "string" ? rec.niche : undefined;
try {
const { profile } = await client.createInfluencerProfile({
displayName,
niche,
});
const reply = `Published your influencer profile "${profile.display_name}"${profile.niche ? ` (${profile.niche})` : ""}. Advertisers can now book you.`;
await callback?.({ text: reply, actions: ["CREATE_INFLUENCER_PROFILE"] });
return {
success: true,
text: `Published influencer profile ${profile.display_name}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
profile: { id: profile.id, displayName: profile.display_name },
},
};
} catch (err) {
logger.warn(
`[CREATE_INFLUENCER_PROFILE] failed: ${err instanceof Error ? err.message : String(err)}`,
);
const msg = "I couldn't publish that profile right now.";
await callback?.({ text: msg, actions: ["CREATE_INFLUENCER_PROFILE"] });
return {
success: false,
text: "Failed to publish profile.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{ name: "{{user}}", content: { text: "list me as a tech influencer" } },
{
name: "{{agent}}",
content: {
text: 'Published your influencer profile "Creator" (tech). Advertisers can now book you.',
actions: ["CREATE_INFLUENCER_PROFILE"],
},
},
],
],
};
export const listInfluencersAction: Action = {
name: "LIST_INFLUENCERS",
similes: ["BROWSE_INFLUENCERS", "FIND_INFLUENCERS", "SEARCH_INFLUENCERS"],
description:
"Browse active influencer profiles on Eliza Cloud (optionally by niche) so the user can pick one to book for promotion. Use when the user wants to find / hire an influencer.",
descriptionCompressed: "Browse influencer profiles to book for promotion.",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
_message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["LIST_INFLUENCERS"] });
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const rec = readOpt(options);
const niche =
typeof rec.niche === "string" && rec.niche.trim()
? rec.niche.trim()
: undefined;
try {
const { profiles } = await client.listInfluencers(niche);
const reply =
profiles.length === 0
? `No influencer profiles${niche ? ` in "${niche}"` : ""} found yet.`
: `${profiles.length} influencer(s)${niche ? ` in "${niche}"` : ""}:\n${profiles
.map((p) => {
const reach = p.platforms.reduce(
(n, pl) => n + (pl.followers || 0),
0,
);
return `${p.display_name}${p.niche ? ` (${p.niche})` : ""}${reach ? ` — ~${reach.toLocaleString()} followers` : ""}`;
})
.join("\n")}`;
await callback?.({ text: reply, actions: ["LIST_INFLUENCERS"] });
return {
success: true,
text: `Listed ${profiles.length} influencers.`,
userFacingText: reply,
verifiedUserFacing: true,
data: { count: profiles.length },
};
} catch (err) {
logger.warn(
`[LIST_INFLUENCERS] failed: ${err instanceof Error ? err.message : String(err)}`,
);
const msg = "I couldn't browse influencers right now.";
await callback?.({ text: msg, actions: ["LIST_INFLUENCERS"] });
return {
success: false,
text: "Failed to list influencers.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: { text: "find tech influencers to promote my app" },
},
{
name: "{{agent}}",
content: {
text: '2 influencer(s) in "tech":\n• Creator (tech) — ~50,000 followers',
actions: ["LIST_INFLUENCERS"],
},
},
],
],
};
@@ -0,0 +1,219 @@
/**
* LIST_APP_DOMAINS — READ-ONLY list of the domains attached to a Cloud app.
*
* Wraps `GET /api/v1/apps/:id/domains` and reports each attachment's
* registrar, status, SSL state, verification state (with the exact TXT record
* hint for unverified external domains), and renewal date.
*/
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import { getCloudClient, resolveCloudApiKey } from "../client.js";
import { formatDomainLine, resolveDomainTargetApp } from "../domain-intent.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can list your domains.";
const NO_APPS_MESSAGE =
"You don't have any Cloud apps yet, so there are no domains to list.";
const ERROR_MESSAGE =
"I couldn't fetch that app's domains right now — the Cloud API returned an error. Try again in a moment.";
function notFoundMessage(available: string[]): string {
return `Which app's domains should I list? Your apps are: ${available.join(", ")}.`;
}
export const listAppDomainsAction: Action = {
name: "LIST_APP_DOMAINS",
similes: [
"LIST_DOMAINS",
"SHOW_DOMAINS",
"MY_DOMAINS",
"APP_DOMAINS",
"WHAT_DOMAINS",
],
description:
"List the custom domains attached to an Eliza Cloud app, with registrar, status, SSL, verification state, and renewal date. Read-only. Use when the user asks what domains an app has or whether a domain is set up/verified.",
descriptionCompressed: "List a Cloud app's attached domains (read-only).",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
parameters: [
{
name: "appName",
description: "Name, slug, or id of the Cloud app whose domains to list.",
required: false,
schema: { type: "string" },
},
],
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["LIST_APP_DOMAINS"] });
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
let resolved: Awaited<ReturnType<typeof resolveDomainTargetApp>>;
try {
resolved = await resolveDomainTargetApp(client, message, options);
} catch (err) {
logger.warn(
`[LIST_APP_DOMAINS] failed to resolve app: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["LIST_APP_DOMAINS"] });
return {
success: false,
text: "Failed to resolve the Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
if (!resolved.app) {
if (resolved.available.length === 0) {
await callback?.({
text: NO_APPS_MESSAGE,
actions: ["LIST_APP_DOMAINS"],
});
return {
success: false,
text: "User has no Cloud apps.",
userFacingText: NO_APPS_MESSAGE,
data: { reason: "no_apps" },
};
}
const candidates =
resolved.ambiguous && resolved.ambiguous.length > 1
? resolved.ambiguous
: resolved.available;
const msg = resolved.ambiguous
? `Which app do you mean? That matches ${candidates.length}: ${candidates.join(", ")}. Reply with the exact name.`
: notFoundMessage(candidates);
await callback?.({ text: msg, actions: ["LIST_APP_DOMAINS"] });
return {
success: false,
text: resolved.ambiguous
? "Ambiguous app reference."
: "No app matched the reference.",
userFacingText: msg,
data: {
reason: resolved.ambiguous ? "ambiguous" : "not_found",
candidates,
},
};
}
const app = resolved.app;
try {
const { domains } = await client.listAppDomains(app.id);
const rows = domains ?? [];
if (rows.length === 0) {
const msg = `"${app.name}" has no custom domains yet. Say "buy yourbrand.com for ${app.name}" and I'll check the price.`;
await callback?.({ text: msg, actions: ["LIST_APP_DOMAINS"] });
return {
success: true,
text: `${app.name} has no attached domains.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
app: { id: app.id, name: app.name, slug: app.slug },
domains: [],
},
};
}
const reply = [
`"${app.name}" has ${rows.length} domain${rows.length === 1 ? "" : "s"}:`,
...rows.map(formatDomainLine),
].join("\n");
await callback?.({ text: reply, actions: ["LIST_APP_DOMAINS"] });
return {
success: true,
text: `Listed ${rows.length} domain(s) for ${app.name}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: app.id, name: app.name, slug: app.slug },
domains: rows.map((d) => ({
domain: d.domain,
registrar: d.registrar,
status: d.status,
verified: d.verified,
sslStatus: d.sslStatus,
expiresAt: d.expiresAt,
verificationToken: d.verificationToken,
})),
},
};
} catch (err) {
logger.warn(
`[LIST_APP_DOMAINS] listAppDomains(${app.id}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["LIST_APP_DOMAINS"] });
return {
success: false,
text: "Failed to list app domains.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: { text: "what domains does Acme Bot have?" },
},
{
name: "{{agent}}",
content: {
text: '"Acme Bot" has 1 domain:\n• coolbrand.com — registered through Eliza Cloud, active, SSL active, renews 2027-07-01',
actions: ["LIST_APP_DOMAINS"],
},
},
],
[
{
name: "{{user}}",
content: { text: "is my custom domain verified yet?" },
},
{
name: "{{agent}}",
content: {
text: '"Acme Bot" has 1 domain:\n• example.org — external, pending, SSL pending, needs DNS verification (add the TXT record at _eliza-cloud-verify.example.org)',
actions: ["LIST_APP_DOMAINS"],
},
},
],
],
};
export default listAppDomainsAction;
@@ -0,0 +1,146 @@
/**
* LIST_CLOUD_APPS — answer "what apps do I have on Eliza Cloud?".
*
* Reads the authenticated org's apps via the typed SDK (`client.listApps()`),
* formats a clean reply (name / url / status), and handles the empty + no-key +
* error paths gracefully. Read-only: no mutating calls.
*/
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
formatAppLine,
getCloudClient,
resolveCloudApiKey,
} from "../client.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY (from elizacloud.ai → dashboard → API keys) and I can list your apps.";
const EMPTY_MESSAGE =
"You haven't created any apps on Eliza Cloud yet. You can build one from the Apps view or just ask me to create an app.";
const ERROR_MESSAGE =
"I couldn't fetch your Eliza Cloud apps right now — the Cloud API returned an error. Try again in a moment.";
export const listCloudAppsAction: Action = {
name: "LIST_CLOUD_APPS",
similes: [
"MY_APPS",
"GET_APPS",
"WHAT_APPS_DO_I_HAVE",
"MY_CLOUD_APPS",
"LIST_APPS",
],
description:
"List the Eliza Cloud apps the user owns (name, URL, deployment status, and credits/earnings when present). Use when the user asks what apps they have, to see their apps, or to list their Cloud apps.",
descriptionCompressed: "List the user's Eliza Cloud apps (name/url/status).",
// Read-only inventory lookup; safe on any user turn.
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
_message: Memory,
_state?: State,
_options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["LIST_CLOUD_APPS"] });
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
try {
const { apps } = await client.listApps();
if (!apps || apps.length === 0) {
await callback?.({ text: EMPTY_MESSAGE, actions: ["LIST_CLOUD_APPS"] });
return {
success: true,
text: "User has no Eliza Cloud apps.",
userFacingText: EMPTY_MESSAGE,
data: { count: 0, apps: [] },
};
}
const header =
apps.length === 1
? "You have 1 app on Eliza Cloud:"
: `You have ${apps.length} apps on Eliza Cloud:`;
const body = apps.map(formatAppLine).join("\n");
const reply = `${header}\n${body}`;
await callback?.({ text: reply, actions: ["LIST_CLOUD_APPS"] });
return {
success: true,
text: `Listed ${apps.length} Eliza Cloud app(s).`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
count: apps.length,
apps: apps.map((a) => ({
id: a.id,
name: a.name,
slug: a.slug,
status: a.deployment_status,
})),
},
};
} catch (err) {
logger.warn(
`[LIST_CLOUD_APPS] Failed to list apps: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["LIST_CLOUD_APPS"] });
return {
success: false,
text: "Failed to list Eliza Cloud apps.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{ name: "{{user}}", content: { text: "what apps do I have?" } },
{
name: "{{agent}}",
content: {
text: "You have 2 apps on Eliza Cloud:\n• Acme Bot — https://acme.elizacloud.ai — deployed\n• Side Project — https://side.example.com — draft",
actions: ["LIST_CLOUD_APPS"],
},
},
],
[
{ name: "{{user}}", content: { text: "list my cloud apps" } },
{
name: "{{agent}}",
content: {
text: "You haven't created any apps on Eliza Cloud yet.",
actions: ["LIST_CLOUD_APPS"],
},
},
],
],
};
export default listCloudAppsAction;
@@ -0,0 +1,598 @@
/**
* Press release agent actions (#11819).
*
* DRAFT_PRESS_RELEASE — create a Cloud-owned press release draft.
* LIST_PRESS_RELEASES — list the org's drafts/submissions.
* SUBMIT_PRESS_RELEASE — two-phase provider-backed submit; currently fails
* closed until a real distribution provider exists.
*/
import type { PressReleaseDto } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
getCloudClient,
matchByReference,
resolveCloudApiKey,
} from "../client.js";
import { cloudErrorInfo } from "../domain-intent.js";
import {
CONFIRM_TTL_MS,
confirmationRoomId,
confirmTargetMismatchMessage,
conflictingConfirmTarget,
deleteCloudAppConfirmation,
findPendingCloudAppConfirmation,
markCloudAppConfirmationRecovery,
pendingExpired,
persistCloudAppConfirmation,
readStructuredConfirmation,
} from "../safety.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY.";
const MISSING_DRAFT_INPUT = "I need a title and body to draft a press release.";
const NO_PENDING_MESSAGE =
"I don't have a pending press-release submit confirmation for this room. Tell me which draft to submit first, and I'll ask for confirmation.";
const CANCELED_MESSAGE = "Okay, I won't submit that press release.";
function readOpt(options: unknown): Record<string, unknown> {
if (!options || typeof options !== "object") return {};
const o = options as Record<string, unknown>;
const nested = o.parameters;
return nested && typeof nested === "object"
? (nested as Record<string, unknown>)
: o;
}
function readString(
rec: Record<string, unknown>,
...keys: string[]
): string | undefined {
for (const key of keys) {
const value = rec[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function readStringArray(
rec: Record<string, unknown>,
key: string,
): string[] | undefined {
const value = rec[key];
if (!Array.isArray(value)) return undefined;
const strings = value.filter(
(item): item is string =>
typeof item === "string" && item.trim().length > 0,
);
return strings.length > 0 ? strings.map((item) => item.trim()) : undefined;
}
function releaseLine(release: PressReleaseDto): string {
const date =
release.updated_at?.slice(0, 10) ?? release.created_at?.slice(0, 10);
return `${release.title}${release.status}${date ? ` — updated ${date}` : ""}`;
}
function releaseReference(message: Memory, options: unknown): string {
const rec = readOpt(options);
return (
readString(rec, "releaseId", "pressReleaseId", "title", "name", "query") ??
(message.content?.text ?? "").trim()
);
}
async function resolveRelease(
client: NonNullable<ReturnType<typeof getCloudClient>>,
reference: string,
): Promise<{
release: PressReleaseDto | null;
available: string[];
ambiguous?: string[];
}> {
const { releases } = await client.listPressReleases();
const match = matchByReference(releases, reference, (release) => ({
id: release.id,
names: [release.title],
}));
return {
release: match.item,
available: releases.map((release) => release.title),
ambiguous:
match.item === null && match.candidates.length > 1
? match.candidates.map((release) => release.title)
: undefined,
};
}
export const draftPressReleaseAction: Action = {
name: "DRAFT_PRESS_RELEASE",
similes: ["CREATE_PRESS_RELEASE", "DRAFT_PR", "WRITE_PRESS_RELEASE"],
description:
"Create a draft press release in Eliza Cloud. Use when the user asks to draft or save a PR/press release for later distribution.",
descriptionCompressed: "Create a draft press release.",
contexts: ["settings", "apps"],
contextGate: { anyOf: ["settings", "apps"] },
suppressPostActionContinuation: true,
parameters: [
{
name: "title",
description: "Press release headline/title.",
required: true,
schema: { type: "string" },
},
{
name: "body",
description: "Full press release body.",
required: true,
schema: { type: "string" },
},
{
name: "summary",
description: "Optional short summary.",
required: false,
schema: { type: "string" },
},
{
name: "targetRegions",
description: "Optional target regions such as US or EU.",
required: false,
schema: { type: "array", items: { type: "string" } },
},
],
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
_message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["DRAFT_PRESS_RELEASE"],
});
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const rec = readOpt(options);
const title = readString(rec, "title", "headline");
const body = readString(rec, "body", "content", "copy");
if (!title || !body) {
await callback?.({
text: MISSING_DRAFT_INPUT,
actions: ["DRAFT_PRESS_RELEASE"],
});
return {
success: false,
text: "Missing press release title/body.",
userFacingText: MISSING_DRAFT_INPUT,
data: { reason: "missing_input" },
};
}
try {
const { release } = await client.createPressRelease({
title,
body,
summary: readString(rec, "summary"),
boilerplate: readString(rec, "boilerplate"),
targetRegions: readStringArray(rec, "targetRegions"),
idempotencyKey: readString(rec, "idempotencyKey"),
});
const reply = `Drafted press release "${release.title}" (${release.status}).`;
await callback?.({ text: reply, actions: ["DRAFT_PRESS_RELEASE"] });
return {
success: true,
text: `Drafted press release ${release.title}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: { release: { id: release.id, title: release.title } },
};
} catch (err) {
logger.warn(
`[DRAFT_PRESS_RELEASE] failed: ${err instanceof Error ? err.message : String(err)}`,
);
const msg = "I couldn't draft that press release right now.";
await callback?.({ text: msg, actions: ["DRAFT_PRESS_RELEASE"] });
return {
success: false,
text: "Failed to draft press release.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: { text: "draft a press release for our launch" },
},
{
name: "{{agent}}",
content: {
text: 'Drafted press release "Launch" (draft).',
actions: ["DRAFT_PRESS_RELEASE"],
},
},
],
],
};
export const listPressReleasesAction: Action = {
name: "LIST_PRESS_RELEASES",
similes: ["LIST_PR_DRAFTS", "SHOW_PRESS_RELEASES", "MY_PRESS_RELEASES"],
description:
"List the user's Eliza Cloud press releases and statuses. Use before choosing a draft to submit or edit.",
descriptionCompressed: "List press release drafts/submissions.",
contexts: ["settings", "apps"],
contextGate: { anyOf: ["settings", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
_message: Memory,
_state?: State,
_options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["LIST_PRESS_RELEASES"],
});
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
try {
const { releases } = await client.listPressReleases();
const reply =
releases.length === 0
? "You don't have any press releases yet."
: `You have ${releases.length} press release(s):\n${releases
.map(releaseLine)
.join("\n")}`;
await callback?.({ text: reply, actions: ["LIST_PRESS_RELEASES"] });
return {
success: true,
text: `Listed ${releases.length} press releases.`,
userFacingText: reply,
verifiedUserFacing: true,
data: { count: releases.length },
};
} catch (err) {
logger.warn(
`[LIST_PRESS_RELEASES] failed: ${err instanceof Error ? err.message : String(err)}`,
);
const msg = "I couldn't list your press releases right now.";
await callback?.({ text: msg, actions: ["LIST_PRESS_RELEASES"] });
return {
success: false,
text: "Failed to list press releases.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{ name: "{{user}}", content: { text: "show my press releases" } },
{
name: "{{agent}}",
content: {
text: "You have 1 press release(s):\n• Launch — draft — updated 2026-07-03",
actions: ["LIST_PRESS_RELEASES"],
},
},
],
],
};
export const submitPressReleaseAction: Action = {
name: "SUBMIT_PRESS_RELEASE",
similes: ["SUBMIT_PR", "DISTRIBUTE_PRESS_RELEASE", "SEND_PRESS_RELEASE"],
description:
"Submit a press release for paid/provider-backed distribution. Requires explicit confirmation before calling the Cloud submit route.",
descriptionCompressed:
"Submit a press release for provider-backed distribution; requires confirm.",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
parameters: [
{
name: "releaseId",
description: "Press release id to submit.",
required: false,
schema: { type: "string" },
},
{
name: "title",
description: "Press release title to resolve.",
required: false,
schema: { type: "string" },
},
{
name: "confirm",
description:
"Follow-up: true confirms the pending submit, false cancels.",
required: false,
schema: { type: "boolean" },
},
],
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["SUBMIT_PRESS_RELEASE"],
});
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const roomId = confirmationRoomId(runtime, message);
const confirmation = readStructuredConfirmation(options);
const pending = await findPendingCloudAppConfirmation(
runtime,
roomId,
"SUBMIT_PRESS_RELEASE",
);
if (confirmation !== null) {
if (!pending) {
await callback?.({
text: NO_PENDING_MESSAGE,
actions: ["SUBMIT_PRESS_RELEASE"],
});
return {
success: false,
text: "No pending press release submit.",
userFacingText: NO_PENDING_MESSAGE,
data: { reason: "no_pending_confirmation" },
};
}
if (confirmation === false) {
await deleteCloudAppConfirmation(runtime, pending.taskId);
await callback?.({
text: CANCELED_MESSAGE,
actions: ["SUBMIT_PRESS_RELEASE"],
});
return {
success: true,
text: CANCELED_MESSAGE,
userFacingText: CANCELED_MESSAGE,
verifiedUserFacing: true,
data: { submitted: false, canceled: true },
};
}
const conflict = conflictingConfirmTarget(
options,
{ id: pending.metadata.appId, name: pending.metadata.appName },
["releaseId", "pressReleaseId", "title", "name"],
);
if (conflict !== null) {
await deleteCloudAppConfirmation(runtime, pending.taskId);
const msg = confirmTargetMismatchMessage(
conflict,
"press release submission",
pending.metadata.appName,
);
await callback?.({ text: msg, actions: ["SUBMIT_PRESS_RELEASE"] });
return {
success: false,
text: `Confirm named "${conflict}" but pending submit was ${pending.metadata.appName}; refused.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
reason: "confirm_target_mismatch",
submitted: false,
requested: conflict,
pendingTarget: {
id: pending.metadata.appId,
name: pending.metadata.appName,
},
},
};
}
if (pendingExpired(pending)) {
await deleteCloudAppConfirmation(runtime, pending.taskId);
const msg =
`That submit request for ${pending.metadata.appName} is more than ${Math.round(CONFIRM_TTL_MS / 60000)} minutes old, so I didn't submit anything. ` +
`Ask me to submit it again and I'll re-confirm the details.`;
await callback?.({ text: msg, actions: ["SUBMIT_PRESS_RELEASE"] });
return {
success: false,
text: `Pending submit for ${pending.metadata.appName} expired.`,
userFacingText: msg,
verifiedUserFacing: true,
data: { reason: "confirmation_expired", submitted: false },
};
}
try {
const result = await client.submitPressRelease(pending.metadata.appId, {
idempotencyKey: `press-release-submit-${pending.taskId}`,
});
await deleteCloudAppConfirmation(runtime, pending.taskId);
const reply = `Submitted press release "${result.release?.title ?? pending.metadata.appName}" for distribution.`;
await callback?.({ text: reply, actions: ["SUBMIT_PRESS_RELEASE"] });
return {
success: true,
text: `Submitted press release ${pending.metadata.appName}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
submitted: true,
release: result.release,
distribution: result.distribution,
},
};
} catch (err) {
const info = cloudErrorInfo(err);
logger.warn(
`[SUBMIT_PRESS_RELEASE] submit failed (${info.status ?? "transport"}/${info.code ?? "-"}): ${info.message}`,
);
if (info.code === "PR_PROVIDER_NOT_CONFIGURED") {
await deleteCloudAppConfirmation(runtime, pending.taskId);
const msg =
"Cloud can't submit press releases yet because no press distribution provider is configured. Nothing was submitted or billed.";
await callback?.({ text: msg, actions: ["SUBMIT_PRESS_RELEASE"] });
return {
success: false,
text: "Press distribution provider is not configured.",
userFacingText: msg,
verifiedUserFacing: true,
error: err instanceof Error ? err : new Error(String(err)),
data: {
reason: "provider_not_configured",
submitted: false,
},
};
}
if (info.status !== null && info.status < 500) {
await deleteCloudAppConfirmation(runtime, pending.taskId);
const msg = `I couldn't submit that press release: ${info.message}. Nothing was submitted.`;
await callback?.({ text: msg, actions: ["SUBMIT_PRESS_RELEASE"] });
return {
success: false,
text: `Press release submit rejected: ${info.message}`,
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "rejected", submitted: false },
};
}
await markCloudAppConfirmationRecovery(runtime, pending);
const msg = `I couldn't confirm whether Cloud accepted the submit for "${pending.metadata.appName}". I kept the same confirmation pending so a retry uses the same idempotency key.`;
await callback?.({ text: msg, actions: ["SUBMIT_PRESS_RELEASE"] });
return {
success: false,
text: "Press release submit outcome is unknown.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "unknown_submit_state", submitted: false },
};
}
}
if (pending) {
const msg = `I already have a pending submit confirmation for "${pending.metadata.appName}". Reply confirm to submit it, or cancel.`;
await callback?.({ text: msg, actions: ["SUBMIT_PRESS_RELEASE"] });
return {
success: false,
text: "Pending press release submit already exists.",
userFacingText: msg,
verifiedUserFacing: true,
data: {
reason: "pending_confirmation_exists",
confirmationRequired: true,
},
};
}
const reference = releaseReference(message, options);
const resolved = await resolveRelease(client, reference);
if (!resolved.release) {
const msg =
resolved.ambiguous && resolved.ambiguous.length > 0
? `Which press release? I found multiple matches: ${resolved.ambiguous.join(", ")}.`
: resolved.available.length === 0
? "You don't have any press releases yet. Draft one first, then I can submit it."
: `Which press release? Your drafts are: ${resolved.available.join(", ")}.`;
await callback?.({ text: msg, actions: ["SUBMIT_PRESS_RELEASE"] });
return {
success: false,
text: "Press release not found.",
userFacingText: msg,
data: {
reason: resolved.ambiguous ? "ambiguous" : "not_found",
available: resolved.available,
},
};
}
await persistCloudAppConfirmation(runtime, {
roomId,
action: "SUBMIT_PRESS_RELEASE",
appId: resolved.release.id,
appName: resolved.release.title,
intentCreatedAt: new Date().toISOString(),
});
const msg =
`Submitting "${resolved.release.title}" may use a paid press distribution provider. ` +
"Reply confirm to submit it, or cancel.";
await callback?.({ text: msg, actions: ["SUBMIT_PRESS_RELEASE"] });
return {
success: false,
text: `Confirmation required to submit press release ${resolved.release.title}.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
confirmationRequired: true,
submitted: false,
release: { id: resolved.release.id, title: resolved.release.title },
},
};
},
examples: [
[
{ name: "{{user}}", content: { text: "submit the Launch PR" } },
{
name: "{{agent}}",
content: {
text: 'Submitting "Launch" may use a paid press distribution provider. Reply confirm to submit it, or cancel.',
actions: ["SUBMIT_PRESS_RELEASE"],
},
},
],
],
};
@@ -0,0 +1,382 @@
/**
* REGENERATE_APP_API_KEY — SECURITY-SENSITIVE. Rotates the app's API key.
*
* Rotating invalidates the current key IMMEDIATELY — anything using it stops
* working until updated — so this action never rotates on the first ask:
* 1. First turn ("rotate my Acme key"): resolve the app, return a confirmation
* prompt spelling out that the previous key dies right away. No rotate call.
* 2. Follow-up with structured `confirm: true` for the pending prompt:
* `client.regenerateAppApiKey(id)` runs exactly once.
*
* The new plaintext key is shown EXACTLY ONCE in the reply with a "save it now"
* warning, and is NEVER logged or placed in the structured `data`/`text` fields
* (which may be persisted) — only in the user-facing message the connector shows.
*/
import type { AppDto } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
extractAppReference,
getCloudClient,
resolveApp,
resolveCloudApiKey,
} from "../client.js";
import {
confirmationRoomId,
confirmTargetMismatchMessage,
conflictingConfirmTarget,
deleteCloudAppConfirmation,
findPendingCloudAppConfirmation,
persistCloudAppConfirmation,
readStructuredConfirmation,
} from "../safety.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can rotate your app keys.";
const NO_REFERENCE_MESSAGE =
"Which app's API key would you like to regenerate? Tell me its name.";
const ERROR_MESSAGE =
"I couldn't rotate that app's API key right now — the Cloud API returned an error. The existing key is unchanged. Try again in a moment.";
const NO_PENDING_CONFIRMATION_MESSAGE =
"I don't have a pending API-key rotation confirmation for this room. Tell me which app key to rotate first, and I'll ask for confirmation.";
const CANCELED_MESSAGE = "Canceled. No app API key was rotated.";
function notFoundMessage(reference: string, available: string[]): string {
const base = `I couldn't find an app matching "${reference}".`;
if (available.length === 0) {
return `${base} You don't have any apps on Eliza Cloud yet.`;
}
return `${base} Your apps are: ${available.join(", ")}.`;
}
export const regenerateAppApiKeyAction: Action = {
name: "REGENERATE_APP_API_KEY",
similes: [
"ROTATE_KEY",
"NEW_API_KEY",
"REGENERATE_API_KEY",
"RESET_API_KEY",
"ROTATE_APP_KEY",
],
description:
"Regenerate (rotate) an Eliza Cloud app's API key. SECURITY-SENSITIVE: invalidates the current key immediately. Requires an explicit confirmation — the first ask only confirms intent. Use when the user asks to rotate, regenerate, reset, or get a new API key for an app.",
descriptionCompressed:
"Rotate a Cloud app's API key (security; two-step confirm).",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
parameters: [
{
name: "appName",
description:
"Name, slug, or id of the Cloud app whose API key to rotate.",
required: false,
schema: { type: "string" },
},
{
name: "confirm",
description:
"Follow-up confirmation. Set true only when the user is confirming the pending API-key rotation prompt for this app; set false when canceling.",
required: false,
schema: { type: "boolean" },
},
],
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["REGENERATE_APP_API_KEY"],
});
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const roomId = confirmationRoomId(runtime, message);
const confirmation = readStructuredConfirmation(options);
const pending = await findPendingCloudAppConfirmation(
runtime,
roomId,
"REGENERATE_APP_API_KEY",
);
if (confirmation !== null) {
if (!pending) {
await callback?.({
text: NO_PENDING_CONFIRMATION_MESSAGE,
actions: ["REGENERATE_APP_API_KEY"],
});
return {
success: false,
text: "No pending API-key rotation confirmation.",
userFacingText: NO_PENDING_CONFIRMATION_MESSAGE,
data: { reason: "no_pending_confirmation", rotated: false },
};
}
await deleteCloudAppConfirmation(runtime, pending.taskId);
if (confirmation === false) {
await callback?.({
text: CANCELED_MESSAGE,
actions: ["REGENERATE_APP_API_KEY"],
});
return {
success: true,
text: CANCELED_MESSAGE,
userFacingText: CANCELED_MESSAGE,
verifiedUserFacing: true,
data: { rotated: false, canceled: true },
};
}
const target = {
id: pending.metadata.appId,
name: pending.metadata.appName,
slug: pending.metadata.appSlug ?? pending.metadata.appName,
};
// Frozen-target guard: a confirm whose own params name a DIFFERENT app
// must never rotate the frozen app's key.
const conflict = conflictingConfirmTarget(options, {
name: target.name,
id: target.id,
aliases: [target.slug],
});
if (conflict !== null) {
const msg = confirmTargetMismatchMessage(
conflict,
"API-key rotation",
target.name,
);
await callback?.({ text: msg, actions: ["REGENERATE_APP_API_KEY"] });
return {
success: false,
text: `Confirm named "${conflict}" but the pending rotation was for ${target.name}; refused.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
reason: "confirm_target_mismatch",
rotated: false,
requested: conflict,
pendingTarget: { id: target.id, name: target.name },
},
};
}
try {
const result = await client.regenerateAppApiKey(target.id);
const newKey =
typeof result.apiKey === "string" && result.apiKey.length > 0
? result.apiKey
: null;
if (result.success === false || !newKey) {
const msg = `I rotated the request for "${target.name}", but the Cloud API didn't return a new key. Check your dashboard to confirm the key, then try again if needed.`;
await callback?.({
text: msg,
actions: ["REGENERATE_APP_API_KEY"],
});
return {
success: false,
text: "Rotation returned no key.",
userFacingText: msg,
data: { reason: "no_key_returned", rotated: false },
};
}
const reply =
`Done — here is the new API key for "${target.name}":\n\n${newKey}\n\n` +
`Save this now: it won't be shown again, and the old key no longer works. ` +
`Update anything that used the previous key.`;
await callback?.({
text: reply,
actions: ["REGENERATE_APP_API_KEY"],
});
return {
success: true,
text: `Regenerated the API key for ${target.name}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: target.id, name: target.name, slug: target.slug },
rotated: true,
keyShown: true,
},
};
} catch (err) {
logger.warn(
`[REGENERATE_APP_API_KEY] regenerateAppApiKey(${target.id}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({
text: ERROR_MESSAGE,
actions: ["REGENERATE_APP_API_KEY"],
});
return {
success: false,
text: "Failed to regenerate API key.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error", rotated: false },
};
}
}
if (pending) {
const msg =
`API-key rotation for "${pending.metadata.appName}" is still waiting for confirmation. ` +
`Reply with a clear confirmation or cancellation.`;
await callback?.({
text: msg,
actions: ["REGENERATE_APP_API_KEY"],
});
return {
success: true,
text: `Awaiting structured confirmation to rotate the API key for ${pending.metadata.appName}.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
app: {
id: pending.metadata.appId,
name: pending.metadata.appName,
slug: pending.metadata.appSlug,
},
rotated: false,
confirmationRequired: true,
},
};
}
const reference = extractAppReference(message, options);
if (!reference) {
await callback?.({
text: NO_REFERENCE_MESSAGE,
actions: ["REGENERATE_APP_API_KEY"],
});
return {
success: false,
text: "No app reference supplied.",
userFacingText: NO_REFERENCE_MESSAGE,
data: { reason: "no_reference" },
};
}
let app: AppDto | null;
let available: string[];
try {
({ app, available } = await resolveApp(client, reference));
} catch (err) {
logger.warn(
`[REGENERATE_APP_API_KEY] Failed to resolve app "${reference}": ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({
text: ERROR_MESSAGE,
actions: ["REGENERATE_APP_API_KEY"],
});
return {
success: false,
text: "Failed to resolve Eliza Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
if (!app) {
const msg = notFoundMessage(reference, available);
await callback?.({ text: msg, actions: ["REGENERATE_APP_API_KEY"] });
return {
success: false,
text: `No app matched "${reference}".`,
userFacingText: msg,
data: { reason: "not_found", reference },
};
}
const target = app;
await persistCloudAppConfirmation(runtime, {
roomId,
action: "REGENERATE_APP_API_KEY",
appId: target.id,
appName: target.name,
appSlug: target.slug,
});
const prompt =
`This will regenerate the API key for "${target.name}" (${target.id}). ` +
`The current key stops working immediately, so any app or integration ` +
`using it will break until you paste in the new one. This can't be undone. ` +
`To go ahead, reply that you confirm rotating the key for ${target.name}.`;
await callback?.({
text: prompt,
actions: ["REGENERATE_APP_API_KEY"],
});
return {
success: true,
text: `Awaiting structured confirmation to rotate the API key for ${target.name}.`,
userFacingText: prompt,
verifiedUserFacing: true,
data: {
app: { id: target.id, name: target.name, slug: target.slug },
rotated: false,
confirmationRequired: true,
},
};
},
examples: [
[
{
name: "{{user}}",
content: { text: "regenerate the API key for Acme Bot" },
},
{
name: "{{agent}}",
content: {
text: 'This will regenerate the API key for "Acme Bot" (…). The current key stops working immediately, so any app or integration using it will break until you paste in the new one. This can\'t be undone. To go ahead, reply that you confirm rotating the key for Acme Bot.',
actions: ["REGENERATE_APP_API_KEY"],
},
},
],
[
{
name: "{{user}}",
content: { text: "I confirm rotating the key for Acme Bot" },
},
{
name: "{{agent}}",
content: {
text: 'Done — here is the new API key for "Acme Bot":\n\neliza_app_…\n\nSave this now: it won\'t be shown again, and the old key no longer works. Update anything that used the previous key.',
actions: ["REGENERATE_APP_API_KEY"],
},
},
],
],
};
export default regenerateAppApiKeyAction;
@@ -0,0 +1,301 @@
/**
* Frontend deployment management agent actions (#10690).
*
* LIST_FRONTEND_DEPLOYMENTS — show an app's frontend versions + which is live.
* ROLLBACK_FRONTEND — make a previous frontend deployment live again
* (activating an older immutable deployment). The
* "editing / rolling back" part of the app lifecycle.
*/
import type { AppFrontendDeploymentDto } from "@elizaos/cloud-sdk";
import type { Action, ActionResult, IAgentRuntime } from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
extractAppReference,
getCloudClient,
resolveApp,
resolveCloudApiKey,
} from "../client.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY.";
function readOpt(options: unknown): Record<string, unknown> {
if (!options || typeof options !== "object") return {};
const o = options as Record<string, unknown>;
const nested = o.parameters;
return nested && typeof nested === "object"
? (nested as Record<string, unknown>)
: o;
}
/** Pick the deployment to roll back to: an explicit version, else the newest non-active restorable one. */
export function selectRollbackTarget(
deployments: AppFrontendDeploymentDto[],
activeId: string | null,
version?: number,
): AppFrontendDeploymentDto | null {
const restorable = deployments
.filter(
(d) =>
d.id !== activeId &&
(d.status === "superseded" ||
d.status === "ready" ||
d.status === "active"),
)
.sort((a, b) => b.version - a.version);
if (version !== undefined)
return restorable.find((d) => d.version === version) ?? null;
return restorable[0] ?? null;
}
export const listFrontendDeploymentsAction: Action = {
name: "LIST_FRONTEND_DEPLOYMENTS",
similes: [
"SHOW_FRONTEND_VERSIONS",
"FRONTEND_HISTORY",
"APP_FRONTEND_DEPLOYMENTS",
],
description:
"List an Eliza Cloud app's frontend deployment versions and which one is live. Use when the user asks about their app's frontend versions / deploy history.",
descriptionCompressed:
"List an app's frontend deployment versions + the live one.",
contexts: ["settings", "apps"],
contextGate: { anyOf: ["settings", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime,
message,
_state,
options,
callback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["LIST_FRONTEND_DEPLOYMENTS"],
});
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const reference = extractAppReference(message, options);
const { app, available } = reference
? await resolveApp(client, reference)
: { app: null, available: [] as string[] };
if (!app) {
const msg =
available.length === 0
? "You don't have any apps yet."
: `Which app? Your apps: ${available.join(", ")}.`;
await callback?.({ text: msg, actions: ["LIST_FRONTEND_DEPLOYMENTS"] });
return {
success: false,
text: "App not found.",
userFacingText: msg,
data: { reason: "not_found" },
};
}
try {
const { deployments, active_deployment_id } =
await client.listAppFrontendDeployments(app.id);
const reply =
deployments.length === 0
? `"${app.name}" has no frontend deployments yet.`
: `"${app.name}" frontend versions:\n${deployments
.slice()
.sort((a, b) => b.version - a.version)
.map(
(d) =>
`• v${d.version} (${d.status})${d.id === active_deployment_id ? " ← live" : ""}`,
)
.join("\n")}`;
await callback?.({ text: reply, actions: ["LIST_FRONTEND_DEPLOYMENTS"] });
return {
success: true,
text: `Listed ${deployments.length} frontend deployments.`,
userFacingText: reply,
verifiedUserFacing: true,
data: { count: deployments.length, activeId: active_deployment_id },
};
} catch (err) {
logger.warn(
`[LIST_FRONTEND_DEPLOYMENTS] failed: ${err instanceof Error ? err.message : String(err)}`,
);
const msg = "I couldn't list the frontend deployments right now.";
await callback?.({ text: msg, actions: ["LIST_FRONTEND_DEPLOYMENTS"] });
return {
success: false,
text: "Failed to list.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: { text: "show my Acme Bot frontend versions" },
},
{
name: "{{agent}}",
content: {
text: '"Acme Bot" frontend versions:\n• v3 (active) ← live\n• v2 (superseded)',
actions: ["LIST_FRONTEND_DEPLOYMENTS"],
},
},
],
],
};
export const rollbackFrontendAction: Action = {
name: "ROLLBACK_FRONTEND",
similes: [
"REVERT_FRONTEND",
"RESTORE_FRONTEND_VERSION",
"UNDO_FRONTEND_DEPLOY",
],
description:
"Roll an Eliza Cloud app's frontend back to a previous deployment (make an earlier version live again). Use when the user wants to revert / undo / roll back an app's frontend to an earlier version.",
descriptionCompressed: "Roll an app's frontend back to a previous version.",
contexts: ["settings", "apps"],
contextGate: { anyOf: ["settings", "apps"] },
suppressPostActionContinuation: true,
parameters: [
{
name: "appName",
description: "Name/slug/id of the app to roll back.",
required: false,
schema: { type: "string" },
},
{
name: "version",
description:
"Specific frontend version number to restore. Omit to roll back to the previous one.",
required: false,
schema: { type: "number" },
},
],
validate: async (runtime: IAgentRuntime): Promise<boolean> =>
resolveCloudApiKey(runtime) !== null,
handler: async (
runtime,
message,
_state,
options,
callback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["ROLLBACK_FRONTEND"],
});
return {
success: false,
text: "No Cloud API key.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const reference = extractAppReference(message, options);
const { app, available } = reference
? await resolveApp(client, reference)
: { app: null, available: [] as string[] };
if (!app) {
const msg =
available.length === 0
? "You don't have any apps yet."
: `Which app? Your apps: ${available.join(", ")}.`;
await callback?.({ text: msg, actions: ["ROLLBACK_FRONTEND"] });
return {
success: false,
text: "App not found.",
userFacingText: msg,
data: { reason: "not_found" },
};
}
const rec = readOpt(options);
const version = typeof rec.version === "number" ? rec.version : undefined;
try {
const { deployments, active_deployment_id } =
await client.listAppFrontendDeployments(app.id);
const target = selectRollbackTarget(
deployments,
active_deployment_id,
version,
);
if (!target) {
const msg =
version !== undefined
? `I couldn't find a restorable v${version} for "${app.name}".`
: `"${app.name}" has no earlier frontend version to roll back to.`;
await callback?.({ text: msg, actions: ["ROLLBACK_FRONTEND"] });
return {
success: false,
text: "No rollback target.",
userFacingText: msg,
data: { reason: "no_target" },
};
}
await client.activateAppFrontend(app.id, target.id);
const reply = `Rolled "${app.name}" frontend back to v${target.version} — it's live now.`;
await callback?.({ text: reply, actions: ["ROLLBACK_FRONTEND"] });
return {
success: true,
text: `Rolled ${app.name} frontend back to v${target.version}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: app.id, name: app.name },
activatedVersion: target.version,
activatedId: target.id,
},
};
} catch (err) {
logger.warn(
`[ROLLBACK_FRONTEND] failed: ${err instanceof Error ? err.message : String(err)}`,
);
const msg = "I couldn't roll back the frontend right now.";
await callback?.({ text: msg, actions: ["ROLLBACK_FRONTEND"] });
return {
success: false,
text: "Rollback failed.",
userFacingText: msg,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: { text: "roll back the Acme Bot site, the new one is broken" },
},
{
name: "{{agent}}",
content: {
text: 'Rolled "Acme Bot" frontend back to v2 — it\'s live now.',
actions: ["ROLLBACK_FRONTEND"],
},
},
],
],
};
@@ -0,0 +1,418 @@
/**
* UPDATE_APP — "rename / edit app X".
*
* Parses the field(s) to change (name, description, logo, website, contact email)
* from the user's text (planner options win when present), resolves the target
* app, then calls the typed `client.updateApp(id, patch)` and confirms the change.
*
* Non-destructive + reversible, so there is no two-phase confirm — an edit is
* applied directly. Validation is light (the server is authoritative); we only
* reject obviously malformed input (e.g. a non-http logo URL) before the call.
*/
import type { AppDto, UpdateAppInput } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
extractAppReference,
getCloudClient,
resolveApp,
resolveCloudApiKey,
} from "../client.js";
import { invalidateAppsCache } from "../providers/cloud-apps.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can update your apps.";
const NO_REFERENCE_MESSAGE =
"Which app would you like to update? Tell me its name.";
const NO_CHANGE_MESSAGE =
"What would you like to change? You can rename the app, or set its description, logo, website, or contact email.";
const ERROR_MESSAGE =
"I couldn't update that app right now — the Cloud API returned an error. Try again in a moment.";
export interface UpdateAppIntent {
/** App reference (name/slug/id) parsed from the request, if any. */
reference: string | null;
/** The partial update to apply. Empty when nothing parseable was found. */
patch: UpdateAppInput;
}
const OPTION_REFERENCE_KEYS = ["app", "appName", "appId", "id"] as const;
const OPTION_NAME_KEYS = ["name", "newName", "new_name", "title"] as const;
const OPTION_DESCRIPTION_KEYS = ["description", "desc", "about"] as const;
const OPTION_LOGO_KEYS = ["logo", "logo_url", "logoUrl"] as const;
const OPTION_WEBSITE_KEYS = ["website", "website_url", "websiteUrl"] as const;
const OPTION_EMAIL_KEYS = ["email", "contact_email", "contactEmail"] as const;
// Bounded captures: a value never swallows a trailing clause/punctuation.
const VALUE_END = `["”']?\\s*[.!?]?\\s*$`;
const REF = `(?:my\\s+|the\\s+)?(?:app\\s+)?["“']?([^"”']+?)["”']?`;
// "rename Acme to Beta" / "rename my app Acme to Beta"
const RENAME_PATTERN = new RegExp(
`\\brename\\s+${REF}\\s+to\\s+["“']?([^"”'.!?\\n]+?)${VALUE_END}`,
"i",
);
// "set Acme's name to Beta"
const POSSESSIVE_NAME_PATTERN = new RegExp(
`\\b(?:set|change|update)\\s+(?:my\\s+|the\\s+)?["“']?([^"”']+?)["”']?(?:'s|s')\\s+name\\s+to\\s+["“']?([^"”'.!?\\n]+?)${VALUE_END}`,
"i",
);
// "change the name of Acme to Beta" / "set the name to Beta"
const NAME_OF_PATTERN = new RegExp(
`\\b(?:change|set|update)\\s+(?:the\\s+)?name\\s+(?:of\\s+${REF}\\s+)?to\\s+["“']?([^"”'.!?\\n]+?)${VALUE_END}`,
"i",
);
// "set Acme's description to ..." (possessive form, ref captured)
const POSSESSIVE_DESC_PATTERN = new RegExp(
`\\b(?:set|change|update)\\s+(?:my\\s+|the\\s+)?["“']?([^"”']+?)["”']?(?:'s|s')\\s+(?:description|desc)\\s+to\\s+["“']?(.+?)${VALUE_END}`,
"i",
);
// "set the description (of Acme) to ..."
const DESC_OF_PATTERN = new RegExp(
`\\b(?:set|change|update)\\s+(?:the\\s+)?(?:description|desc|about)\\s+(?:(?:of|for)\\s+${REF}\\s+)?to\\s+["“']?(.+?)${VALUE_END}`,
"i",
);
// "set the logo (of Acme) to <url>"
const LOGO_PATTERN = new RegExp(
`\\b(?:set|change|update)\\s+(?:the\\s+)?logo\\s+(?:url\\s+)?(?:(?:of|for)\\s+${REF}\\s+)?to\\s+(\\S+)`,
"i",
);
// "set the website (of Acme) to <url>"
const WEBSITE_PATTERN = new RegExp(
`\\b(?:set|change|update)\\s+(?:the\\s+)?(?:website|site|url)\\s+(?:(?:of|for)\\s+${REF}\\s+)?to\\s+(\\S+)`,
"i",
);
// "set the contact email (of Acme) to <email>"
const EMAIL_PATTERN = new RegExp(
`\\b(?:set|change|update)\\s+(?:the\\s+)?(?:contact\\s+)?email\\s+(?:(?:of|for)\\s+${REF}\\s+)?to\\s+(\\S+)`,
"i",
);
function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function firstOption(
opts: Record<string, unknown>,
keys: readonly string[],
): string | null {
for (const key of keys) {
const v = asString(opts[key]);
if (v) return v;
}
return null;
}
function cleanCapture(value: string | undefined): string | null {
if (!value) return null;
const v = value.trim().replace(/\s+/g, " ");
return v.length > 0 && v.length <= 200 ? v : null;
}
function isHttpUrl(value: string): boolean {
try {
const u = new URL(value);
return u.protocol === "https:" || u.protocol === "http:";
} catch {
return false;
}
}
/** Parse the app reference + the field patch from planner options and raw text. */
export function parseUpdateAppIntent(
text: string,
options?: unknown,
): UpdateAppIntent {
const opts =
options && typeof options === "object"
? (options as Record<string, unknown>)
: {};
const body = text ?? "";
const patch: UpdateAppInput = {};
let reference: string | null = firstOption(opts, OPTION_REFERENCE_KEYS);
// 1) Planner options take priority for the patch fields.
const optName = firstOption(opts, OPTION_NAME_KEYS);
if (optName) patch.name = optName;
const optDesc = firstOption(opts, OPTION_DESCRIPTION_KEYS);
if (optDesc) patch.description = optDesc;
const optLogo = firstOption(opts, OPTION_LOGO_KEYS);
if (optLogo) patch.logo_url = optLogo;
const optWebsite = firstOption(opts, OPTION_WEBSITE_KEYS);
if (optWebsite) patch.website_url = optWebsite;
const optEmail = firstOption(opts, OPTION_EMAIL_KEYS);
if (optEmail) patch.contact_email = optEmail;
// 2) Text patterns fill any field not supplied via options.
const applyRefName = (ref?: string, name?: string): void => {
const r = cleanCapture(ref);
const n = cleanCapture(name);
if (!reference && r) reference = r;
if (patch.name === undefined && n) patch.name = n;
};
if (patch.name === undefined) {
for (const pattern of [
RENAME_PATTERN,
POSSESSIVE_NAME_PATTERN,
NAME_OF_PATTERN,
]) {
const m = pattern.exec(body);
if (m) {
applyRefName(m[1], m[2]);
if (patch.name !== undefined) break;
}
}
}
if (patch.description === undefined) {
for (const pattern of [POSSESSIVE_DESC_PATTERN, DESC_OF_PATTERN]) {
const m = pattern.exec(body);
if (m) {
if (!reference) reference = cleanCapture(m[1]);
const d = cleanCapture(m[2]);
if (d) patch.description = d;
if (patch.description !== undefined) break;
}
}
}
if (patch.logo_url === undefined) {
const m = LOGO_PATTERN.exec(body);
if (m) {
if (!reference) reference = cleanCapture(m[1]);
const url = cleanCapture(m[2]);
if (url) patch.logo_url = url;
}
}
if (patch.website_url === undefined) {
const m = WEBSITE_PATTERN.exec(body);
if (m) {
if (!reference) reference = cleanCapture(m[1]);
const url = cleanCapture(m[2]);
if (url) patch.website_url = url;
}
}
if (patch.contact_email === undefined) {
const m = EMAIL_PATTERN.exec(body);
if (m) {
if (!reference) reference = cleanCapture(m[1]);
const email = cleanCapture(m[2]);
if (email) patch.contact_email = email;
}
}
return { reference, patch };
}
function patchHasFields(patch: UpdateAppInput): boolean {
return Object.keys(patch).length > 0;
}
/** Describe the applied change(s) for the reply, using the updated app's values. */
function describeChange(patch: UpdateAppInput, updated: AppDto): string[] {
const parts: string[] = [];
if (patch.name !== undefined) parts.push(`renamed to "${updated.name}"`);
if (patch.description !== undefined) parts.push("description updated");
if (patch.logo_url !== undefined) parts.push("logo updated");
if (patch.website_url !== undefined) parts.push("website updated");
if (patch.contact_email !== undefined) parts.push("contact email updated");
if (patch.is_active !== undefined) {
parts.push(updated.is_active === false ? "deactivated" : "activated");
}
return parts;
}
function notFoundMessage(reference: string, available: string[]): string {
const base = `I couldn't find an app matching "${reference}".`;
if (available.length === 0) {
return `${base} You don't have any apps on Eliza Cloud yet.`;
}
return `${base} Your apps are: ${available.join(", ")}.`;
}
export const updateAppAction: Action = {
name: "UPDATE_APP",
similes: ["RENAME_APP", "EDIT_APP", "UPDATE_CLOUD_APP", "CHANGE_APP"],
description:
"Update an existing Eliza Cloud app's details — rename it, or change its description, logo, website, or contact email. Use when the user asks to rename, edit, or change an app's settings (not its monetization).",
descriptionCompressed: "Rename or edit a Cloud app's details.",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({ text: NO_KEY_MESSAGE, actions: ["UPDATE_APP"] });
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const intent = parseUpdateAppIntent(message.content?.text ?? "", options);
const reference = intent.reference ?? extractAppReference(message, options);
if (!reference) {
await callback?.({ text: NO_REFERENCE_MESSAGE, actions: ["UPDATE_APP"] });
return {
success: false,
text: "No app reference supplied.",
userFacingText: NO_REFERENCE_MESSAGE,
data: { reason: "no_reference" },
};
}
if (!patchHasFields(intent.patch)) {
await callback?.({ text: NO_CHANGE_MESSAGE, actions: ["UPDATE_APP"] });
return {
success: false,
text: "No update fields supplied.",
userFacingText: NO_CHANGE_MESSAGE,
data: { reason: "no_change" },
};
}
// Reject obviously malformed URLs before hitting the API.
for (const [field, value] of [
["logo", intent.patch.logo_url],
["website", intent.patch.website_url],
] as const) {
if (typeof value === "string" && !isHttpUrl(value)) {
const msg = `That ${field} URL doesn't look like a valid http(s) URL. Give me a full URL like https://example.com/logo.png.`;
await callback?.({ text: msg, actions: ["UPDATE_APP"] });
return {
success: false,
text: `Invalid ${field} URL.`,
userFacingText: msg,
data: { reason: "invalid_url", field },
};
}
}
let app: AppDto | null;
let available: string[];
try {
({ app, available } = await resolveApp(client, reference));
} catch (err) {
logger.warn(
`[UPDATE_APP] Failed to resolve app "${reference}": ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["UPDATE_APP"] });
return {
success: false,
text: "Failed to resolve Eliza Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
if (!app) {
const msg = notFoundMessage(reference, available);
await callback?.({ text: msg, actions: ["UPDATE_APP"] });
return {
success: false,
text: `No app matched "${reference}".`,
userFacingText: msg,
data: { reason: "not_found", reference },
};
}
const target = app;
try {
const { app: updated } = await client.updateApp(target.id, intent.patch);
// App inventory changed — evict the provider cache so the next turn's
// context reflects the new name/description/etc. (cache-invalidation
// invariant; the ~60s WeakMap cache would otherwise serve stale state).
invalidateAppsCache(runtime);
const result = updated ?? target;
const changes = describeChange(intent.patch, result);
const summary =
changes.length > 0 ? changes.join(", ") : "settings updated";
const reply = `Updated "${target.name}" — ${summary}.`;
await callback?.({ text: reply, actions: ["UPDATE_APP"] });
return {
success: true,
text: `Updated Eliza Cloud app ${result.name}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: result.id, name: result.name, slug: result.slug },
updated: Object.keys(intent.patch),
},
};
} catch (err) {
logger.warn(
`[UPDATE_APP] updateApp(${target.id}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({ text: ERROR_MESSAGE, actions: ["UPDATE_APP"] });
return {
success: false,
text: "Failed to update Eliza Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{ name: "{{user}}", content: { text: "rename Acme Bot to Zephyr" } },
{
name: "{{agent}}",
content: {
text: 'Updated "Acme Bot" — renamed to "Zephyr".',
actions: ["UPDATE_APP"],
},
},
],
[
{
name: "{{user}}",
content: {
text: "set the description of Zephyr to a friendly support bot",
},
},
{
name: "{{agent}}",
content: {
text: 'Updated "Zephyr" — description updated.',
actions: ["UPDATE_APP"],
},
},
],
],
};
export default updateAppAction;
@@ -0,0 +1,441 @@
/**
* UPDATE_MONETIZATION — "set the price / change the markup / enable monetization".
*
* Parses the monetization change (enable/disable, inference markup %, purchase
* share %) from the user's text (planner options win), resolves the app, then
* calls the typed `client.updateMonetization(id, settings)` and echoes the
* resulting settings.
*
* Absurd values are rejected BEFORE the call with a clear message. The bounds
* mirror the server's zod schema exactly (`UpdateMonetizationSchema`):
* - inference markup: 01000 %
* - purchase share: 0100 %
* Rejecting a value the server would accept (or sending one it would reject) is
* a bug, so the guard is the server's contract — not a stricter local opinion.
*/
import type { AppDto, UpdateAppMonetizationInput } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
extractAppReference,
getCloudClient,
resolveApp,
resolveCloudApiKey,
} from "../client.js";
import { invalidateAppsCache } from "../providers/cloud-apps.js";
/** Server-enforced bounds (mirror `UpdateMonetizationSchema` in cloud-api). */
export const MARKUP_MIN = 0;
export const MARKUP_MAX = 1000;
export const SHARE_MIN = 0;
export const SHARE_MAX = 100;
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can change your app's monetization.";
const NO_REFERENCE_MESSAGE =
"Which app's monetization would you like to change? Tell me its name.";
const NO_CHANGE_MESSAGE =
"What should I change? You can turn monetization on or off, set the inference markup % (01000), or set the purchase share % (0100).";
const ERROR_MESSAGE =
"I couldn't update that app's monetization right now — the Cloud API returned an error. Try again in a moment.";
export interface MonetizationIntent {
reference: string | null;
settings: UpdateAppMonetizationInput;
/** Out-of-range value that was parsed but rejected (for a clear message). */
rejected?: {
field: "markup" | "share";
value: number;
min: number;
max: number;
};
}
const OPTION_REFERENCE_KEYS = ["app", "appName", "appId", "id"] as const;
const OPTION_ENABLE_KEYS = [
"monetization",
"monetize",
"monetizationEnabled",
"monetization_enabled",
"enabled",
"enable",
] as const;
const OPTION_MARKUP_KEYS = [
"markup",
"markupPercentage",
"inferenceMarkupPercentage",
"inference_markup_percentage",
] as const;
const OPTION_SHARE_KEYS = [
"purchaseShare",
"purchaseSharePercentage",
"purchase_share_percentage",
"share",
] as const;
function asBool(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (["true", "yes", "on", "1", "enable", "enabled"].includes(v))
return true;
if (["false", "no", "off", "0", "disable", "disabled"].includes(v)) {
return false;
}
}
return null;
}
function asNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const n = Number(value.replace(/%/g, "").trim());
if (Number.isFinite(n)) return n;
}
return null;
}
function firstOption(
opts: Record<string, unknown>,
keys: readonly string[],
): unknown {
for (const key of keys) {
if (opts[key] !== undefined) return opts[key];
}
return undefined;
}
/** Parse the enable/markup/share change + app reference from options + text. */
export function parseMonetizationIntent(
text: string,
options?: unknown,
): MonetizationIntent {
const opts =
options && typeof options === "object"
? (options as Record<string, unknown>)
: {};
const body = text ?? "";
const settings: UpdateAppMonetizationInput = {};
let reference: string | null = null;
for (const key of OPTION_REFERENCE_KEYS) {
const v = opts[key];
if (typeof v === "string" && v.trim()) {
reference = v.trim();
break;
}
}
// Enable / disable.
let enable = asBool(firstOption(opts, OPTION_ENABLE_KEYS));
if (enable === null) {
if (
/\b(disable|turn\s+off|deactivate|stop|switch\s+off)\b[^.!?\n]*\bmoneti/i.test(
body,
) ||
/\bmoneti\w*\s+(off|disabled?)\b/i.test(body)
) {
enable = false;
} else if (
/\b(enable|turn\s+on|activate|start|switch\s+on)\b[^.!?\n]*\bmoneti/i.test(
body,
) ||
/\bmoneti\w*\s+(on|enabled?)\b/i.test(body)
) {
enable = true;
}
}
if (enable !== null) settings.monetizationEnabled = enable;
// Inference markup %.
let markup = asNumber(firstOption(opts, OPTION_MARKUP_KEYS));
if (markup === null) {
const m =
/\bmarkup\b[^%\d-]*(-?\d+(?:\.\d+)?)\s*%?/i.exec(body) ??
/(-?\d+(?:\.\d+)?)\s*%\s*markup\b/i.exec(body);
if (m) markup = asNumber(m[1]);
}
// Purchase share %.
let share = asNumber(firstOption(opts, OPTION_SHARE_KEYS));
if (share === null) {
const m =
/\b(?:purchase\s+share|revenue\s+share|share)\b[^%\d-]*(-?\d+(?:\.\d+)?)\s*%?/i.exec(
body,
);
if (m) share = asNumber(m[1]);
}
// Range-guard absurd values; surface the first offender for a clear message.
if (markup !== null) {
if (markup < MARKUP_MIN || markup > MARKUP_MAX) {
return {
reference,
settings,
rejected: {
field: "markup",
value: markup,
min: MARKUP_MIN,
max: MARKUP_MAX,
},
};
}
settings.inferenceMarkupPercentage = markup;
// Enabling monetization is implied by setting a markup if not stated.
if (settings.monetizationEnabled === undefined && markup > 0) {
settings.monetizationEnabled = true;
}
}
if (share !== null) {
if (share < SHARE_MIN || share > SHARE_MAX) {
return {
reference,
settings,
rejected: {
field: "share",
value: share,
min: SHARE_MIN,
max: SHARE_MAX,
},
};
}
settings.purchaseSharePercentage = share;
}
return { reference, settings };
}
function settingsHaveFields(settings: UpdateAppMonetizationInput): boolean {
return Object.keys(settings).length > 0;
}
function notFoundMessage(reference: string, available: string[]): string {
const base = `I couldn't find an app matching "${reference}".`;
if (available.length === 0) {
return `${base} You don't have any apps on Eliza Cloud yet.`;
}
return `${base} Your apps are: ${available.join(", ")}.`;
}
/** Echo the resulting monetization settings from the server response. */
function formatSettings(
name: string,
monetization: {
monetizationEnabled?: boolean;
inferenceMarkupPercentage?: number;
purchaseSharePercentage?: number;
} | null,
): string {
if (!monetization) {
return `Updated "${name}"'s monetization.`;
}
if (monetization.monetizationEnabled === false) {
return `Monetization is now OFF for "${name}".`;
}
const lines = [`Monetization is ON for "${name}".`];
if (typeof monetization.inferenceMarkupPercentage === "number") {
lines.push(`Inference markup: ${monetization.inferenceMarkupPercentage}%`);
}
if (typeof monetization.purchaseSharePercentage === "number") {
lines.push(`Purchase share: ${monetization.purchaseSharePercentage}%`);
}
return lines.join("\n");
}
export const updateMonetizationAction: Action = {
name: "UPDATE_MONETIZATION",
similes: [
"SET_PRICE",
"CHANGE_MARKUP",
"ENABLE_MONETIZATION",
"DISABLE_MONETIZATION",
"SET_MARKUP",
],
description:
"Change an Eliza Cloud app's monetization — turn it on or off, set the inference markup percentage, or set the purchase share percentage. Use when the user asks to monetize, set a price/markup, or enable/disable earning on an app.",
descriptionCompressed: "Set a Cloud app's monetization (markup / on-off).",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["UPDATE_MONETIZATION"],
});
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const intent = parseMonetizationIntent(
message.content?.text ?? "",
options,
);
if (intent.rejected) {
const { field, value, min, max } = intent.rejected;
const label = field === "markup" ? "inference markup" : "purchase share";
const msg = `${value}% is out of range for the ${label} — it has to be between ${min}% and ${max}%. Tell me a value in that range.`;
await callback?.({ text: msg, actions: ["UPDATE_MONETIZATION"] });
return {
success: false,
text: `Out-of-range ${label}: ${value}%.`,
userFacingText: msg,
data: { reason: "out_of_range", field, value, min, max },
};
}
const reference = intent.reference ?? extractAppReference(message, options);
if (!reference) {
await callback?.({
text: NO_REFERENCE_MESSAGE,
actions: ["UPDATE_MONETIZATION"],
});
return {
success: false,
text: "No app reference supplied.",
userFacingText: NO_REFERENCE_MESSAGE,
data: { reason: "no_reference" },
};
}
if (!settingsHaveFields(intent.settings)) {
await callback?.({
text: NO_CHANGE_MESSAGE,
actions: ["UPDATE_MONETIZATION"],
});
return {
success: false,
text: "No monetization change supplied.",
userFacingText: NO_CHANGE_MESSAGE,
data: { reason: "no_change" },
};
}
let app: AppDto | null;
let available: string[];
try {
({ app, available } = await resolveApp(client, reference));
} catch (err) {
logger.warn(
`[UPDATE_MONETIZATION] Failed to resolve app "${reference}": ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({
text: ERROR_MESSAGE,
actions: ["UPDATE_MONETIZATION"],
});
return {
success: false,
text: "Failed to resolve Eliza Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
if (!app) {
const msg = notFoundMessage(reference, available);
await callback?.({ text: msg, actions: ["UPDATE_MONETIZATION"] });
return {
success: false,
text: `No app matched "${reference}".`,
userFacingText: msg,
data: { reason: "not_found", reference },
};
}
const target = app;
try {
const { monetization } = await client.updateMonetization(
target.id,
intent.settings,
);
// Monetization state changed — evict the provider cache so the next
// turn doesn't serve a stale enabled/markup/share for ~60s.
invalidateAppsCache(runtime);
const reply = formatSettings(target.name, monetization);
await callback?.({ text: reply, actions: ["UPDATE_MONETIZATION"] });
return {
success: true,
text: `Updated monetization for ${target.name}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: target.id, name: target.name, slug: target.slug },
monetization: monetization ?? null,
},
};
} catch (err) {
logger.warn(
`[UPDATE_MONETIZATION] updateMonetization(${target.id}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({
text: ERROR_MESSAGE,
actions: ["UPDATE_MONETIZATION"],
});
return {
success: false,
text: "Failed to update monetization.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
},
examples: [
[
{
name: "{{user}}",
content: { text: "set Acme Bot's inference markup to 20%" },
},
{
name: "{{agent}}",
content: {
text: 'Monetization is ON for "Acme Bot".\nInference markup: 20%',
actions: ["UPDATE_MONETIZATION"],
},
},
],
[
{ name: "{{user}}", content: { text: "turn off monetization for Acme" } },
{
name: "{{agent}}",
content: {
text: 'Monetization is now OFF for "Acme Bot".',
actions: ["UPDATE_MONETIZATION"],
},
},
],
],
};
export default updateMonetizationAction;
@@ -0,0 +1,636 @@
/**
* WITHDRAW_APP_EARNINGS — MONEY-OUT. Handled with maximum care.
*
* ── Safety model (documented per PR_EVIDENCE) ────────────────────────────────
* Two layers protect the user's money, and NO secret/credential ever transits a
* connector:
*
* 1. Two-phase confirm (structured `confirm` + pending task in safety.ts).
* The first ask NEVER moves money — it returns a confirmation prompt naming
* the exact app + amount, plus a connector-agnostic CTA to the dashboard
* earnings page. Money moves only when a later turn carries structured
* `confirm: true` for that pending prompt.
*
* 2. The "safe path" on confirm calls `client.withdrawAppEarnings(id, …)`,
* which wraps `POST /api/v1/apps/:id/earnings/withdraw`. That endpoint is
* idempotent (accepts an `idempotency_key`) and SERVER-GATED: the server
* independently verifies org ownership, app-creator identity, monetization,
* the minimum-payout threshold, and a sufficient withdrawable balance. It
* does NOT itself wire cash to a bank — it records the withdrawal request
* and moves the funds into the creator's redeemable balance; the actual
* cash-out (admin-gated Stripe Connect transfer / token redemption) is
* completed by the user IN THE BROWSER via the CTA. We therefore call the
* safe, idempotent, server-gated request endpoint on confirm AND hand off
* the dashboard CTA so the user finishes the money/credential step there.
*
* The CTA ({@link buildConnectorCta}) carries ONLY a human label + an https URL —
* never a token, signed payload, secret, or amount baked into a credential.
*/
import type { AppDto, WithdrawAppEarningsRequest } from "@elizaos/cloud-sdk";
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
extractAppReference,
getCloudClient,
plannerOptionSources,
resolveApp,
resolveCloudApiKey,
resolveCloudSiteBaseUrl,
} from "../client.js";
import {
buildConnectorCta,
CONFIRM_TTL_MS,
type ConnectorCta,
confirmationRoomId,
confirmTargetMismatchMessage,
conflictingConfirmAmount,
conflictingConfirmTarget,
deleteCloudAppConfirmation,
findPendingCloudAppConfirmation,
pendingExpired,
persistCloudAppConfirmation,
readStructuredConfirmation,
} from "../safety.js";
import { extractEarningsView } from "./get-app-earnings.js";
const NO_KEY_MESSAGE =
"I can't reach Eliza Cloud yet — no Cloud API key is configured. Add your ELIZAOS_CLOUD_API_KEY and I can help you withdraw earnings.";
const NO_REFERENCE_MESSAGE =
"Which app's earnings would you like to withdraw? Tell me its name.";
const ERROR_MESSAGE =
"I couldn't process that withdrawal right now — the Cloud API returned an error. Nothing was withdrawn. Try again in a moment.";
const NO_PENDING_CONFIRMATION_MESSAGE =
"I don't have a pending withdrawal confirmation for this room. Tell me which app earnings to withdraw first, and I'll ask for confirmation.";
const CANCELED_MESSAGE = "Canceled. No app earnings were withdrawn.";
function usd(n: number): string {
return `$${n.toFixed(2)}`;
}
function newIdempotencyKey(): string {
const uuid = globalThis.crypto?.randomUUID?.();
if (uuid) return uuid; // 36 chars — within the server's 1664 bound.
return `wd-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
}
const AMOUNT_OPTION_KEYS = ["amount", "usd", "value"] as const;
/**
* Parse a withdrawal amount (USD) from planner options or text; null = "all".
*
* MONEY-CRITICAL: on the real planner path the validated `amount` arrives
* NESTED under `options.parameters` (execute-planned-tool-call.ts), so the
* nested object is read before the top level ({@link plannerOptionSources}).
* Missing it here silently upgraded "withdraw $50" to the FULL withdrawable
* balance at the confirm stage.
*/
export function parseWithdrawAmount(
text: string,
options?: unknown,
): number | null {
for (const source of plannerOptionSources(options)) {
for (const key of AMOUNT_OPTION_KEYS) {
const v = source[key];
if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
if (typeof v === "string") {
const n = Number(v.replace(/[$,]/g, "").trim());
if (Number.isFinite(n) && n > 0) return n;
}
}
}
const body = text ?? "";
// Prefer an explicit currency amount: "$50", "50 dollars", "50 usd". The
// bare-number fallback requires a standalone whitespace-bounded token not
// glued to letters, so a digit inside an app name ("Acme2") never reads as
// a dollar amount.
const m =
/\$\s*(\d+(?:\.\d+)?)/.exec(body) ??
/(?:^|\s)(\d+(?:\.\d+)?)\s*(?:dollars?|usd)\b/i.exec(body) ??
/\b(?:withdraw(?:al)?|cash\s*out|pay\s*out|payout)\b[^$\d]*?(?:^|\s)(\d+(?:\.\d+)?)(?![A-Za-z0-9])/i.exec(
body,
);
if (m) {
const n = Number(m[1]);
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
function notFoundMessage(reference: string, available: string[]): string {
const base = `I couldn't find an app matching "${reference}".`;
if (available.length === 0) {
return `${base} You don't have any apps on Eliza Cloud yet.`;
}
return `${base} Your apps are: ${available.join(", ")}.`;
}
export const withdrawAppEarningsAction: Action = {
name: "WITHDRAW_APP_EARNINGS",
similes: [
"CASH_OUT",
"PAYOUT",
"WITHDRAW_EARNINGS",
"REQUEST_PAYOUT",
"CASH_OUT_APP",
],
description:
"Withdraw (cash out) an Eliza Cloud app's earnings. MONEY-OUT: requires an explicit confirmation — the first ask only confirms intent and hands off a dashboard link. Use when the user asks to withdraw, cash out, or request a payout of an app's earnings.",
descriptionCompressed:
"Withdraw a Cloud app's earnings (money-out; two-step confirm).",
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
suppressPostActionContinuation: true,
parameters: [
{
name: "appName",
description:
"Name, slug, or id of the Cloud app whose earnings to withdraw.",
required: false,
schema: { type: "string" },
},
{
name: "amount",
description:
"Optional USD amount to withdraw on the first ask. Omit to withdraw the full available balance.",
required: false,
schema: { type: "number" },
},
{
name: "confirm",
description:
"Follow-up confirmation. Set true only when the user is confirming the pending withdrawal prompt for this app and amount; set false when canceling.",
required: false,
schema: { type: "boolean" },
},
],
validate: async (runtime: IAgentRuntime): Promise<boolean> => {
return resolveCloudApiKey(runtime) !== null;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const client = getCloudClient(runtime);
if (!client) {
await callback?.({
text: NO_KEY_MESSAGE,
actions: ["WITHDRAW_APP_EARNINGS"],
});
return {
success: false,
text: "No Eliza Cloud API key configured.",
userFacingText: NO_KEY_MESSAGE,
data: { reason: "no_key" },
};
}
const roomId = confirmationRoomId(runtime, message);
const confirmation = readStructuredConfirmation(options);
const pending = await findPendingCloudAppConfirmation(
runtime,
roomId,
"WITHDRAW_APP_EARNINGS",
);
if (confirmation !== null) {
if (!pending || typeof pending.metadata.amount !== "number") {
await callback?.({
text: NO_PENDING_CONFIRMATION_MESSAGE,
actions: ["WITHDRAW_APP_EARNINGS"],
});
return {
success: false,
text: "No pending withdrawal confirmation.",
userFacingText: NO_PENDING_CONFIRMATION_MESSAGE,
data: { reason: "no_pending_confirmation", withdrawn: false },
};
}
if (pendingExpired(pending)) {
// A stale confirm must not fire a money-out withdrawal on a bare "yes"
// long after the fact. Mirror BUY_APP_DOMAIN / BOOK_INFLUENCER: expire
// the pending and ask the user to re-request rather than moving money.
await deleteCloudAppConfirmation(runtime, pending.taskId);
const msg =
`That withdrawal request for ${pending.metadata.appName} is more than ${Math.round(CONFIRM_TTL_MS / 60000)} minutes old, so I didn't move any money. ` +
`Ask me to withdraw ${pending.metadata.appName}'s earnings again and I'll re-confirm the amount.`;
await callback?.({ text: msg, actions: ["WITHDRAW_APP_EARNINGS"] });
return {
success: false,
text: `Pending withdrawal of ${pending.metadata.appName} expired before confirmation.`,
userFacingText: msg,
verifiedUserFacing: true,
data: { reason: "confirmation_expired", withdrawn: false },
};
}
await deleteCloudAppConfirmation(runtime, pending.taskId);
if (confirmation === false) {
await callback?.({
text: CANCELED_MESSAGE,
actions: ["WITHDRAW_APP_EARNINGS"],
});
return {
success: true,
text: CANCELED_MESSAGE,
userFacingText: CANCELED_MESSAGE,
verifiedUserFacing: true,
data: { withdrawn: false, canceled: true },
};
}
const target = {
id: pending.metadata.appId,
name: pending.metadata.appName,
slug: pending.metadata.appSlug ?? pending.metadata.appName,
};
const amount = pending.metadata.amount;
// Frozen-snapshot guard: a confirm whose own params name a DIFFERENT app
// or amount must never fund the frozen withdrawal the user is no longer
// talking about.
const appConflict = conflictingConfirmTarget(options, {
name: target.name,
id: target.id,
aliases: [target.slug],
});
const amountConflict = conflictingConfirmAmount(options, amount);
if (appConflict !== null || amountConflict !== null) {
const requested =
appConflict ??
`${usd(amountConflict ?? amount)} (not ${usd(amount)})`;
const msg = confirmTargetMismatchMessage(
requested,
`withdrawal of ${usd(amount)}`,
target.name,
);
await callback?.({ text: msg, actions: ["WITHDRAW_APP_EARNINGS"] });
return {
success: false,
text: `Confirm named "${requested}" but the pending withdrawal was ${usd(amount)} from ${target.name}; refused.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
reason: "confirm_target_mismatch",
withdrawn: false,
requested,
pendingTarget: { id: target.id, name: target.name },
amount,
},
};
}
const cta =
pending.metadata.cta ??
buildConnectorCta(
`Open ${target.name}'s earnings dashboard`,
`${resolveCloudSiteBaseUrl(runtime)}/dashboard/apps/${target.id}?tab=earnings`,
"link",
);
try {
const request: WithdrawAppEarningsRequest = {
amount,
idempotency_key: newIdempotencyKey(),
};
const result = await client.withdrawAppEarnings(target.id, request);
if (result.success === false) {
const msg = `Couldn't withdraw from "${target.name}": ${
result.error ?? result.message ?? "the request was rejected"
}. Nothing was withdrawn.`;
await callback?.({ text: msg, actions: ["WITHDRAW_APP_EARNINGS"] });
return {
success: false,
text: "Withdrawal rejected by the Cloud API.",
userFacingText: msg,
data: { reason: "rejected", withdrawn: false, cta },
};
}
const newBalance =
typeof result.newBalance === "number" ? result.newBalance : null;
const reply =
`Requested a payout of ${usd(amount)} from "${target.name}". ` +
(result.message
? `${result.message} `
: "It's now in your redeemable balance. ") +
(newBalance !== null
? `Remaining withdrawable: ${usd(newBalance)}. `
: "") +
`Finish the cash-out here: ${cta.url}`;
await callback?.({ text: reply, actions: ["WITHDRAW_APP_EARNINGS"] });
return {
success: true,
text: `Withdrawal of ${usd(amount)} requested for ${target.name}.`,
userFacingText: reply,
verifiedUserFacing: true,
data: {
app: { id: target.id, name: target.name, slug: target.slug },
amount,
withdrawn: true,
transactionId: result.transactionId ?? null,
newBalance,
cta,
},
};
} catch (err) {
logger.warn(
`[WITHDRAW_APP_EARNINGS] withdrawAppEarnings(${target.id}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({
text: ERROR_MESSAGE,
actions: ["WITHDRAW_APP_EARNINGS"],
});
return {
success: false,
text: "Failed to withdraw earnings.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error", withdrawn: false, cta },
};
}
}
if (pending) {
const msg =
`Withdrawal for "${pending.metadata.appName}" is still waiting for confirmation. ` +
`Reply with a clear confirmation or cancellation.`;
await callback?.({
text: msg,
actions: ["WITHDRAW_APP_EARNINGS"],
});
return {
success: true,
text: `Awaiting structured confirmation to withdraw from ${pending.metadata.appName}.`,
userFacingText: msg,
verifiedUserFacing: true,
data: {
app: {
id: pending.metadata.appId,
name: pending.metadata.appName,
slug: pending.metadata.appSlug,
},
amount: pending.metadata.amount,
withdrawn: false,
confirmationRequired: true,
cta: pending.metadata.cta,
},
};
}
const reference = extractAppReference(message, options);
if (!reference) {
await callback?.({
text: NO_REFERENCE_MESSAGE,
actions: ["WITHDRAW_APP_EARNINGS"],
});
return {
success: false,
text: "No app reference supplied.",
userFacingText: NO_REFERENCE_MESSAGE,
data: { reason: "no_reference" },
};
}
let app: AppDto | null;
let available: string[];
let ambiguous: string[] | undefined;
try {
({ app, available, ambiguous } = await resolveApp(client, reference));
} catch (err) {
logger.warn(
`[WITHDRAW_APP_EARNINGS] Failed to resolve app "${reference}": ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({
text: ERROR_MESSAGE,
actions: ["WITHDRAW_APP_EARNINGS"],
});
return {
success: false,
text: "Failed to resolve Eliza Cloud app.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
if (!app) {
const candidates = ambiguous && ambiguous.length > 1 ? ambiguous : null;
const msg = candidates
? `Which app do you mean? "${reference}" matches ${candidates.length}: ${candidates.join(", ")}. Reply with the exact name so I withdraw from the right one.`
: notFoundMessage(reference, available);
await callback?.({ text: msg, actions: ["WITHDRAW_APP_EARNINGS"] });
return {
success: false,
text: candidates
? `Ambiguous reference "${reference}" (${candidates.length} matches).`
: `No app matched "${reference}".`,
userFacingText: msg,
data: {
reason: candidates ? "ambiguous" : "not_found",
reference,
...(candidates ? { candidates } : {}),
},
};
}
const target = app;
// Read the authoritative balance/threshold (read-only) before doing anything.
let withdrawable = 0;
let threshold = 0;
let monetizationOn = target.monetization_enabled;
try {
const earnings = await client.getAppEarnings(target.id);
const view = extractEarningsView(earnings.earnings);
if (view) {
withdrawable = view.withdrawableBalance;
threshold = view.payoutThreshold;
}
if (earnings.monetization?.enabled === true) monetizationOn = true;
if (earnings.monetization?.enabled === false) monetizationOn = false;
} catch (err) {
logger.warn(
`[WITHDRAW_APP_EARNINGS] getAppEarnings(${target.id}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
await callback?.({
text: ERROR_MESSAGE,
actions: ["WITHDRAW_APP_EARNINGS"],
});
return {
success: false,
text: "Failed to read earnings before withdrawal.",
userFacingText: ERROR_MESSAGE,
error: err instanceof Error ? err : new Error(String(err)),
data: { reason: "error" },
};
}
// Connector-agnostic CTA — label + https URL only; never a secret/amount-token.
const dashboardUrl = `${resolveCloudSiteBaseUrl(runtime)}/dashboard/apps/${
target.id
}?tab=earnings`;
let cta: ConnectorCta;
try {
cta = buildConnectorCta(
`Open ${target.name}'s earnings dashboard`,
dashboardUrl,
"link",
);
} catch (err) {
// A malformed base URL must never block the read-only guidance.
logger.warn(
`[WITHDRAW_APP_EARNINGS] Could not build CTA: ${
err instanceof Error ? err.message : String(err)
}`,
);
cta = {
label: "Open your earnings dashboard",
url: dashboardUrl,
kind: "link",
};
}
// Pre-checks: never start a withdrawal that the server would reject anyway.
if (!monetizationOn) {
const msg = `"${target.name}" isn't monetized, so there's nothing to withdraw. Turn on monetization first.`;
await callback?.({ text: msg, actions: ["WITHDRAW_APP_EARNINGS"] });
return {
success: false,
text: "Monetization disabled — nothing to withdraw.",
userFacingText: msg,
data: { reason: "not_monetized", withdrawn: false, cta },
};
}
if (withdrawable <= 0) {
const msg = `"${target.name}" has no withdrawable balance yet. Earn a bit more and I'll help you cash out.`;
await callback?.({ text: msg, actions: ["WITHDRAW_APP_EARNINGS"] });
return {
success: false,
text: "No withdrawable balance.",
userFacingText: msg,
data: { reason: "no_balance", withdrawn: false, cta },
};
}
if (threshold > 0 && withdrawable < threshold) {
const msg = `"${target.name}" has ${usd(
withdrawable,
)} withdrawable, but the minimum payout is ${usd(
threshold,
)}. Keep earning to reach it.`;
await callback?.({ text: msg, actions: ["WITHDRAW_APP_EARNINGS"] });
return {
success: false,
text: "Below minimum payout threshold.",
userFacingText: msg,
data: { reason: "below_threshold", withdrawn: false, cta },
};
}
// Amount: explicit request, else the full withdrawable balance.
const requested = parseWithdrawAmount(message.content?.text ?? "", options);
const amount = requested ?? withdrawable;
if (amount > withdrawable + 1e-9) {
const msg = `You asked to withdraw ${usd(amount)}, but only ${usd(
withdrawable,
)} is withdrawable right now. Ask for ${usd(withdrawable)} or less.`;
await callback?.({ text: msg, actions: ["WITHDRAW_APP_EARNINGS"] });
return {
success: false,
text: "Requested amount exceeds withdrawable balance.",
userFacingText: msg,
data: { reason: "exceeds_balance", withdrawn: false, cta },
};
}
if (threshold > 0 && amount < threshold) {
const msg = `The minimum payout is ${usd(
threshold,
)}. Ask for at least that much (you have ${usd(withdrawable)} available).`;
await callback?.({ text: msg, actions: ["WITHDRAW_APP_EARNINGS"] });
return {
success: false,
text: "Requested amount below minimum payout.",
userFacingText: msg,
data: { reason: "below_threshold", withdrawn: false, cta },
};
}
await persistCloudAppConfirmation(runtime, {
roomId,
action: "WITHDRAW_APP_EARNINGS",
appId: target.id,
appName: target.name,
appSlug: target.slug,
amount,
cta,
});
const prompt =
`This will request a payout of ${usd(amount)} from "${target.name}" ` +
`(${target.id}). The funds move to your redeemable balance; you finish ` +
`the cash-out on your dashboard — I never touch your bank details or keys. ` +
`To go ahead, reply that you confirm withdrawing ${usd(amount)} from ${target.name}. ` +
`Or open the dashboard: ${cta.url}`;
await callback?.({ text: prompt, actions: ["WITHDRAW_APP_EARNINGS"] });
return {
success: true,
text: `Awaiting structured confirmation to withdraw ${usd(amount)} from ${target.name}.`,
userFacingText: prompt,
verifiedUserFacing: true,
data: {
app: { id: target.id, name: target.name, slug: target.slug },
amount,
withdrawn: false,
confirmationRequired: true,
cta,
},
};
},
examples: [
[
{
name: "{{user}}",
content: { text: "withdraw my Acme Bot earnings" },
},
{
name: "{{agent}}",
content: {
text: 'This will request a payout of $42.00 from "Acme Bot" (…). The funds move to your redeemable balance; you finish the cash-out on your dashboard — I never touch your bank details or keys. To go ahead, reply that you confirm withdrawing $42.00 from Acme Bot.',
actions: ["WITHDRAW_APP_EARNINGS"],
},
},
],
[
{ name: "{{user}}", content: { text: "I confirm the Acme Bot payout" } },
{
name: "{{agent}}",
content: {
text: 'Requested a payout of $42.00 from "Acme Bot". $42.00 marked as withdrawn. Check your Earnings page to redeem as elizaOS tokens. Finish the cash-out here: https://www.elizacloud.ai/dashboard/apps/…?tab=earnings',
actions: ["WITHDRAW_APP_EARNINGS"],
},
},
],
],
};
export default withdrawAppEarningsAction;
+165
View File
@@ -0,0 +1,165 @@
/**
* Facts/knowledge cache for app deploys (the "completion saves to memory" idea).
*
* On a successful deploy we persist ONE durable fact keyed on `app.id` to the
* runtime's real `facts` memory table, so the agent recalls "you built <name>
* at <url>" later across surfaces. This is a derived convenience cache — it is
* explicitly NOT the completion gate (the gate is READY + reachability in
* deploy-gate.ts). The write is best-effort: a memory failure never fails the
* deploy.
*
* Idempotency is keyed on `app.id`: re-deploying the same app updates the single
* fact in place (status/url/timestamp refresh) rather than appending a duplicate.
* Core IS importable in the agent runtime (unlike the Worker), so we use the real
* `runtime.createMemory` / `getMemories` / `updateMemory` API.
*/
import type { AppDto } from "@elizaos/cloud-sdk";
import type { IAgentRuntime, Memory } from "@elizaos/core";
import { logger, MemoryType } from "@elizaos/core";
/** Marks facts written by this cache so we can find + dedupe them. */
export const APP_DEPLOY_FACT_SOURCE = "cloud_apps_deploy";
export interface RecordDeployFactResult {
/** True when a fact was written (created or updated). */
written: boolean;
/** True when an existing fact for this app.id was updated in place. */
updated: boolean;
/** The memory id, when one was written/updated. */
memoryId?: string;
}
function factText(app: AppDto, url: string): string {
const date = new Date().toISOString().slice(0, 10);
return `User deployed Eliza Cloud app "${app.name}" — live at ${url} (app ${app.id}) on ${date}.`;
}
async function findExistingDeployFact(
runtime: IAgentRuntime,
message: Memory,
appId: string,
): Promise<Memory | null> {
if (typeof runtime.getMemories !== "function") return null;
try {
const rows = await runtime.getMemories({
tableName: "facts",
// Dedup is keyed on app.id for the app owner across ALL rooms/connectors:
// re-deploying the same app from a different surface must update the single
// existing fact, not append a duplicate. Scope to the entity (owner), NOT
// the room — a room-scoped query would miss the prior fact and defeat the
// "exactly one fact per app.id" guarantee.
entityId: message.entityId,
count: 200,
unique: false,
});
if (!Array.isArray(rows)) return null;
return (
rows.find((m) => {
const md = m.metadata as Record<string, unknown> | undefined;
return md?.source === APP_DEPLOY_FACT_SOURCE && md?.appId === appId;
}) ?? null
);
} catch (err) {
// A read failure disables dedup for this write; degrade to create (the worst
// case is a duplicate fact, never a lost deploy) but surface the failure.
logger.warn(
`[CloudApps] deploy-fact dedup read failed for ${appId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
/**
* Write (or idempotently update) the deploy fact for `app` at `url`. Returns
* `{ written: false }` when the runtime has no memory API or the write fails —
* the caller treats this as a non-fatal cache miss.
*/
export async function recordAppDeployFact(
runtime: IAgentRuntime,
message: Memory,
app: AppDto,
url: string,
): Promise<RecordDeployFactResult> {
if (typeof runtime.createMemory !== "function") {
return { written: false, updated: false };
}
const text = factText(app, url);
const metadata = {
type: MemoryType.CUSTOM,
source: APP_DEPLOY_FACT_SOURCE,
appId: app.id,
appName: app.name,
appSlug: app.slug,
appUrl: url,
tags: ["fact", "cloud_app", "deploy", app.id],
// Confirmed live-deploy state is a durable identity fact, not a transient
// single-message claim — keep it out of the time-decay path.
kind: "durable" as const,
confidence: 1,
deployedAt: new Date().toISOString(),
} satisfies Memory["metadata"];
try {
const existing = await findExistingDeployFact(runtime, message, app.id);
if (existing?.id && typeof runtime.updateMemory === "function") {
await runtime.updateMemory({
id: existing.id,
content: { text, type: "fact" },
metadata,
});
return { written: true, updated: true, memoryId: existing.id };
}
const id = await runtime.createMemory(
{
entityId: message.entityId,
agentId: runtime.agentId,
roomId: message.roomId,
content: { text, type: "fact" },
metadata,
} as Memory,
"facts",
true,
);
return { written: true, updated: false, memoryId: id };
} catch (err) {
logger.warn(
`[CloudApps] Failed to record deploy fact for ${app.id}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return { written: false, updated: false };
}
}
/**
* Purge the durable "app is live" deploy fact for `appId` (if one exists) after
* the app is deleted — otherwise the agent keeps recalling a deleted app as
* live at its old URL forever (the fact is `kind:"durable"`, so it never
* decays). Best-effort: returns `false` when there's nothing to remove or the
* runtime has no delete API; a failure never blocks the delete.
*/
export async function removeAppDeployFact(
runtime: IAgentRuntime,
message: Memory,
appId: string,
): Promise<boolean> {
if (typeof runtime.deleteMemory !== "function") return false;
try {
const existing = await findExistingDeployFact(runtime, message, appId);
if (!existing?.id) return false;
await runtime.deleteMemory(existing.id);
return true;
} catch (err) {
logger.warn(
`[CloudApps] Failed to remove deploy fact for ${appId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return false;
}
}
+410
View File
@@ -0,0 +1,410 @@
/**
* Eliza Cloud client construction + app resolution/formatting helpers.
*
* The agent reaches Eliza Cloud with the same credentials plugin-elizacloud
* uses: the `ELIZAOS_CLOUD_API_KEY` setting (sent as the bearer/API key) and the
* `ELIZAOS_CLOUD_BASE_URL` setting (the API base, e.g.
* `https://elizacloud.ai/api/v1`). We mirror plugin-elizacloud's
* `createElizaCloudClient` construction shape: the configured value is the API
* base (it ends at `/api/v1`), so it is passed as `apiBaseUrl`; the site
* `baseUrl` is the same origin with the `/api/v1` suffix stripped.
*/
import type { AppDto } from "@elizaos/cloud-sdk";
import { ElizaCloudClient } from "@elizaos/cloud-sdk";
import type { IAgentRuntime, Memory } from "@elizaos/core";
/** Default Eliza Cloud API base URL (matches the cloud runtime default). */
export const DEFAULT_CLOUD_API_BASE_URL = "https://elizacloud.ai/api/v1";
/** Settings key holding the Eliza Cloud API key. */
export const CLOUD_API_KEY_SETTING = "ELIZAOS_CLOUD_API_KEY";
/** Settings key holding the Eliza Cloud API base URL. */
export const CLOUD_BASE_URL_SETTING = "ELIZAOS_CLOUD_BASE_URL";
function normalizeSecret(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}
/** Strip a trailing `/api/v1` so the SDK gets the bare site origin for `baseUrl`. */
function apiBaseToSiteBaseUrl(apiBaseUrl: string): string {
const trimmed = trimTrailingSlash(apiBaseUrl);
return trimmed.endsWith("/api/v1")
? trimmed.slice(0, -"/api/v1".length)
: trimmed;
}
/** Resolve the Eliza Cloud API key from runtime settings. Returns null when unset. */
export function resolveCloudApiKey(runtime: IAgentRuntime): string | null {
return normalizeSecret(runtime.getSetting(CLOUD_API_KEY_SETTING));
}
/** Resolve the Eliza Cloud API base URL (ends at `/api/v1`). */
export function resolveCloudApiBaseUrl(runtime: IAgentRuntime): string {
return (
normalizeSecret(runtime.getSetting(CLOUD_BASE_URL_SETTING)) ??
DEFAULT_CLOUD_API_BASE_URL
);
}
/**
* Resolve the Eliza Cloud dashboard (site) origin — the API base with a trailing
* `/api/v1` stripped (e.g. `https://www.elizacloud.ai`). Used to build the
* connector-agnostic CTA URLs the paid actions hand back so the user finishes a
* money/credential step in the browser, never over the connector.
*/
export function resolveCloudSiteBaseUrl(runtime: IAgentRuntime): string {
return apiBaseToSiteBaseUrl(resolveCloudApiBaseUrl(runtime));
}
/**
* Construct an authenticated {@link ElizaCloudClient} from runtime settings.
* Returns `null` when no API key is configured so callers can degrade
* gracefully (no key → no cloud calls).
*/
export function getCloudClient(
runtime: IAgentRuntime,
): ElizaCloudClient | null {
const apiKey = resolveCloudApiKey(runtime);
if (!apiKey) return null;
const apiBaseUrl = trimTrailingSlash(resolveCloudApiBaseUrl(runtime));
return new ElizaCloudClient({
apiBaseUrl,
baseUrl: apiBaseToSiteBaseUrl(apiBaseUrl),
apiKey,
});
}
// ─── Formatting ─────────────────────────────────────────────────────────────
/** Coerce a `numeric` decimal string (or number/null) into a finite number. */
function toNumber(value: string | number | null | undefined): number | null {
if (value === null || value === undefined) return null;
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : null;
}
/**
* Draft/placeholder sentinel hosts the cloud uses for "not yet deployed" apps —
* never surfaced to the user as a real URL.
*/
function isPlaceholderUrl(url: string): boolean {
try {
const host = new URL(url).hostname.toLowerCase();
return host === "placeholder.invalid" || host === "placeholder.local";
} catch {
return false;
}
}
/** A live, reachable URL for an app: prefer its production deploy, else its app URL — never a draft sentinel. */
export function appUrl(app: AppDto): string | null {
const prod = normalizeSecret(app.production_url);
if (prod && !isPlaceholderUrl(prod)) return prod;
const declared = normalizeSecret(app.app_url);
if (declared && !isPlaceholderUrl(declared)) return declared;
return null;
}
/** Short human status combining deployment + active flags. */
export function appStatus(app: AppDto): string {
const deployment = app.deployment_status ?? "draft";
if (app.is_active === false) return `${deployment} (inactive)`;
return deployment;
}
/** One-line summary for the list view: "Name — url — status". */
export function formatAppLine(app: AppDto): string {
const parts = [app.name];
const url = appUrl(app);
if (url) parts.push(url);
parts.push(appStatus(app));
return `${parts.join(" — ")}`;
}
/** Multi-line detail block for a single app (GET_APP / provider). */
export function formatAppDetail(app: AppDto): string {
const lines: string[] = [`${app.name} (${app.slug})`];
if (normalizeSecret(app.description)) {
lines.push(app.description as string);
}
const url = appUrl(app);
if (url) lines.push(`URL: ${url}`);
lines.push(`Status: ${appStatus(app)}`);
const creditsUsed = toNumber(app.total_credits_used);
if (creditsUsed !== null) {
lines.push(`Credits used: $${creditsUsed.toFixed(2)}`);
}
if (app.monetization_enabled) {
const earnings = toNumber(app.total_creator_earnings);
lines.push(
earnings !== null
? `Monetization: on — earnings $${earnings.toFixed(2)}`
: "Monetization: on",
);
}
if (typeof app.total_users === "number" && app.total_users > 0) {
lines.push(`Users: ${app.total_users}`);
}
if (typeof app.total_requests === "number" && app.total_requests > 0) {
lines.push(`Requests: ${app.total_requests}`);
}
return lines.join("\n");
}
/**
* Result of resolving a free-text app reference: a single confident match, or —
* when the reference is ambiguous (several apps match equally well) — no match
* plus the tied `candidates`, so a destructive/money action can ask the user to
* disambiguate instead of silently acting on the wrong app.
*/
export interface AppReferenceMatch {
app: AppDto | null;
candidates: AppDto[];
}
/** Escape a string for safe literal use inside a RegExp. */
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* True when `needle` occurs in `haystack` bounded by non-alphanumeric
* characters (a word boundary) — so "bot" matches "delete bot" but NOT the
* "bot" inside "chatbot". Both sides are matched case-insensitively.
*/
function containsAsWholeWord(haystack: string, needle: string): boolean {
return new RegExp(
`(^|[^a-z0-9])${escapeRegExp(needle)}([^a-z0-9]|$)`,
"i",
).test(haystack);
}
/**
* Specificity score for how well the lowercased reference `lower` targets one
* of an item's `names`. 0 = no match; higher = more specific:
* - the name appears in the reference as WHOLE WORDS (a sentence naming the
* item) — scored by the matched name length, so a longer, more specific
* name ("Prod API Backup", 15) beats a prefix ("Prod API", 8).
* - otherwise the reference is a substring the user typed of the name (a
* fragment like "acme") — always scored below any whole-word match.
*/
function referenceScore(lower: string, names: string[]): number {
let best = 0;
for (const field of names) {
const f = field.toLowerCase();
if (!f) continue;
if (f.length >= 3 && containsAsWholeWord(lower, f)) {
best = Math.max(best, 1000 + f.length);
} else if (lower.length >= 2 && f.includes(lower)) {
best = Math.max(best, 500 + lower.length);
}
}
return best;
}
/**
* Result of {@link matchByReference}: a single confident match, or — when the
* reference is ambiguous (several items match equally well) — no match plus
* the tied `candidates`, so a destructive/money action can ask the user to
* disambiguate instead of silently acting on the wrong target.
*/
export interface ReferenceMatch<T> {
item: T | null;
candidates: T[];
}
/**
* Resolve an item (app, influencer profile, …) from a free-text reference
* against a list, ambiguity-aware. `identify` maps each item to its stable id
* plus the human names it answers to (name, slug, display name, …).
*
* Match priority:
* 1. exact id
* 2. exact (case-insensitive) name
* 3. best-scoring fuzzy match ({@link referenceScore}) — a whole-word
* name-in-sentence beats a typed fragment, and a longer name beats a
* shorter prefix. When two or more items tie for the top score the result
* is AMBIGUOUS: `item` is null and `candidates` holds the tied items.
*
* Never silently returns the first of several equally-good matches: destructive
* references such as "delete Prod API Backup" must not resolve to the shorter
* "Prod API" prefix, "delete my chatbot helper" must not match an app named
* "Bot", and adversarial influencer names must not capture someone else's
* booking.
*/
export function matchByReference<T>(
items: T[],
reference: string,
identify: (item: T) => {
id: string;
names: Array<string | null | undefined>;
},
): ReferenceMatch<T> {
const ref = reference.trim();
if (!ref) return { item: null, candidates: [] };
const lower = ref.toLowerCase();
const namesOf = (item: T): string[] =>
identify(item).names.filter(
(n): n is string => typeof n === "string" && n.length > 0,
);
const byId = items.find((item) => identify(item).id === ref);
if (byId) return { item: byId, candidates: [byId] };
const exact = items.filter((item) =>
namesOf(item).some((n) => n.toLowerCase() === lower),
);
if (exact.length === 1) return { item: exact[0], candidates: exact };
if (exact.length > 1) return { item: null, candidates: exact };
const scored = items
.map((item) => ({ item, score: referenceScore(lower, namesOf(item)) }))
.filter((s) => s.score > 0);
if (scored.length === 0) return { item: null, candidates: [] };
const max = Math.max(...scored.map((s) => s.score));
const top = scored.filter((s) => s.score === max).map((s) => s.item);
return top.length === 1
? { item: top[0], candidates: top }
: { item: null, candidates: top };
}
/**
* Resolve an app from a free-text reference against a list, ambiguity-aware.
* App-typed wrapper over {@link matchByReference} (id → exact name/slug →
* whole-word-in-sentence → fragment; ties = ambiguous).
*/
export function matchAppByReference(
apps: AppDto[],
reference: string,
): AppReferenceMatch {
const match = matchByReference(apps, reference, (a) => ({
id: a.id,
names: [a.name, a.slug],
}));
return { app: match.item, candidates: match.candidates };
}
/**
* Back-compat single-result resolver: the confident match, or null (including
* when the reference is ambiguous). Prefer {@link matchAppByReference} when you
* need to surface the tied candidates to the user.
*/
export function findAppByReference(
apps: AppDto[],
reference: string,
): AppDto | null {
return matchAppByReference(apps, reference).app;
}
/** RFC-4122-ish UUID shape check (used to take the direct `getApp(id)` path). */
export function looksLikeAppId(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
value.trim(),
);
}
// ─── Reference resolution (shared by the mutating actions) ───────────────────
/** Planner-option keys that may carry an app reference, in priority order. */
const REFERENCE_OPTION_KEYS = [
"app",
"appName",
"name",
"id",
"appId",
"query",
] as const;
/**
* The candidate objects that may carry planner-validated action args, most
* authoritative first. On the real planner path the validated args arrive
* NESTED under `options.parameters` (execute-planned-tool-call.ts sets
* `handlerOptions.parameters = validation.args`); only direct handler calls /
* scenario `action`-kind turns place them at the top level. Mirrors
* `readStructuredConfirmation` (safety.ts) and `actionParams`
* (domain-intent.ts) so every option read sees both shapes, nested first.
*/
export function plannerOptionSources(
options?: unknown,
): ReadonlyArray<Record<string, unknown>> {
if (!options || typeof options !== "object") return [];
const top = options as Record<string, unknown>;
const nested =
top.parameters && typeof top.parameters === "object"
? (top.parameters as Record<string, unknown>)
: undefined;
return nested ? [nested, top] : [top];
}
/**
* Pull an app reference from planner-supplied options (nested
* `options.parameters` first — the real planner path — then top-level) or,
* failing that, the raw message text. Mirrors the read-core's
* `resolveReference` so the mutating actions resolve apps identically.
*/
export function extractAppReference(
message: Memory,
options?: unknown,
): string {
for (const source of plannerOptionSources(options)) {
for (const key of REFERENCE_OPTION_KEYS) {
const value = source[key];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
}
return (message.content?.text ?? "").trim();
}
export interface ResolvedApp {
/** The matched app, or null when nothing matched OR the reference was ambiguous. */
app: AppDto | null;
/** Names of the user's apps (for a helpful not-found message). */
available: string[];
/**
* Names of the tied candidate apps when the reference was AMBIGUOUS (set only
* when `app` is null because >1 app matched equally well). Lets a
* destructive/money action ask "which one?" instead of a generic not-found.
*/
ambiguous?: string[];
}
/**
* Resolve an app from a free-text reference against the user's apps. Id-shaped
* references take the direct `getApp(id)` path; names resolve via `listApps()` +
* {@link matchAppByReference}. Read-only — used by the mutating actions to locate
* the target before they mutate it. When the reference is ambiguous, `app` is
* null and `ambiguous` holds the tied candidate names.
*/
export async function resolveApp(
client: ElizaCloudClient,
reference: string,
): Promise<ResolvedApp> {
if (looksLikeAppId(reference)) {
const { app } = await client.getApp(reference);
if (app) return { app, available: [app.name] };
}
const { apps } = await client.listApps();
const list = apps ?? [];
const match = matchAppByReference(list, reference);
return {
app: match.app,
available: list.map((a) => a.name),
ambiguous:
match.app === null && match.candidates.length > 1
? match.candidates.map((a) => a.name)
: undefined,
};
}
@@ -0,0 +1,313 @@
/**
* The DEPLOY_APP completion gate.
*
* "Done" is not "the deploy was accepted (202)". The gate runs two checks:
* 1. COMPLETION — poll `getAppDeployStatus` until the public status is READY
* (bounded retries with exponential backoff + an overall timeout). A
* server-reported ERROR/FAILED short-circuits immediately.
* 2. REACHABILITY — once READY, read the authoritative `production_url` from
* the app row (deriveAppPublicUrl semantics — NOT the create response) and
* probe `<production_url>/health`, treating any answer EXCEPT a Caddy
* gateway error (502/503/504) as reachable — the SAME rule the server uses
* to mark the app READY, so the gate never contradicts the server (an
* auth-gated 401/403 app, or one with no `/health` route, is still live).
* Only when BOTH pass do we report the app live.
*
* ROBUSTNESS: the deploy is already running server-side once the gate starts,
* so a transient status-poll failure (network blip, 5xx, HTTP timeout) must
* NOT abort the gate — it is logged via `onPollError` and the gate keeps
* polling until the overall attempt budget runs out, at which point it returns
* the honest "still building" timeout result (never a claim of done). Every
* poll is also bounded by a per-request timeout (`requestTimeoutMs`): the dep
* receives an AbortSignal to tear down the stalled connection, and the gate
* additionally races the call so even a signal-ignoring dep can never wedge it.
*
* The gate is pure and fully injectable (status fetch, app fetch, probe, sleep),
* so unit tests can pin status progression, timeout, and reachability behavior
* without coupling this helper to a live staging deploy backend.
*/
import type { AppDeployStatusResponse, AppResponse } from "@elizaos/cloud-sdk";
import {
healthUrl,
type ReachabilityResult,
respondedLive,
} from "./reachability.js";
/** Terminal outcome of the gate. */
export type DeployPhase = "ready" | "error" | "timeout" | "unreachable";
export interface DeployGateResult {
phase: DeployPhase;
/** The app's public production URL, when one was resolved. */
url: string | null;
/** The last public deploy status string observed. */
status: string;
/** How many status polls ran. */
attempts: number;
/** The reachability probe result (present once status reached READY). */
reachability?: ReachabilityResult;
/** Server-reported error / failure reason, when relevant. */
error?: string;
}
export interface DeployGateConfig {
/** Max status polls before declaring a timeout. */
maxAttempts: number;
/** First backoff delay (ms). */
initialDelayMs: number;
/** Backoff ceiling (ms). */
maxDelayMs: number;
/** Per-probe HTTP timeout passed through to the reachability probe (ms). */
probeTimeoutMs: number;
/**
* Per-request HTTP timeout for each Cloud API call the gate makes (the status
* poll + the app re-read), so one stalled connection can never hang a poll —
* let alone the gate — indefinitely.
*/
requestTimeoutMs: number;
/** Health path probed after READY. */
healthPath: string;
}
export interface DeployGateDeps {
/**
* `client.getAppDeployStatus(id)` — thread the per-poll `signal` into the
* HTTP request so a stalled connection is actually torn down at the
* `requestTimeoutMs` budget (the gate also races the call, so a dep that
* ignores the signal still can't hang it).
*/
getStatus: (signal: AbortSignal) => Promise<AppDeployStatusResponse>;
/**
* `client.getApp(id)` — re-read to get the authoritative production_url.
* Receives the same per-request abort signal as `getStatus`.
*/
getApp: (signal: AbortSignal) => Promise<AppResponse>;
/** Probe a fully-qualified URL for reachability. */
probe: (url: string) => Promise<ReachabilityResult>;
/** Sleep between polls (injected so tests run instantly). */
sleep?: (ms: number) => Promise<void>;
/** Optional progress hook for streaming "still building…" updates. */
onProgress?: (status: string, attempt: number) => void;
/**
* Optional hook fired when one status poll fails transiently (network error,
* HTTP timeout). The gate logs-and-continues — it never aborts on a poll
* error, because the deploy is still running server-side.
*/
onPollError?: (error: unknown, attempt: number) => void;
}
/** Production defaults: ~ up to ~2 min of polling with capped backoff. */
export const DEFAULT_DEPLOY_GATE_CONFIG: DeployGateConfig = {
maxAttempts: 24,
initialDelayMs: 2_000,
maxDelayMs: 10_000,
probeTimeoutMs: 10_000,
requestTimeoutMs: 10_000,
healthPath: "/health",
};
const TERMINAL_SUCCESS = new Set(["READY", "DEPLOYED"]);
const TERMINAL_ERROR = new Set(["ERROR", "FAILED"]);
const NON_RETRIABLE_POLL_ERROR_STATUSES = new Set([401, 403, 404]);
type StatusClass = "success" | "error" | "pending";
/**
* Map the public deploy status to a terminal/pending class. The server's public
* lifecycle is DRAFT | BUILDING | READY | ERROR (with `deploying` folded into
* BUILDING); we also accept the `DEPLOYED` synonym defensively.
*/
export function classifyDeployStatus(
status: string | null | undefined,
): StatusClass {
const s = (status ?? "").trim().toUpperCase();
if (TERMINAL_SUCCESS.has(s)) return "success";
if (TERMINAL_ERROR.has(s)) return "error";
return "pending";
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeUrl(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function cloudApiStatusCode(err: unknown): number | null {
if (typeof err !== "object" || err === null) return null;
const value = (err as Record<string, unknown>).statusCode;
return typeof value === "number" ? value : null;
}
function isNonRetriablePollError(err: unknown): boolean {
const status = cloudApiStatusCode(err);
return status !== null && NON_RETRIABLE_POLL_ERROR_STATUSES.has(status);
}
function pollErrorMessage(err: unknown): string {
if (typeof err === "object" && err !== null) {
const body = (err as Record<string, unknown>).errorBody;
if (typeof body === "object" && body !== null) {
const bodyError = (body as Record<string, unknown>).error;
if (typeof bodyError === "string" && bodyError.trim().length > 0) {
return bodyError;
}
}
}
return err instanceof Error ? err.message : String(err ?? "unknown error");
}
/**
* Run one injected network call under a hard per-request budget. The dep gets
* an `AbortSignal` (threaded into `fetch` so the stalled connection is actually
* torn down), and the call is ALSO raced against the same deadline — so even an
* implementation that ignores the signal can never hang the gate.
*/
function callWithTimeout<T>(
call: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
label: string,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(
() =>
controller.abort(new Error(`${label} timed out after ${timeoutMs}ms`)),
timeoutMs,
);
const deadline = new Promise<never>((_, reject) => {
controller.signal.addEventListener(
"abort",
() => reject(controller.signal.reason),
{ once: true },
);
});
const attempt = Promise.resolve(call(controller.signal));
// If the deadline wins, the in-flight call may still reject later (e.g. the
// aborted fetch) — swallow that so it never surfaces as an unhandled rejection.
attempt.catch(() => {});
return Promise.race([attempt, deadline]).finally(() => clearTimeout(timer));
}
export async function runDeployGate(
deps: DeployGateDeps,
config: DeployGateConfig = DEFAULT_DEPLOY_GATE_CONFIG,
): Promise<DeployGateResult> {
const sleep = deps.sleep ?? defaultSleep;
let lastStatus = "";
let delay = config.initialDelayMs;
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
let statusRes: AppDeployStatusResponse | undefined;
try {
statusRes = await callWithTimeout(
deps.getStatus,
config.requestTimeoutMs,
"deploy status poll",
);
} catch (err) {
if (isNonRetriablePollError(err)) {
return {
phase: "error",
url: null,
status: lastStatus,
attempts: attempt,
error: pollErrorMessage(err),
};
}
// A transient poll failure must NOT abort the gate — the deploy is
// already running server-side. Log it and keep polling until the
// overall attempt budget runs out (which reports "still building",
// never a claim of done).
deps.onPollError?.(err, attempt);
}
if (statusRes) {
lastStatus = statusRes.status ?? "";
deps.onProgress?.(lastStatus, attempt);
const cls = classifyDeployStatus(lastStatus);
if (cls === "error") {
return {
phase: "error",
url: normalizeUrl(statusRes.vercelUrl),
status: lastStatus,
attempts: attempt,
error: statusRes.error ?? undefined,
};
}
if (cls === "success") {
return finishReady(deps, config, statusRes, lastStatus, attempt);
}
}
// pending or failed poll — wait then retry (skip the final wait so the
// loop exits straight to the honest timeout result)
if (attempt < config.maxAttempts) {
await sleep(delay);
delay = Math.min(delay * 2, config.maxDelayMs);
}
}
return {
phase: "timeout",
url: null,
status: lastStatus,
attempts: config.maxAttempts,
};
}
/** READY observed — resolve the authoritative URL and run the reachability leg. */
async function finishReady(
deps: DeployGateDeps,
config: DeployGateConfig,
statusRes: AppDeployStatusResponse,
lastStatus: string,
attempt: number,
): Promise<DeployGateResult> {
// Authoritative URL is the app row's production_url (deriveAppPublicUrl),
// NOT the create/deploy response. Fall back to the status' vercelUrl only
// if the re-read fails or hasn't populated production_url yet.
let url = normalizeUrl(statusRes.vercelUrl);
try {
const { app } = await callWithTimeout(
deps.getApp,
config.requestTimeoutMs,
"app re-read",
);
url = normalizeUrl(app?.production_url) ?? url;
} catch {
// keep vercelUrl fallback
}
if (!url) {
return {
phase: "unreachable",
url: null,
status: lastStatus,
attempts: attempt,
error: "no_production_url",
};
}
const reachability = await deps.probe(healthUrl(url, config.healthPath));
return respondedLive(reachability)
? {
phase: "ready",
url,
status: lastStatus,
attempts: attempt,
reachability,
}
: {
phase: "unreachable",
url,
status: lastStatus,
attempts: attempt,
reachability,
error: reachability.error,
};
}
@@ -0,0 +1,165 @@
/**
* Durable memory of an INTERRUPTED domain purchase (the buy route's 502
* `persist_failed_recoverable`: the org was charged and the domain registered,
* but the attach to the app never completed).
*
* The server finishes such a purchase for free on a retried buy
* (`hasUnrefundedDomainPurchase` → free assign), but ONLY a buy request
* reaches that branch — the availability check reports the org's own
* registered-but-unattached domain as plain unavailable. Without a durable
* marker, an agent that loses the in-flight recovery confirmation (user
* canceled, session ended) would later tell the owner their own paid domain
* "isn't available to register" and never issue the recovering buy. This fact
* is that marker: written when the 502 lands, consulted by the fresh-ask
* unavailable branch, and removed once a buy for the domain succeeds.
*
* Same conventions as app-facts.ts: keyed per (appId, domain), entity-scoped
* across rooms, best-effort (a memory failure never fails the action).
*/
import type { IAgentRuntime, Memory } from "@elizaos/core";
import { logger, MemoryType } from "@elizaos/core";
/** Marks facts written by this module so we can find + dedupe them. */
export const INTERRUPTED_DOMAIN_PURCHASE_SOURCE =
"cloud_apps_domain_purchase_interrupted";
async function findInterruptedFactRow(
runtime: IAgentRuntime,
message: Memory,
appId: string,
domain: string,
): Promise<Memory | null> {
if (typeof runtime.getMemories !== "function") return null;
if (!message.entityId) return null;
try {
const rows = await runtime.getMemories({
tableName: "facts",
// Entity-scoped across ALL rooms: the owner may cancel the recovery in
// one connector and finish it from another.
entityId: message.entityId,
count: 200,
unique: false,
});
if (!Array.isArray(rows)) return null;
return (
rows.find((m) => {
const md = m.metadata as Record<string, unknown> | undefined;
return (
md?.source === INTERRUPTED_DOMAIN_PURCHASE_SOURCE &&
md?.appId === appId &&
md?.domain === domain
);
}) ?? null
);
} catch (err) {
logger.warn(
`[CloudApps] interrupted-purchase fact read failed for ${domain}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
/**
* True when an interrupted (charged + registered, unattached) purchase of
* `domain` for `appId` is on record for this user.
*/
export async function hasInterruptedDomainPurchase(
runtime: IAgentRuntime,
message: Memory,
appId: string,
domain: string,
): Promise<boolean> {
return (
(await findInterruptedFactRow(runtime, message, appId, domain)) !== null
);
}
/**
* Record that a purchase of `domain` charged + registered but failed to
* attach. Idempotent per (appId, domain); best-effort.
*/
export async function recordInterruptedDomainPurchase(
runtime: IAgentRuntime,
message: Memory,
app: { id: string; name: string },
domain: string,
): Promise<boolean> {
if (typeof runtime.createMemory !== "function") return false;
if (!message.entityId) return false;
try {
const existing = await findInterruptedFactRow(
runtime,
message,
app.id,
domain,
);
if (existing) return true;
await runtime.createMemory(
{
entityId: message.entityId,
agentId: runtime.agentId,
roomId: message.roomId,
content: {
text: `User's purchase of the domain ${domain} for Eliza Cloud app "${app.name}" (app ${app.id}) was charged and registered but not yet attached — a retried buy finishes it without a new charge.`,
type: "fact",
},
metadata: {
type: MemoryType.CUSTOM,
source: INTERRUPTED_DOMAIN_PURCHASE_SOURCE,
appId: app.id,
appName: app.name,
domain,
tags: ["fact", "cloud_app", "domain_purchase", app.id, domain],
// A standing money fact must not decay before it is resolved.
kind: "durable" as const,
confidence: 1,
interruptedAt: new Date().toISOString(),
},
} as Memory,
"facts",
true,
);
return true;
} catch (err) {
logger.warn(
`[CloudApps] Failed to record interrupted purchase of ${domain}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return false;
}
}
/**
* Clear the interrupted-purchase marker once a buy for `domain` succeeded
* (fresh, replayed, or recovered). Best-effort.
*/
export async function removeInterruptedDomainPurchase(
runtime: IAgentRuntime,
message: Memory,
appId: string,
domain: string,
): Promise<boolean> {
if (typeof runtime.deleteMemory !== "function") return false;
try {
const existing = await findInterruptedFactRow(
runtime,
message,
appId,
domain,
);
if (!existing?.id) return false;
await runtime.deleteMemory(existing.id);
return true;
} catch (err) {
logger.warn(
`[CloudApps] Failed to remove interrupted-purchase fact for ${domain}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return false;
}
}
@@ -0,0 +1,239 @@
/**
* Pure helpers shared by the domain actions (CHECK_APP_DOMAIN, BUY_APP_DOMAIN,
* LIST_APP_DOMAINS): extracting domain names from planner options / message
* text, resolving which app a domain request targets (with a sole-app
* default), money formatting, and duck-typed CloudApiError inspection so the
* money action can branch on 402/409/502 without importing the SDK error
* class (the test suite mocks `@elizaos/cloud-sdk` with only the client).
*/
import type { AppDto, ElizaCloudClient } from "@elizaos/cloud-sdk";
import type { Memory } from "@elizaos/core";
import {
extractAppReference,
looksLikeAppId,
matchAppByReference,
type ResolvedApp,
} from "./client.js";
/**
* Mirror of the server's canonical domain schema
* (`packages/cloud/api/v1/apps/[id]/domains/schemas.ts`): dot-separated
* labels, alphabetic TLD of 2+ chars, 4253 chars overall. Used to fail fast
* with a friendly message instead of a server 400.
*/
const DOMAIN_SHAPE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
/**
* Find domain-shaped tokens inside free text ("buy example.com for my bot").
*
* The left guard rejects tokens glued to letters/digits/`._@-` so an IDN like
* "münchen.de" is never mangled into a bogus ASCII tail ("nchen.de") and an
* email's domain part is not extracted as a purchase target. Labels are a flat
* `[a-z0-9-]{0,62}` run (no nested optional groups → linear scan, no
* catastrophic backtracking on pasted dotted text); trailing-hyphen shapes the
* flat run admits are rejected by {@link isValidDomain} afterwards.
*/
const DOMAIN_TOKEN =
/(?<![\p{L}\p{N}._@-])(?:[a-z0-9][a-z0-9-]{0,62}\.)+[a-z]{2,24}(?![\p{L}\p{N}-])/giu;
/** Bound the prose scan — nobody names a purchase target 4000 chars in. */
const MAX_SCANNED_TEXT = 4000;
/** True when `value` is a registrable-looking domain (server-schema mirror). */
export function isValidDomain(value: string): boolean {
const v = value.trim();
return v.length >= 4 && v.length <= 253 && DOMAIN_SHAPE.test(v);
}
function normalizeDomain(value: string): string {
return value.trim().toLowerCase().replace(/\.$/, "");
}
/**
* Planner-validated args arrive nested under `options.parameters` on the real
* planner path (execute-planned-tool-call.ts) and at the top level on direct
* handler calls — merge both (nested wins) so extraction sees either shape.
*/
export function actionParams(options?: unknown): Record<string, unknown> {
if (!options || typeof options !== "object") return {};
const top = options as Record<string, unknown>;
const nested =
top.parameters && typeof top.parameters === "object"
? (top.parameters as Record<string, unknown>)
: {};
return { ...top, ...nested };
}
const DOMAIN_OPTION_KEYS = ["domain", "domainName", "hostname"] as const;
/**
* Extract the distinct domain names a message refers to. A planner-supplied
* option always wins; otherwise every domain-shaped token in the text is
* collected (deduped, normalized to lowercase, invalid shapes dropped) so the
* money action can refuse to guess when several domains are named at once.
*/
export function extractDomainReferences(
message: Memory,
options?: unknown,
): string[] {
const params = actionParams(options);
for (const key of DOMAIN_OPTION_KEYS) {
const value = params[key];
if (typeof value === "string" && value.trim().length > 0) {
const domain = normalizeDomain(value);
return isValidDomain(domain) ? [domain] : [];
}
}
const text = (message.content?.text ?? "").slice(0, MAX_SCANNED_TEXT);
const seen = new Set<string>();
for (const match of text.matchAll(DOMAIN_TOKEN)) {
const domain = normalizeDomain(match[0]);
if (isValidDomain(domain)) seen.add(domain);
}
return [...seen];
}
/** Format integer USD cents as "$12.34". */
export function usdFromCents(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
/** Resolution of which app a domain request targets. */
export interface DomainTargetApp extends ResolvedApp {
/**
* True when no reference matched but the user has exactly one app, so it
* was used as the obvious default.
*/
defaulted?: boolean;
/** The user's apps as fetched during resolution (for app-agnostic fallbacks). */
apps: AppDto[];
}
/** Planner-option keys that carry an explicit app reference (client.ts mirror). */
const EXPLICIT_APP_KEYS = ["app", "appName", "name", "id", "appId"] as const;
function hasExplicitAppReference(options?: unknown): boolean {
const params = actionParams(options);
return EXPLICIT_APP_KEYS.some(
(key) =>
typeof params[key] === "string" &&
(params[key] as string).trim().length > 0,
);
}
/**
* Resolve the app a domain action targets. Like {@link resolveApp}, plus a
* sole-app default: domain requests often name only the domain ("buy
* example.com"), so when the free-text reference matches nothing and the user
* has exactly one app, that app is the unambiguous target. The default is
* suppressed when the planner supplied an EXPLICIT app reference that matched
* nothing — the user named a specific app, so guessing a different one is
* wrong even with only one to guess. With several apps and no match the
* caller must ask (never guess where a purchase attaches).
*/
export async function resolveDomainTargetApp(
client: ElizaCloudClient,
message: Memory,
options?: unknown,
): Promise<DomainTargetApp> {
const reference = extractAppReference(message, actionParams(options));
if (looksLikeAppId(reference)) {
// A stale/foreign UUID must fall through to name resolution (and its
// helpful which-app reply), not abort the whole action on the 404.
try {
const { app } = await client.getApp(reference);
if (app) return { app, available: [app.name], apps: [app] };
} catch {
// fall through to list-based resolution
}
}
const { apps } = await client.listApps();
const list = apps ?? [];
const available = list.map((a) => a.name);
const match = matchAppByReference(list, reference);
if (match.app) return { app: match.app, available, apps: list };
if (match.candidates.length > 1) {
return {
app: null,
available,
ambiguous: match.candidates.map((a) => a.name),
apps: list,
};
}
if (list.length === 1 && !hasExplicitAppReference(options)) {
return { app: list[0], available, defaulted: true, apps: list };
}
return { app: null, available, apps: list };
}
/** What a domain action needs to know about a thrown Cloud API error. */
export interface CloudErrorInfo {
/** HTTP status when the error was a CloudApiError, else null. */
status: number | null;
/** The server's machine-readable `code` field, when present. */
code: string | null;
/** Human message (the server's `error` field when available). */
message: string;
}
/**
* Duck-typed view of an SDK `CloudApiError` (`statusCode` + `errorBody`).
* Never throws; unknown errors come back as `{ status: null, code: null }`.
*/
export function cloudErrorInfo(err: unknown): CloudErrorInfo {
let status: number | null = null;
let code: string | null = null;
let bodyError: string | null = null;
if (typeof err === "object" && err !== null) {
const e = err as Record<string, unknown>;
if (typeof e.statusCode === "number") status = e.statusCode;
if (e.errorBody && typeof e.errorBody === "object") {
const body = e.errorBody as Record<string, unknown>;
if (typeof body.code === "string") code = body.code;
if (typeof body.error === "string") bodyError = body.error;
}
}
const message =
bodyError ??
(err instanceof Error ? err.message : String(err ?? "unknown error"));
return { status, code, message };
}
/** Human summary line for one attached domain (LIST_APP_DOMAINS). */
export function formatDomainLine(domain: {
domain: string;
registrar: string;
status: string;
verified: boolean;
/** Nullable: the ssl_status column has a default but no NOT NULL. */
sslStatus: string | null;
expiresAt: string | null;
/** The TXT verification token (present for unverified external domains). */
verificationToken?: string | null;
}): string {
const parts = [
domain.registrar === "cloudflare"
? "registered through Eliza Cloud"
: "external",
domain.status,
`SSL ${domain.sslStatus ?? "pending"}`,
];
if (domain.registrar === "external" && !domain.verified) {
parts.push(
domain.verificationToken
? `needs DNS verification (add a TXT record at _eliza-cloud-verify.${domain.domain} with value ${domain.verificationToken})`
: `needs DNS verification (add the TXT record at _eliza-cloud-verify.${domain.domain})`,
);
}
if (domain.expiresAt) {
const day = domain.expiresAt.slice(0, 10);
if (day) parts.push(`renews ${day}`);
}
return `${domain.domain}${parts.join(", ")}`;
}
/** One AppDto field the domain actions surface in replies. */
export function appLabel(app: AppDto): string {
return `"${app.name}" (${app.id})`;
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Cloud Apps lifecycle plugin for connector-driven app management.
*
* The registration lets an Eliza agent list, inspect, create, deploy, monetize,
* back up, and safely mutate the user's Eliza Cloud Apps from every connector
* surface through the shared AgentRuntime pipeline.
*
* Read-core (non-mutating):
* - Action LIST_CLOUD_APPS — list the user's apps (name / url / status).
* - Action GET_APP — details for one app by name or id.
* - Provider CLOUD_APPS — injects the app inventory into planner context.
*
* Create → deploy → live loop + safe delete (this layer):
* - Action CREATE_APP — create an app from name/description/monetization intent.
* - Action DEPLOY_APP — deploy + COMPLETION GATE (READY status, then
* probe production_url `/health` for 2xx before
* claiming live) + idempotent facts cache.
* - Action GET_APP_DEPLOY_STATUS — report DRAFT/BUILDING/DEPLOYING/READY/ERROR + url.
* - Action DELETE_APP — DESTRUCTIVE: two-phase, connector-agnostic confirm.
*
* Manage layer (edit / monetize / earnings / money-out / key rotation):
* - Action UPDATE_APP — rename / edit name, description, logo, website, email.
* - Action UPDATE_MONETIZATION — enable/disable + markup % / purchase share %; range-guarded.
* - Action GET_APP_EARNINGS — READ-ONLY: withdrawable / pending / lifetime / withdrawn.
* - Action WITHDRAW_APP_EARNINGS — MONEY-OUT: two-phase confirm + dashboard CTA; the safe,
* idempotent, server-gated request endpoint fires on confirm;
* money/credentials NEVER transit the connector.
* - Action REGENERATE_APP_API_KEY— SECURITY: two-phase confirm; new key shown ONCE, never logged.
*
* Domains layer (the last launch slice — check → buy → list):
* - Action CHECK_APP_DOMAIN — READ-ONLY availability + purchase/renewal price quote.
* - Action BUY_APP_DOMAIN — MONEY-OUT: read-only quote first, two-phase confirm with a
* 15-minute quote TTL; maps the server's idempotent-buy /
* refund-on-registrar-failure / no-charge-recovery outcomes
* to honest replies; money never transits the connector.
* - Action LIST_APP_DOMAINS — READ-ONLY: registrar/status/SSL/verification per domain.
*
* Auth uses `ELIZAOS_CLOUD_API_KEY` plus optional `ELIZAOS_CLOUD_BASE_URL` from
* runtime settings, matching plugin-elizacloud credentials. Without a key,
* actions decline gracefully and the provider stays empty.
*/
import type { Plugin } from "@elizaos/core";
import { getAdCampaignAttributionAction } from "./actions/ad-attribution.js";
import {
duplicateAdCampaignAction,
exportAdCampaignReportAction,
setAdCampaignDaypartingAction,
} from "./actions/ad-campaigns.js";
import {
createAdSlotAction,
listAdSlotsAction,
} from "./actions/ad-inventory.js";
import { backupAppAction } from "./actions/backup-app.js";
import { bookInfluencerAction } from "./actions/book-influencer.js";
import { buyAppDomainAction } from "./actions/buy-app-domain.js";
import { checkAppDomainAction } from "./actions/check-app-domain.js";
import { createAppAction } from "./actions/create-app.js";
import { deleteAppAction } from "./actions/delete-app.js";
import { deployAppAction } from "./actions/deploy-app.js";
import { deployFrontendAction } from "./actions/deploy-frontend.js";
import { getAppAction } from "./actions/get-app.js";
import { getAppDeployStatusAction } from "./actions/get-app-deploy-status.js";
import { getAppEarningsAction } from "./actions/get-app-earnings.js";
import {
createInfluencerProfileAction,
listInfluencersAction,
} from "./actions/influencer.js";
import { listAppDomainsAction } from "./actions/list-app-domains.js";
import { listCloudAppsAction } from "./actions/list-cloud-apps.js";
import {
draftPressReleaseAction,
listPressReleasesAction,
submitPressReleaseAction,
} from "./actions/press-releases.js";
import { regenerateAppApiKeyAction } from "./actions/regenerate-app-api-key.js";
import {
listFrontendDeploymentsAction,
rollbackFrontendAction,
} from "./actions/rollback-frontend.js";
import { updateAppAction } from "./actions/update-app.js";
import { updateMonetizationAction } from "./actions/update-monetization.js";
import { withdrawAppEarningsAction } from "./actions/withdraw-app-earnings.js";
import { cloudAppsProvider } from "./providers/cloud-apps.js";
export { getAdCampaignAttributionAction } from "./actions/ad-attribution.js";
export {
duplicateAdCampaignAction,
setAdCampaignDaypartingAction,
} from "./actions/ad-campaigns.js";
export {
createAdSlotAction,
listAdSlotsAction,
} from "./actions/ad-inventory.js";
export { backupAppAction } from "./actions/backup-app.js";
export { bookInfluencerAction } from "./actions/book-influencer.js";
export { buyAppDomainAction } from "./actions/buy-app-domain.js";
export { checkAppDomainAction } from "./actions/check-app-domain.js";
export { createAppAction } from "./actions/create-app.js";
export { deleteAppAction } from "./actions/delete-app.js";
export { deployAppAction } from "./actions/deploy-app.js";
export { deployFrontendAction } from "./actions/deploy-frontend.js";
export { getAppAction } from "./actions/get-app.js";
export { getAppDeployStatusAction } from "./actions/get-app-deploy-status.js";
export { getAppEarningsAction } from "./actions/get-app-earnings.js";
export {
createInfluencerProfileAction,
listInfluencersAction,
} from "./actions/influencer.js";
export { listAppDomainsAction } from "./actions/list-app-domains.js";
export { listCloudAppsAction } from "./actions/list-cloud-apps.js";
export {
draftPressReleaseAction,
listPressReleasesAction,
submitPressReleaseAction,
} from "./actions/press-releases.js";
export { regenerateAppApiKeyAction } from "./actions/regenerate-app-api-key.js";
export {
listFrontendDeploymentsAction,
rollbackFrontendAction,
} from "./actions/rollback-frontend.js";
export { updateAppAction } from "./actions/update-app.js";
export { updateMonetizationAction } from "./actions/update-monetization.js";
export { withdrawAppEarningsAction } from "./actions/withdraw-app-earnings.js";
export * from "./app-facts.js";
export * from "./client.js";
export * from "./deploy-gate.js";
export * from "./domain-facts.js";
export * from "./domain-intent.js";
export { cloudAppsProvider } from "./providers/cloud-apps.js";
export * from "./reachability.js";
export * from "./safety.js";
export const cloudAppsPlugin: Plugin = {
name: "cloud-apps",
description:
"Eliza Cloud Apps: list and describe the user's apps, create them, deploy them with a live-verification gate, check deploy status, safely delete them, and manage their custom domains (check, buy, list) — across every connector.",
actions: [
listCloudAppsAction,
getAppAction,
createAppAction,
deployAppAction,
deployFrontendAction,
listFrontendDeploymentsAction,
rollbackFrontendAction,
getAppDeployStatusAction,
deleteAppAction,
updateAppAction,
updateMonetizationAction,
getAppEarningsAction,
withdrawAppEarningsAction,
regenerateAppApiKeyAction,
getAdCampaignAttributionAction,
createAdSlotAction,
listAdSlotsAction,
setAdCampaignDaypartingAction,
duplicateAdCampaignAction,
exportAdCampaignReportAction,
createInfluencerProfileAction,
listInfluencersAction,
bookInfluencerAction,
draftPressReleaseAction,
listPressReleasesAction,
submitPressReleaseAction,
backupAppAction,
checkAppDomainAction,
buyAppDomainAction,
listAppDomainsAction,
],
providers: [cloudAppsProvider],
};
export default cloudAppsPlugin;
@@ -0,0 +1,122 @@
/**
* CLOUD_APPS provider — injects the user's Eliza Cloud app inventory into the
* planner context so the agent can reason about "my apps" without first calling
* an action. Modeled on plugin-elizacloud's `creditBalanceProvider`: 60s
* in-memory cache keyed by runtime, context-gated to apps/finance/settings, and
* EMPTY when no Cloud API key is configured.
*/
import type { AppDto } from "@elizaos/cloud-sdk";
import type {
IAgentRuntime,
Memory,
Provider,
ProviderResult,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
appStatus,
appUrl,
getCloudClient,
resolveCloudApiKey,
} from "../client.js";
const TTL = 60_000;
const MAX_APPS_RENDERED = 10;
const appsCaches = new WeakMap<IAgentRuntime, { apps: AppDto[]; at: number }>();
/**
* Drop the cached app list for a runtime so the next provider read re-fetches
* live. Call after a mutating action (create/delete/deploy) so the CLOUD_APPS
* provider never keeps serving a just-deleted app (or hiding a just-created one)
* inside the 60s TTL window of the same conversation.
*/
export function invalidateAppsCache(runtime: IAgentRuntime): void {
appsCaches.delete(runtime);
}
const EMPTY: ProviderResult = { text: "" };
function render(apps: AppDto[]): ProviderResult {
if (apps.length === 0) {
return {
text: "Eliza Cloud apps: none yet.",
values: { cloudAppCount: 0 },
data: { apps: [] },
};
}
const shown = apps.slice(0, MAX_APPS_RENDERED);
const lines = shown.map((a) => {
const url = appUrl(a);
return `- ${a.name}${url ? ` (${url})` : ""}${appStatus(a)}`;
});
if (apps.length > shown.length) {
lines.push(`…and ${apps.length - shown.length} more`);
}
const header =
apps.length === 1
? "The user has 1 Eliza Cloud app:"
: `The user has ${apps.length} Eliza Cloud apps:`;
return {
text: `${header}\n${lines.join("\n")}`,
values: { cloudAppCount: apps.length },
data: {
apps: shown.map((a) => ({
id: a.id,
name: a.name,
slug: a.slug,
status: a.deployment_status,
})),
},
};
}
export const cloudAppsProvider: Provider = {
name: "CLOUD_APPS",
description: "The user's Eliza Cloud apps (name, URL, deployment status).",
descriptionCompressed: "User's Eliza Cloud apps.",
dynamic: true,
contexts: ["settings", "finance", "apps"],
contextGate: { anyOf: ["settings", "finance", "apps"] },
cacheStable: false,
cacheScope: "turn",
position: 92,
async get(
runtime: IAgentRuntime,
_message: Memory,
_state: State,
): Promise<ProviderResult> {
if (resolveCloudApiKey(runtime) === null) return EMPTY;
const cached = appsCaches.get(runtime);
if (cached && Date.now() - cached.at < TTL) {
return render(cached.apps);
}
const client = getCloudClient(runtime);
if (!client) return EMPTY;
try {
const { apps } = await client.listApps();
const list = apps ?? [];
appsCaches.set(runtime, { apps: list, at: Date.now() });
return render(list);
} catch (err) {
logger.warn(
`[CloudApps] Failed to fetch apps: ${
err instanceof Error ? err.message : String(err)
}`,
);
// Serve a stale cache if we have one; otherwise stay EMPTY (no prompt bloat).
if (cached) return render(cached.apps);
return EMPTY;
}
},
};
export default cloudAppsProvider;
@@ -0,0 +1,125 @@
/**
* HTTP reachability probe for the DEPLOY_APP completion gate.
*
* After the deploy status flips to READY we still don't claim an app is "live"
* until its public endpoint actually answers. {@link probeReachable} does a
* bounded HTTP GET (default `/health`) and reports whether it returned 2xx.
*
* The network boundary (`fetchImpl`) is injectable so the deploy gate's unit
* tests can drive reachable / unreachable / timeout without a live container —
* production passes the global `fetch`.
*/
export interface ReachabilityResult {
/** True iff the endpoint answered with a 2xx status. */
ok: boolean;
/** The HTTP status code, when a response was received. */
status?: number;
/** A short reason when the probe failed (network error / abort / no-fetch). */
error?: string;
}
/** Minimal fetch surface the probe needs — satisfied by the global `fetch`. */
export type FetchLike = (
input: string,
init?: {
method?: string;
signal?: AbortSignal;
redirect?: "follow" | "manual";
},
) => Promise<{ ok: boolean; status: number }>;
export interface ProbeOptions {
/** Abort the probe after this many ms (default 10s). */
timeoutMs?: number;
/** Injected fetch (defaults to the global `fetch`). */
fetchImpl?: FetchLike;
}
const DEFAULT_TIMEOUT_MS = 10_000;
const globalFetchLike: FetchLike | undefined =
typeof globalThis.fetch === "function"
? (input, init) => globalThis.fetch(input, init)
: undefined;
/** Append `/health` (or another path) to a base URL, collapsing slashes. */
export function healthUrl(base: string, path = "/health"): string {
const trimmedBase = base.replace(/\/+$/, "");
const trimmedPath = path.startsWith("/") ? path : `/${path}`;
return `${trimmedBase}${trimmedPath}`;
}
/**
* Caddy gateway statuses: the ingress is up but the upstream app isn't serving.
* Any OTHER completed status (200/3xx/401/403/404/…) means the app answered.
*/
const GATEWAY_DOWN_STATUSES = new Set([502, 503, 504]);
/**
* Whether a probe result means the app ANSWERED — the SAME rule the server uses
* to mark an app READY (`isReachableStatus`: reachable unless the status is a
* Caddy gateway error 502/503/504). A 401/403 auth gate or a 404 still proves
* the container is up and serving; a network error / abort (no status at all) is
* NOT reachable. Using this instead of a strict-2xx check keeps the DEPLOY_APP
* completion gate from contradicting the server's own live decision (an
* auth-gated app, or one with no `/health` route, is live per the server but a
* strict-2xx check would report "not live").
*/
export function respondedLive(result: ReachabilityResult): boolean {
return (
typeof result.status === "number" &&
!GATEWAY_DOWN_STATUSES.has(result.status)
);
}
/**
* Probe a URL with a bounded HTTP GET. Never throws — a network error or abort
* resolves to `{ ok: false }` with no `status`; any HTTP response resolves with
* its `status` (and `ok` reflecting a strict 2xx). Redirects are NOT followed
* (same as the server's probe): a 3xx surfaces as its own status, which
* {@link respondedLive} counts as live. Callers that want the server's "the app
* answered" rule should use {@link respondedLive}.
*/
export async function probeReachable(
url: string,
options: ProbeOptions = {},
): Promise<ReachabilityResult> {
const fetchImpl = options.fetchImpl ?? globalFetchLike;
if (typeof fetchImpl !== "function") {
return { ok: false, error: "no_fetch" };
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
// SSRF note: `url` is NOT user-controlled — the deploy gate derives it from
// `app.production_url`, read back from the authenticated Cloud row via
// `getApp` (Cloud-authoritative first-party origin), never from message text.
// So a raw bounded GET is acceptable here without the core network SSRF guard.
//
// `redirect: "manual"` MIRRORS the server's authoritative probe
// (`probeUrlReachable` in cloud-shared app-reachability): don't chase
// redirects (avoid loops / external hops) — a 3xx already proves the app
// answered, and `respondedLive` treats it as live. Following redirects here
// let a redirecting `/health` land on a failing target and contradict the
// server's READY with a false "not live".
const res = await fetchImpl(url, {
method: "GET",
signal: controller.signal,
redirect: "manual",
});
const ok =
res.ok === true ||
(typeof res.status === "number" && res.status >= 200 && res.status < 300);
return { ok, status: res.status };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
} finally {
clearTimeout(timer);
}
}
+515
View File
@@ -0,0 +1,515 @@
/**
* Shared safety primitives for the plugin's destructive + paid actions.
*
* ── Two-phase confirm (connector-agnostic) ───────────────────────────────────
* A destructive or paid action NEVER acts on the first ask. On the first turn it
* returns a confirmation prompt that names the exact target and stores a pending
* confirmation task. It acts only when a later turn carries the planner's
* structured `confirm: true` boolean for that pending task. The handler never
* authorizes money/security/destructive work by matching the user's prose, so
* non-English confirmations depend on the planner's structured extraction
* instead of English keyword banks.
*
* ── Connector-agnostic CTA (for the paid actions) ────────────────────────────
* Paid actions that must hand the user off to a browser (withdraw earnings, buy
* a domain) build a neutral {label,url,kind} object the connector renders
* however it can (Discord link button, Telegram URL button, in-app card). Money
* and credentials NEVER transit the connector: the CTA carries only a human
* label plus an https URL the user opens themselves. {@link buildConnectorCta}
* is the single seam those actions reuse.
*/
import type { IAgentRuntime, Memory, UUID } from "@elizaos/core";
import { logger } from "@elizaos/core";
/** How a connector should render a call-to-action handed back by an action. */
export type CtaKind = "link" | "button" | "card";
/**
* A neutral call-to-action a connector renders. Carries ONLY a label + URL —
* never a token, secret, signed payload, or money amount. The user completes
* any payment/credential step in the browser the URL opens.
*/
export interface ConnectorCta {
label: string;
url: string;
kind: CtaKind;
}
/** The thing a destructive/paid action is about to act on. */
export interface ConfirmTarget {
/** Human-facing label, e.g. the app name. */
name: string;
/** Stable id, e.g. the app id (matched verbatim when present). */
id?: string;
/** Other strings that also identify the target (slug, etc.). */
aliases?: string[];
}
export type CloudAppConfirmationAction =
| "BUY_APP_DOMAIN"
| "DELETE_APP"
| "REGENERATE_APP_API_KEY"
| "WITHDRAW_APP_EARNINGS"
| "BOOK_INFLUENCER"
| "SUBMIT_PRESS_RELEASE";
export const CLOUD_APP_CONFIRM_TAG = "cloud-apps-confirm";
export interface CloudAppConfirmationMetadata {
roomId: string;
action: CloudAppConfirmationAction;
appId: string;
appName: string;
appSlug?: string;
amount?: number;
/**
* The confirmed charge in integer USD cents (BUY_APP_DOMAIN) — compared
* exactly against the re-checked price at purchase time so the server never
* debits a price the user did not confirm.
*/
amountUsdCents?: number;
/** The exact domain a pending BUY_APP_DOMAIN confirmation is for. */
domain?: string;
/**
* True when the pending is a recovery retry of a money move whose earlier
* attempt already (or may already) have committed server-side:
* BUY_APP_DOMAIN — the purchase charged + registered but failed to attach
* (the server finishes it without a new charge); BOOK_INFLUENCER — the fund
* call failed at the transport level, so the escrow may already be held and
* the pending's taskId is the sole holder of the idempotency key the server
* dedupes/resumes on. Recovery retries complete without a second charge.
*/
recovery?: boolean;
cta?: ConnectorCta;
intentCreatedAt?: string;
/**
* BOOK_INFLUENCER only: the campaign brief for the pending booking. (For that
* action `appId`/`appName` carry the influencer profile id + display name.)
*/
brief?: string;
}
export interface PendingCloudAppConfirmation {
taskId: string;
metadata: CloudAppConfirmationMetadata;
}
/**
* A pending money confirmation is honored for this long; after that the gated
* action refuses a bare confirm and asks the user to re-state the intent.
* Shared by every gated action so a stale pending can never fund a
* booking/purchase the user has long forgotten about.
*/
export const CONFIRM_TTL_MS = 15 * 60 * 1000;
/**
* True when a pending confirmation is older than {@link CONFIRM_TTL_MS}.
*
* A recovery retry never expires: it completes with no new charge — there is
* no stale price to protect, and expiring it would strand money already
* committed server-side (a paid, unattached domain; an influencer escrow whose
* idempotency key only the pending still holds).
*/
export function pendingExpired(
pending: PendingCloudAppConfirmation,
now: number = Date.now(),
): boolean {
if (pending.metadata.recovery === true) return false;
const at =
typeof pending.metadata.intentCreatedAt === "string"
? Date.parse(pending.metadata.intentCreatedAt)
: Number.NaN;
if (!Number.isFinite(at)) return false;
return now - at > CONFIRM_TTL_MS;
}
export function readStructuredConfirmation(options?: unknown): boolean | null {
if (!options || typeof options !== "object") return null;
const opts = options as Record<string, unknown>;
// Validated action parameters arrive nested under `options.parameters` on the
// real planner path (execute-planned-tool-call.ts sets `handlerOptions.parameters
// = validation.args`); only direct handler calls / scenario `action`-kind turns
// place them at the top level. Read the nested location first, then fall back.
const params =
opts.parameters && typeof opts.parameters === "object"
? (opts.parameters as Record<string, unknown>)
: undefined;
const value =
params?.confirm ?? params?.confirmed ?? opts.confirm ?? opts.confirmed;
if (value === true || value === false) return value;
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1") return true;
if (normalized === "false" || normalized === "0") return false;
return null;
}
// ─── Confirm-turn target consistency (the "frozen target" guard) ─────────────
//
// The gated actions execute the params FROZEN at the first ask — the confirm
// turn is never re-parsed for new work. But when the confirm turn's own
// structured params clearly name a DIFFERENT target ("yes — delete Beta
// Dashboard" while the pending delete is for "Acme Bot"), executing the frozen
// target acts on something the user is no longer talking about. These helpers
// detect that conflict so the action refuses + clears the pending instead of
// mutating. They are deliberately lenient: a bare confirm, generic filler
// ("my app"), or a partial name of the SAME target never blocks.
/**
* Planner-option keys that may carry the confirm turn's own app reference.
* Mirrors the reference keys the actions resolve with at the first ask, minus
* `query` (which can carry loose prose that must not read as a target switch).
*/
export const CONFIRM_APP_REFERENCE_KEYS = [
"app",
"appName",
"name",
"id",
"appId",
] as const;
/** Filler words that alone never name a specific target ("my app", "it"). */
const GENERIC_REFERENCE_WORDS = new Set([
"my",
"the",
"this",
"that",
"our",
"your",
"it",
"its",
"app",
"apps",
"application",
"one",
"domain",
"influencer",
"creator",
"profile",
"booking",
"key",
"earnings",
]);
function normalizeReference(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, " ");
}
/** True when the reference is generic filler that names no specific target. */
function isGenericReference(reference: string): boolean {
return normalizeReference(reference)
.split(" ")
.every((word) => word.length === 0 || GENERIC_REFERENCE_WORDS.has(word));
}
/**
* The confirm turn's own structured target reference, when the planner sent
* one alongside `confirm` (nested `options.parameters` first — the real
* planner path — then top-level). Null = a bare confirm with no reference.
*/
export function readConfirmTurnReference(
options: unknown,
keys: readonly string[],
): string | null {
if (!options || typeof options !== "object") return null;
const top = options as Record<string, unknown>;
const nested =
top.parameters && typeof top.parameters === "object"
? (top.parameters as Record<string, unknown>)
: undefined;
for (const source of nested ? [nested, top] : [top]) {
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
}
return null;
}
/**
* True when `reference` plausibly names the frozen target: its id verbatim, or
* a normalized exact/containment match on the name or an alias. Lenient on
* purpose — a partial name ("acme" for "Acme Bot") or generic filler ("my
* app") must NOT read as a target switch; only a clearly different name does.
*/
export function confirmReferenceMatchesTarget(
reference: string,
target: ConfirmTarget,
): boolean {
const ref = normalizeReference(reference);
if (ref.length === 0 || isGenericReference(reference)) return true;
if (typeof target.id === "string" && normalizeReference(target.id) === ref) {
return true;
}
const names = [target.name, ...(target.aliases ?? [])]
.filter(
(name): name is string =>
typeof name === "string" && name.trim().length > 0,
)
.map(normalizeReference);
return names.some(
(name) => name === ref || name.includes(ref) || ref.includes(name),
);
}
/**
* The conflicting reference when the confirm turn's structured params name a
* DIFFERENT target than the frozen pending snapshot — the gated action must
* refuse (and clear the pending) instead of executing the frozen target. Null
* = consistent: a bare confirm, or a reference matching the frozen target.
*/
export function conflictingConfirmTarget(
options: unknown,
target: ConfirmTarget,
keys: readonly string[] = CONFIRM_APP_REFERENCE_KEYS,
): string | null {
const reference = readConfirmTurnReference(options, keys);
if (reference === null) return null;
return confirmReferenceMatchesTarget(reference, target) ? null : reference;
}
/**
* The conflicting amount when the confirm turn's structured params carry a
* numeric amount that differs from the frozen one (e.g. "confirm — but only
* $50" against a pending $100 withdrawal). Only trusts an unambiguous number
* (a number, or a plain numeric string with an optional leading `$`); prose
* never blocks. Null = consistent or no amount sent.
*/
export function conflictingConfirmAmount(
options: unknown,
frozenAmount: number,
): number | null {
if (!options || typeof options !== "object") return null;
const top = options as Record<string, unknown>;
const nested =
top.parameters && typeof top.parameters === "object"
? (top.parameters as Record<string, unknown>)
: undefined;
const value = nested?.amount ?? top.amount;
let amount: number | null = null;
if (typeof value === "number" && Number.isFinite(value)) {
amount = value;
} else if (typeof value === "string") {
const cleaned = value.trim().replace(/^\$/, "");
if (/^\d+(\.\d+)?$/.test(cleaned)) amount = Number(cleaned);
}
if (amount === null) return null;
return Math.abs(amount - frozenAmount) < 0.005 ? null : amount;
}
/**
* The conflicting domain when the confirm turn's structured `domain` param
* names a DIFFERENT domain than the frozen pending purchase. Domains are exact
* identifiers, so unlike app names this is an exact comparison
* (case-insensitive, ignoring a leading "www." and a trailing dot); values
* that don't look like a domain never block. Null = consistent or none sent.
*/
export function conflictingConfirmDomain(
options: unknown,
frozenDomain: string,
): string | null {
const reference = readConfirmTurnReference(options, ["domain"]);
if (reference === null) return null;
const normalize = (value: string): string =>
value
.trim()
.toLowerCase()
.replace(/\.$/, "")
.replace(/^www\./, "");
const turn = normalize(reference);
if (!turn.includes(".")) return null;
return turn === normalize(frozenDomain) ? null : reference;
}
/**
* The shared refusal copy for a confirm-turn target/amount conflict. Truthful:
* nothing was executed and the pending confirmation has been cleared by the
* caller before replying.
*/
export function confirmTargetMismatchMessage(
requested: string,
what: string,
pendingName: string,
): string {
return (
`Your confirmation names "${requested}", but the pending ${what} was for "${pendingName}". ` +
`To be safe I did nothing, and that pending confirmation is now cleared. ` +
`Re-state what you want and I'll ask you to confirm again.`
);
}
export function confirmationRoomId(
runtime: IAgentRuntime,
message: Memory,
): string {
if (typeof message.roomId === "string" && message.roomId.length > 0) {
return message.roomId;
}
return String(runtime.agentId ?? "cloud-apps-default-room");
}
function isCloudAppConfirmationMetadata(
metadata: Record<string, unknown> | undefined,
roomId: string,
action: CloudAppConfirmationAction,
): metadata is Record<string, unknown> & CloudAppConfirmationMetadata {
return (
metadata?.roomId === roomId &&
metadata.action === action &&
typeof metadata.appId === "string" &&
typeof metadata.appName === "string"
);
}
export async function findPendingCloudAppConfirmation(
runtime: IAgentRuntime,
roomId: string,
action: CloudAppConfirmationAction,
): Promise<PendingCloudAppConfirmation | null> {
const tasks = await runtime.getTasks({
agentIds: [runtime.agentId],
tags: [CLOUD_APP_CONFIRM_TAG],
});
const matching = tasks
.map((task): PendingCloudAppConfirmation | null => {
const metadata = task.metadata as Record<string, unknown> | undefined;
if (
!task.id ||
!isCloudAppConfirmationMetadata(metadata, roomId, action)
) {
return null;
}
return {
taskId: task.id,
metadata,
};
})
.filter((task): task is PendingCloudAppConfirmation => task !== null)
.sort((a, b) => {
const aAt =
typeof a.metadata.intentCreatedAt === "string"
? Date.parse(a.metadata.intentCreatedAt) || 0
: 0;
const bAt =
typeof b.metadata.intentCreatedAt === "string"
? Date.parse(b.metadata.intentCreatedAt) || 0
: 0;
return bAt - aAt;
});
return matching[0] ?? null;
}
export async function persistCloudAppConfirmation(
runtime: IAgentRuntime,
metadata: CloudAppConfirmationMetadata,
): Promise<void> {
await runtime.createTask({
name: `${metadata.action} confirm`,
description: `Awaiting user confirmation for ${metadata.action}: ${metadata.appName}`,
tags: [CLOUD_APP_CONFIRM_TAG],
metadata: {
...metadata,
intentCreatedAt: metadata.intentCreatedAt ?? new Date().toISOString(),
},
});
}
/**
* Mark an existing pending confirmation as a recovery retry IN PLACE.
*
* The task is updated, never deleted + re-created: for BOOK_INFLUENCER the
* taskId is the escrow idempotency key (`influencer-confirm-<taskId>`), so a
* re-created task would mint a new key and let a user retry fund a SECOND
* escrow instead of resuming the first (#11844). Failures are logged and
* swallowed — the pending (and its key) survives either way; only the TTL
* exemption is best-effort.
*/
export async function markCloudAppConfirmationRecovery(
runtime: IAgentRuntime,
pending: PendingCloudAppConfirmation,
): Promise<void> {
try {
await runtime.updateTask(pending.taskId as UUID, {
metadata: { ...pending.metadata, recovery: true },
});
} catch (err) {
logger.warn(
`[plugin-cloud-apps] failed to mark confirm task ${pending.taskId} as recovery: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
export async function deleteCloudAppConfirmation(
runtime: IAgentRuntime,
taskId: string,
): Promise<void> {
await runtime
.deleteTask(taskId as UUID)
.catch((err) =>
logger.warn(
`[plugin-cloud-apps] failed to delete confirm task ${taskId}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
}
/**
* Build the first-phase confirmation prompt for a destructive action.
*
* Names the exact target (+ id when present), lists what is destroyed, and tells
* the user the exact token to send back. `verb` defaults to "delete".
*/
export function confirmationPrompt(
target: ConfirmTarget,
destroys: string[],
verb = "delete",
): string {
const label = target.id
? `"${target.name}" (${target.id})`
: `"${target.name}"`;
const destroyClause =
destroys.length > 0
? ` This permanently destroys ${joinList(destroys)}.`
: "";
return (
`This will ${verb} ${label}.${destroyClause} This can't be undone. ` +
`To go ahead, reply that you confirm ${verb} ${target.name}.`
);
}
function joinList(items: string[]): string {
if (items.length === 1) return items[0];
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
}
/**
* Build a neutral connector CTA. The seam the DEFERRED paid actions reuse so a
* connector can surface a "complete in browser" affordance. Throws if the URL is
* not an http(s) URL — money/credentials must never be smuggled into the CTA.
*/
export function buildConnectorCta(
label: string,
url: string,
kind: CtaKind = "link",
): ConnectorCta {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`buildConnectorCta: invalid URL "${url}"`);
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new Error(
`buildConnectorCta: refusing non-http(s) URL "${parsed.protocol}"`,
);
}
return { label, url: parsed.toString(), kind };
}