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.
The manifest
Section titled “The manifest”Everything the runtime enforces is declared here. Nothing is discovered at runtime.
{ "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
Section titled “The page”<!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>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()Fetching data
Section titled “Fetching data”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.
Adding an agent-callable action
Section titled “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.
"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" }]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.
A destructive action
Section titled “A destructive action”{ "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.
Ship it
Section titled “Ship it”sollar validatesollar buildsollar uploadsollar submit # tenant-private: publishes. Store: enters review.Next: Connect a backend or Publish.