Skip to content

Your 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.

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

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.

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>
src/main.ts
import { renderApprovals } from './pages/approvals'
import './actions'
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()
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.

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

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"
}
]
src/actions.ts
import { listApprovals } from './api'
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 for how to write these well.

{
"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"
}
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.

Terminal window
sollar validate
sollar build
sollar upload
sollar submit # tenant-private: publishes. Store: enters review.

Next: Connect a backend or Publish.