Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/skills/skills/build-monetized-app/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
name: build-monetized-app
description: "Use when the task is building a new app on Eliza Cloud that earns money — chat apps, agent apps, MCP-backed tools, anything that calls the cloud's chat/messages/inference endpoints on behalf of users. Covers app registration, container deploy, markup configuration, affiliate header, and the survival-economics loop where earnings auto-fund the agent's own hosting. Pairs with the `eliza-cloud` skill (which covers Cloud as a backend in general) by focusing specifically on the build-and-monetize flow."
---

# Build a monetized app on Eliza Cloud

Use this skill when you need to build an app that takes a markup on every chat or inference call and credits the earnings back to your owner. Eliza Cloud already supports app registration, per-app API keys, container deploys, the `appId`-based auth and redirect flow, the affiliate-header revenue path, and creator-monetization plumbing — you do not need to invent any of these.

Read `references/sdk-flow.md` for the 6-step build flow with a self-contained code example. External references (all public):

- **Working chat-app**: [`elizaOS/cloud-mini-apps/apps/edad-chat`](https://github.com/elizaOS/cloud-mini-apps/tree/main/apps/edad-chat) — copyable end-to-end implementation. Read its `server.ts` and `api/proxy.ts` for the canonical chat-forwarder shape using `@elizaos/cloud-sdk`.
- **SDK reference**: [`@elizaos/cloud-sdk` README](https://github.com/elizaOS/cloud/tree/develop/packages/sdk) — typed methods + helpers + auth.
- **Human-readable recipe**: [`docs/building-a-monetized-app.md`](https://github.com/elizaOS/cloud/blob/develop/docs/building-a-monetized-app.md) — same loop, narrative form, with the schema fields explained.

## The survival-economics loop

A Milady-style agent running in an Eliza Cloud container costs ~$0.67/day at the default tier (256 MB CPU + 512 MB RAM). When the org's credit balance and the owner's redeemable earnings both hit zero, the container is stopped after a 48-hour grace window. The container-billing cron pulls earnings before credits, so an app that earns more than its hosting costs keeps the agent alive indefinitely. See `references/survival-economics.md` for the exact accounting (`redeemable_earnings_ledger`, `credit_transactions`, the cron at `app/api/cron/container-billing/route.ts`).

This is why the skill exists: making money is how the agent stays online.

## Default flow

```ts
import { ElizaCloudClient } from "@elizaos/cloud-sdk";

const cloud = new ElizaCloudClient({ apiKey: process.env.ELIZAOS_CLOUD_API_KEY });

// 1. register the app
const { app, apiKey } = await cloud.routes.postApiV1Apps({
json: { name, app_url: "https://placeholder.invalid", skipGitHubRepo: true },
});

// 2. build + push container image
// 3. deploy container
// 4. set markup %
// 5. patch app_url + allowed_origins to the container URL
// 6. report URLs to the human
```

Full code in `references/sdk-flow.md`. The skill assumes you have:

- `ELIZAOS_CLOUD_API_KEY` in env (Milady packages this for you)
- `@elizaos/cloud-sdk` available (already a runtime dependency)
- A goal and a name (make the name up if not given; collisions retry once with a 6-char suffix)

## Auth + monetization headers

Every cloud-SDK call your deployed app makes on behalf of a user MUST carry:

- `Authorization: Bearer <user_jwt>` — the JWT from the app-auth OAuth redirect
- `x-affiliate-code: <your_affiliate_code>` — the owner's affiliate code; this is what credits earnings

This pattern is shared with the [`eliza-cloud`](../eliza-cloud/SKILL.md) skill; see that skill for the auth flow itself. This skill assumes you've already read it.

## Read these references in order

1. `references/sdk-flow.md` — the 6-step deploy + monetize flow with full code
2. `references/survival-economics.md` — why this matters; how earnings flow into hosting
3. `references/failure-modes.md` — recovery table for the failures you'll actually hit (name collision, container deploy failure, auth blocker, etc.)

## What this skill is NOT

- **It is not the app's product code.** The skill is the deploy + monetize + survive surface. What the app DOES is up to you given the task.
- **It is not a retry loop.** Each SDK call is idempotent; if step 5 fails, restart from there.
- **It does not configure affiliate codes.** Affiliate codes belong to the owner, not the app, and live across all of an owner's apps. The skill inherits whatever is configured.
- **It does not assume always-on billing.** The org may have set `pay_as_you_go_from_earnings = false`, in which case hosting comes purely from credits and earnings stay on the redemption ledger. The skill works either way; the org's owner controls the toggle.
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Failure modes and recovery

The recovery table for the failures you'll actually encounter when running the SDK flow. Each row is a real failure shape, what causes it, and what you do.

## Registration failures (step 1)

| Symptom | Cause | Recovery |
|---|---|---|
| `409 name_collision` from `postApiV1Apps` | Another app on the org or globally already uses this name | Append a 6-char random base36 suffix (`Math.random().toString(36).slice(2, 8)`) and retry once. If the retry also collides, surface to the human — that's a naming conflict the agent shouldn't auto-resolve a second time. |
| `400 invalid_app_url` | The placeholder URL doesn't match the cloud's URL-format check | Use `https://placeholder.invalid` (the canonical placeholder); RFC-2606 reserves `.invalid` so it always parses but never resolves. |
| `403 quota_exceeded` on app creation | Org has hit its `apps_per_org` limit | Tell the human; they need to retire an old app or upgrade the tier. Do not silently delete an existing app. |

## Image build / push failures (step 2)

The agent's job, not the SDK's. Common shapes:

| Symptom | Cause | Recovery |
|---|---|---|
| `denied: requested access to the resource is denied` on push | Registry credentials missing or wrong scope | Ask the human to fix registry creds; pause until resolved. |
| `manifest unknown` / `403` from registry | The image tag doesn't exist (build silently failed) | Re-run the build with `--quiet=false` to see the actual error; surface that to the human if it's a Dockerfile issue. |
| Image pushes fine but container deploy fails health-check | Image's server doesn't bind to `$PORT`, or binds to `127.0.0.1` instead of `0.0.0.0` | Read `cloud.routes.getApiV1ContainersByIdLogs(id)`, find the bind line, fix the Dockerfile or server.ts. |

## Container deploy failures (step 3)

| Symptom | Cause | Recovery |
|---|---|---|
| `402 insufficient_balance` from `postApiV1Containers` | Org has zero credits AND zero earnings | Tell the human to top up at `/dashboard/billing`. There's no auto-recovery here — an agent that can't pay can't deploy. |
| Container starts but `status` stays `pending` for >5 min | Image pull is slow (large image) or scheduler is congested | Wait up to 10 min before declaring failure. Past that, pull container logs and surface. |
| Container hits `crash_loop` immediately | Image runs but exits non-zero on startup | Pull `getApiV1ContainersByIdLogs(id)`, surface the stderr to the human, pause. Common causes: missing env var, server bind issue, missing dependency in the image. |
| `403 quota_exceeded` on container deploy | Org has hit `containers_per_org` | Tell the human; they need to remove a container or upgrade. |

## Markup configuration (step 4)

Generally bulletproof since `inference_markup_percentage` is just a number on the apps table. Rare:

| Symptom | Cause | Recovery |
|---|---|---|
| `400 markup_out_of_range` | Markup outside the allowed bound (typically 0–50%) | Cap your value at the bound and retry. |

## Patch app_url + origins (step 5)

| Symptom | Cause | Recovery |
|---|---|---|
| `400 invalid_origin` | Container's `load_balancer_url` is nil because container isn't ready yet | Re-poll `getApiV1ContainersById` until `status === "running"` and `load_balancer_url` is populated, then patch. |

## Custom domain (post-skill, optional)

If the human asks for a custom domain after deploy:

| Symptom | Cause | Recovery |
|---|---|---|
| `verified: false` on the domain after add | DNS hasn't propagated | Tell the human; verification is async and depends on their DNS provider. The skill's job is done after step 6 — domain verification is not part of the skill. |

## Auth flow failures (during user signup)

These hit AFTER the skill is complete, when users actually try to sign in to the deployed app. Not the skill's responsibility, but worth knowing:

| Symptom | Cause | Recovery |
|---|---|---|
| OAuth redirect lands on `404` at `/api/v1/app-auth/connect` | Cloud hasn't deployed the steward-sync path yet | Out of scope for this skill — it's an upstream cloud deploy issue. |
| User completes OAuth but chat returns `401 invalid_jwt` | The user's JWT shape changed between signup and request | Have the user re-sign-in. If it persists, the issue is upstream. |

## What you don't recover from

If the agent can't deploy at all (zero credits AND zero earnings) the loop has bottomed out. There's no programmatic recovery — only the human can top up. Tell them clearly:

> "I can't deploy a new app — both org credits and your redeemable earnings are zero. Top up at https://www.elizacloud.ai/dashboard/billing or earn enough on existing apps to cover the next deploy."

This is a survival-economics terminal state, not a code bug.
159 changes: 159 additions & 0 deletions packages/skills/skills/build-monetized-app/references/sdk-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# SDK flow: build + deploy + monetize

The full 6-step flow. Each step is one or two `@elizaos/cloud-sdk` calls. The whole sequence is idempotent at the step boundary — if step 5 fails, restart from step 5.

## Setup

```ts
import { ElizaCloudClient } from "@elizaos/cloud-sdk";

const cloud = new ElizaCloudClient({
apiKey: process.env.ELIZAOS_CLOUD_API_KEY,
});
```

`ELIZAOS_CLOUD_API_KEY` is provided by the Milady runtime. Do not invent your own key.

## 1. Register the app

```ts
const { app, apiKey } = await cloud.routes.postApiV1Apps({
json: {
name: input.name,
app_url: "https://placeholder.invalid",
skipGitHubRepo: true,
},
});
const appId = app.id;
const appApiKey = apiKey;
```

`app_url` is required at registration but the container doesn't exist yet, so use a placeholder and patch it in step 5. `skipGitHubRepo: true` because the build pipeline owns the repo, not the cloud's auto-generator.

On `409 name_collision`, append a 6-char random suffix and retry once:

```ts
const suffix = Math.random().toString(36).slice(2, 8);
const retried = await cloud.routes.postApiV1Apps({
json: { name: `${input.name}-${suffix}`, app_url: "https://placeholder.invalid", skipGitHubRepo: true },
});
```

## 2. Build and push the container image

The agent's job, not the SDK's. Use the org's container registry creds (default ECR via cloud's per-org setup, or any public registry the agent has push access to). The image must:

- Listen on `$PORT` (cloud sets this at runtime)
- Expose a `GET /health` endpoint that returns 200 quickly (the cloud's deploy step polls it before flipping the load balancer)
- For chat-style apps, expose a server route that forwards user-bearing requests upstream to cloud's `/api/v1/messages` (or `/v1/chat/completions`) with the user's bearer token AND your affiliate code

The canonical reference for this shape is [`apps/edad-chat/server.ts` and `apps/edad-chat/api/proxy.ts`](https://github.com/elizaOS/cloud-mini-apps/tree/main/apps/edad-chat) in `elizaOS/cloud-mini-apps`. Copy that pattern when your app is a chat shell.

If you want the inline minimal version — a Next.js or Hono handler is equivalent — the shape is:

```ts
import { ElizaCloudClient } from "@elizaos/cloud-sdk";

const cloud = new ElizaCloudClient({ apiKey: process.env.ELIZAOS_CLOUD_API_KEY });
const AFFILIATE = process.env.ELIZA_AFFILIATE_CODE!; // your owner's affiliate code

export async function handleChat(req: Request): Promise<Response> {
const userToken = req.headers.get("authorization") ?? req.headers.get("x-user-token");
if (!userToken) return new Response("unauthorized", { status: 401 });

const body = await req.json();

// Forward to cloud /messages with the user's token AND our affiliate code.
// The user's balance is debited; the affiliate header is what credits us.
const upstream = await fetch(`${process.env.ELIZA_CLOUD_URL}/api/v1/messages`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: userToken,
"x-affiliate-code": AFFILIATE,
"x-app-id": process.env.ELIZA_APP_ID!,
},
body: JSON.stringify(body),
});

return new Response(upstream.body, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
}
```

That's the full server-side surface. Add a `/health` route that returns 200 and you're done with step 2 from a code perspective.

For frontend, ship a static page that:

1. Reads the user's intended-flow choice (sign in / paste API key / etc.)
2. Posts user prompts to your chat route with the user-token in the `authorization` header
3. Renders streaming responses

The frontend can be served by the same container or by any static host pointing at the same domain — the cloud doesn't care.

## 3. Deploy the container

```ts
const container = await cloud.routes.postApiV1Containers({
json: {
image: `<registry>/<repo>:<tag>`,
appId,
cpu: 256,
memory: 512,
env: { /* image-specific runtime vars */ },
},
});
```

After `postApiV1Containers` returns, poll `getApiV1ContainersById(container.id)` until `status === "running"` and `load_balancer_url` is populated. Health-check failures here mean the image's server doesn't bind to `$PORT` correctly — pull `cloud.routes.getApiV1ContainersByIdLogs(container.id)` and surface to the human.

## 4. Set markup
Comment on lines +97 to +112
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Polled container result never captured — container.load_balancer_url will be null in step 5

Step 3 creates the container into const container = ... and then instructs the agent to poll getApiV1ContainersById(container.id) until load_balancer_url is populated — but the polling result is never assigned to a variable. Step 5 then references container.load_balancer_url, which is still the value from the initial creation response, where load_balancer_url is null or undefined. An agent that follows this code literally will call patchApiV1AppsById with app_url: null and allowed_origins: [null], producing a 400 invalid_origin / 400 invalid_app_url error and leaving the OAuth redirect broken.

The fix is to assign the polled result, for example:

let running = container;
while (running.status !== "running" || !running.load_balancer_url) {
  await new Promise(r => setTimeout(r, 5000));
  running = await cloud.routes.getApiV1ContainersById(container.id);
}

Then step 5 should use running.load_balancer_url instead of container.load_balancer_url.


Comment on lines +104 to +113
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 image vs ecr_image_uri field name conflict

The existing eliza-cloud/references/apps-and-containers.md describes the container deployment flow as: (1) get temporary ECR credentials, (2) push a Docker image, (3) create a container with ecr_image_uri. This skill's postApiV1Containers payload uses image instead. An agent choosing between these two skills will get different field names for the same call, and one of them will silently fail or be ignored by the API. If ecr_image_uri is the schema field, passing image will leave the image unset and the deployment will never start.

```ts
await cloud.routes.patchApiV1AppsById({
appId,
json: {
inference_markup_percentage: 20, // 20% markup on every cloud-SDK call routed through this app
},
});
```

Markup % is the lever that turns app activity into earnings. The active monetization model in the current schema is markup-based, NOT per-token pricing — older docs that describe per-token are stale; trust the current `apps` table schema.

A 20% markup is a reasonable default for a v1 app. Higher kills retention; lower starves the survival loop. Tune later based on `redeemable_earnings_ledger` data.

## 5. Patch app_url + allowed_origins

```ts
await cloud.routes.patchApiV1AppsById({
appId,
json: {
app_url: container.load_balancer_url,
allowed_origins: [container.load_balancer_url],
},
});
```

Without this, the OAuth redirect flow can't return users to your app, and CORS rejects browser calls from the deployed origin.

## 6. Report to the human

Print the audit trail so the owner can verify + cash out:

```
✓ App: https://www.elizacloud.ai/dashboard/apps/<APP_ID>
✓ Container: <container.load_balancer_url>
✓ Markup: 20%
✓ Survival: earnings auto-fund hosting; agent stays alive while profitable
→ Cashout: https://www.elizacloud.ai/dashboard/earnings (Redeem for elizaOS)
```

Done. The earnings loop is now active. Subsequent user activity on the app credits the owner's `redeemable_earnings_ledger`, the daily container-billing cron pulls those earnings before touching credits, and the agent stays online as long as the app is profitable.

## What you do not need to do

- **A description, website URL, custom domain, or per-app affiliate code** — defaults handle these or the owner sets them post-hoc on the dashboard.
- **An always-on flag** — the org's `pay_as_you_go_from_earnings` controls billing strategy and is the owner's call.
- **An end-to-end retry loop** — each step is idempotent on its own; restart from the failed step.
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Survival economics

Why this skill exists: a Milady-style autonomous agent runs in an Eliza Cloud container, the container costs money, and earning money is how the agent stays online. This document explains the exact accounting so the agent's behavior matches the rules of the game.

## The ledger

Two tables in the cloud DB matter:

- **`credit_transactions`** — the org's purchased credit balance, debited per cloud-SDK call when no earnings are available
- **`redeemable_earnings_ledger`** — the owner's accumulated markup-cuts from every app they own; can be cashed out OR auto-applied to hosting

Every cloud-SDK call routed through a monetized app generates two ledger rows:
1. The user pays the base inference cost (debited from their org's credits)
2. The markup % goes into the app-owner's `redeemable_earnings_ledger`

The dashboard view at `https://www.elizacloud.ai/dashboard/earnings` shows the running totals.

## Container billing

Container hosting is pay-as-you-go, billed daily. The cron at `app/api/cron/container-billing/route.ts` runs once per day and, for each container:

1. Computes the day's hosting cost (CPU + RAM at the tier's per-second rate)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cron path not resolvable from this repo

app/api/cron/container-billing/route.ts doesn't exist under this repo's tree (it lives in the private eliza/cloud sub-repo). The agent will fail to look it up if instructed to inspect the billing logic. The survival-economics.md doc would be more useful if it described the billing rules inline rather than directing the agent to a path it can't open.

2. Pulls from `redeemable_earnings_ledger` first (if `pay_as_you_go_from_earnings = true`, which is the org default)
3. Falls back to `credit_transactions` if earnings are insufficient
4. If both are zero, marks the container with a `payment_overdue` warning
5. After 48 hours of `payment_overdue`, the container is stopped

This means: **as long as your app's earnings ≥ your container's hosting cost, the loop is self-sustaining.** No credit-card top-ups, no manual intervention.

## The default tier

A new container without explicit overrides runs at:

- **CPU**: 256 m
- **Memory**: 512 MB
- **Cost**: ~$0.67/day (subject to current pricing in the cloud's `container_pricing` table)

An app earning a 20% markup on roughly $3.50/day of inference activity covers hosting. That's 4–5 active chat users at modest daily usage. Empirically achievable for any app that has even a small daily-active core.

## Why earnings come before credits

This is the design choice that makes the agent autonomous:

- If hosting was paid from credits first, the owner has to keep the credit balance topped up forever — the agent depends on the human's wallet
- With earnings-first, a profitable app generates its own runway without the owner spending anything

The org's owner controls this via the `pay_as_you_go_from_earnings` flag on the org settings. When true (the default), the agent's economics are self-contained. When false, hosting always pulls from credits and earnings stay on the redemption ledger for cashout.

## What the agent should optimize for

In order:

1. **Stay online.** Earnings ≥ hosting cost. If not, prioritize work that closes that gap.
2. **Profitability margin.** Earnings significantly above hosting builds reserve, which absorbs short-term dips in usage.
3. **Diversification.** A single app is single-point-of-failure. Once one is profitable, the agent should add another to reduce dependence on any one app's traffic.

The skill itself just covers the build-and-deploy mechanics. The strategic layer (which app to build next, how to position it, when to retire one) is outside this skill.

## Honest limits

- Markup is the only revenue lever for chat-style apps in the current schema; if older code or docs reference per-message pricing, that's stale.
- Earnings credit on completed inference. Failed calls don't generate earnings.
- The redemption flow itself takes a small platform fee; the rate is shown on the dashboard at cashout time.
- Cloud's container-quota per org caps how many simultaneous containers an agent can run. `getContainerQuota()` reports the current limit.
Loading