# Your first mini app

> Build a working approvals mini app end to end — manifest, permissions, an agent-callable action, and a message posted back into the conversation.

Source: https://miniapp.sollar.com/start/first-mini-app/

---

We will build an approvals app. A human opens it from a conversation and sees what is waiting for
them; an AI agent can ask the same question without opening anything. Both paths run the same code.

## The manifest

Everything the runtime enforces is declared here. Nothing is discovered at runtime.

```json title="manifest.json"
{
  "manifest_version": 1,
  "id": "8f3c2a1e9b7d4f60",
  "name": "Acme Approvals",
  "version": "1.0.0",
  "description": "Review and approve purchase orders from inside a conversation.",
  "icons": [
    { "src": "assets/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "assets/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ],
  "vendor": {
    "id": "acme-corp",
    "name": "Acme Corporation",
    "contact": "miniapps@acme.example"
  },
  "distribution": { "channel": "tenant-private", "tenants": ["acme-corp"] },
  "age_rating": "4+",
  "entry": "index.html",
  "routes": [
    { "path": "/", "title": "Approvals" },
    { "path": "/orders/:purchase_order_id", "title": "Purchase order" }
  ],
  "permissions": [
    { "scope": "sollar.identity.basic",
      "purpose": "Show your name on approvals you submit." },
    { "scope": "sollar.room.context",
      "purpose": "Link an approval to the conversation it came from." },
    { "scope": "sollar.message.send", "optional": true,
      "purpose": "Post the approval result back into the conversation." }
  ],
  "network": { "connect": ["erp.acme.example"] },
  "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://erp.acme.example; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
  "auth": { "type": "sollar-exchange", "audience": "acme-erp-api", "scopes": ["orders.read", "orders.approve"] },
  "actions": []
}
```

Three things worth noticing:

**Every permission carries a `purpose`.** It is shown to the user at consent time, and a reviewer
can argue with it. A permission whose stated purpose does not match what the app does is a rejection
reason, not a formality.

**`network.connect` is a list of hosts.** It is enforced by native code in the request interceptor,
not by the CSP alone — a mini app can rewrite its own CSP, but it cannot reach the interceptor.
Adding a host means shipping a new signed version. That is deliberate.

**`sollar.message.send` is `optional`.** The app must work without it. Ask for it when the user
actually wants to post a result, not at install time.

## The page

```html title="index.html"
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acme Approvals</title>
<div id="app">Loading…</div>
<script type="module" src="/src/main.ts"></script>
```

```ts title="src/main.ts"

async function boot() {
  const identity = await sollar.identity.get()
  const room = await sollar.room.getContext()

  sollar.ui.setTitle('Approvals')
  document.querySelector('#app')!.replaceChildren(
    await renderApprovals({ identity, room })
  )
}

sollar.app.onLifecycle((e) => {
  // The instance may have been destroyed and recreated on the same route.
  // Never assume continuity.
  if (e.type === 'resume' && (e.elapsed_ms ?? 0) > 60_000) boot()
})

boot()
```

## Fetching data

```ts title="src/api.ts"
export async function listApprovals(limit = 10) {
  const { token } = await sollar.auth.getToken()
  const res = await fetch(`https://erp.acme.example/approvals?limit=${limit}`, {
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) throw new Error(`ERP returned ${res.status}`)
  return res.json()
}
```

Call `getToken()` before each use rather than caching it. The token is short-lived and
audience-restricted to `acme-erp-api`; the runtime handles renewal.

> **DANGER**
`sollar.identity.get()` returns client-side data. It is convenient for drawing a name in the corner.
It is **not** proof of identity, and it must never be what your backend authorises against.

Your ERP validates the bearer token — signature, `aud`, `exp`, `iss` — and derives the user from
that. Trusting the client-supplied identity is the most common and most expensive mistake in this
class of platform. Telegram publishes the same warning about `tgWebAppData`; the reason is the same.

## Adding an agent-callable action

So far this is a web app in a WebView. The `actions[]` entry is what makes it something a Sollar
agent can use.

```json title="manifest.json — actions"
"actions": [
  {
    "name": "list_pending_approvals",
    "title": "List pending approvals",
    "description": "Returns purchase orders awaiting the current user's approval, most urgent first. Only returns orders where the current user is a designated approver; it never returns another user's queue.",
    "route": "/",
    "input_schema": {
      "type": "object",
      "properties": {
        "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10,
                   "description": "Maximum number of purchase orders to return." }
      },
      "additionalProperties": false
    },
    "output_schema": {
      "type": "object",
      "properties": {
        "approvals": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "purchase_order_id": { "type": "string" },
              "vendor_name":       { "type": "string" },
              "amount_minor":      { "type": "integer",
                                     "description": "Amount in the minor unit of currency_code, e.g. cents." },
              "currency_code":     { "type": "string", "description": "ISO 4217, e.g. USD." }
            },
            "required": ["purchase_order_id", "vendor_name", "amount_minor", "currency_code"]
          }
        }
      },
      "required": ["approvals"]
    },
    "annotations": { "read_only": true, "destructive": false,
                     "idempotent": true, "open_world": false },
    "confirm": "never"
  }
]
```

```ts title="src/actions.ts"

sollar.actions.handle('list_pending_approvals', async ({ limit = 10 }) => {
  return { approvals: await listApprovals(limit) }
})
```

The `description` is doing real work here. It says what the action returns, in what order, and —
critically — that it never returns another user's queue. An agent that does not know that will try.

See [Agent actions](/guides/agent-actions/) for how to write these well.

## A destructive action

```json
{
  "name": "approve_purchase_order",
  "title": "Approve purchase order",
  "description": "Approves one purchase order on behalf of the current user. This is final: an approved order is released to the vendor and cannot be revoked from within this mini app.",
  "input_schema": {
    "type": "object",
    "properties": {
      "purchase_order_id": { "type": "string",
                             "description": "Identifier returned by list_pending_approvals." },
      "note": { "type": "string", "maxLength": 500 }
    },
    "required": ["purchase_order_id"],
    "additionalProperties": false
  },
  "annotations": { "read_only": false, "destructive": true,
                   "idempotent": true, "open_world": false },
  "confirm": "always"
}
```

```ts
sollar.actions.handle('approve_purchase_order', async (input) => {
  // The input already validated against input_schema. A schema guarantees SHAPE, never PERMISSION.
  // Authorise here — and again on your server.
  const result = await approve(input.purchase_order_id, input.note)

  const room = await sollar.room.getContext()
  if (room && await sollar.permissions.query('sollar.message.send') === 'granted') {
    await sollar.message.send({
      room_id: room.room_id,
      card: {
        title: `Purchase order ${input.purchase_order_id} approved`,
        fields: [{ label: 'Vendor', value: result.vendor_name }],
      },
    })
  }
  return { status: 'approved', purchase_order_id: input.purchase_order_id }
})
```

With `confirm: "always"`, Sollar asks the human before this runs — showing the action title and the
resolved arguments in plain language, never raw JSON. A human approving something they cannot read
is not approval.

> **CAUTION**
`sollar.message.send()` sends **through the user's client**, with the user's identity. That is what
makes it work in an end-to-end encrypted room.

Your **backend cannot post into an E2EE room at all** — it is not a cryptographic member and the
server holds no keys. Check `room.is_encrypted` and design around it from the start. Discovering
this during integration is expensive.

## Ship it

```sh
sollar validate
sollar build
sollar upload
sollar submit         # tenant-private: publishes. Store: enters review.
```

Next: [Connect a backend](/start/connect-a-backend/) or [Publish](/start/publish/).
