# Agent actions

> How a Sollar AI agent calls your mini app — the manifest as a tool registry, confirmation policy, elicitation, and how to write a description an agent uses correctly.

Source: https://miniapp.sollar.com/guides/agent-actions/

---

Sollar's core is closed to connectors, MCP servers, plugins and skills. AI agents are nonetheless
one of its five core pillars, and an agent that cannot act is a chatbot.

The resolution: **a mini app is the only capability surface Sollar has**, and the `actions[]` array
in its signed manifest is the tool registry. Installing a mini app simultaneously gives a human an
app and gives an agent a tool. There are not two lists to keep in sync, and not two permission
models to reconcile.

## What the agent sees

For each installed and tenant-permitted mini app, the agent receives the declared actions in the
format it already uses:

```json
{
  "name": "acme_erp__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" },
      "note": { "type": "string", "maxLength": 500 }
    },
    "required": ["purchase_order_id"]
  }
}
```

The `<mini_app_id>__` prefix prevents collisions between apps that name actions the same.

Nothing here is a novel protocol, deliberately. The shape mirrors Anthropic's `tool_use` /
`input_schema` and OpenAI's function calling, so an agent does not have to learn a Sollar-specific
convention to use a Sollar mini app.

## The call cycle

```
 agent decides to call acme_erp__approve_purchase_order
        │
        ▼
 1. runtime checks: installed? permitted in this tenant?
    does the action exist in the SIGNED manifest? does input match input_schema?
        │
        ▼
 2. confirmation policy: confirm=always → ask the human BEFORE executing
        │
        ▼
 3. RFC 8693 exchange with actor_token (the agent) + subject_token (the user)
        │
        ▼
 4. runtime opens or resumes the mini app in the background, calls your handler
        │
        ▼
 5. your handler runs, optionally emitting progress
        │
        ▼
 6. result validated against output_schema, returned to the agent
        │
        ▼
 7. audit record: who, on whose behalf, what, when, outcome
```

**Step 1 is what closes the tool-poisoning class of attack.** The action must exist in a signed,
reviewed manifest — not in a server response. A compromised backend cannot invent a new tool or
rewrite the description of an existing one.

## Why this is structurally safer than MCP

MCP is the design reference and will not be adopted. The difference is worth stating plainly,
because it is the technical justification for keeping the Sollar core closed:

| | MCP | Sollar |
|---|---|---|
| Where the description comes from | A server response, at runtime | The manifest, in a signed package |
| Can the server change it after trust is granted? | **Yes** — the rug pull | No. New version, new signature, new gate. |
| Human confirmation | A SHOULD in a non-normative note | A required manifest field, enforced by the client |
| Annotations | Hints (`ToolAnnotations`) | Hints — but inside a signed artifact |
| Prior review | None | Store reviews; a tenant signs its own |

Annotations remain declarations, not guarantees. Neither MCP nor Sollar can verify that an action
marked `read_only` really is. What changes is that a false declaration in Sollar is recorded,
attributable to a key, and cannot be swapped silently after a user has trusted it.

## Registering a handler

```js
sollar.actions.handle('approve_purchase_order', async (input, ctx) => {
  const { access_token } = await sollar.auth.getToken()

  const res = await fetch(
    `https://erp.acme.example/api/orders/${input.purchase_order_id}/approve`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${access_token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ note: input.note ?? null }),
    })

  if (res.status === 409) {
    const { detail } = await res.json()
    throw new Error(detail)         // a sentence, not a code — see below
  }
  if (!res.ok) throw new Error(`Approval failed: ${res.status}`)

  return await res.json()
})
```

```ts
interface ActionContext {
  invoked_by: 'agent' | 'user'
  agent_id?: string
  confirmed: boolean        // did a human approve this specific call?
}
```

> **DANGER**
`ctx` is information, not authorisation. The real limit is enforced on your backend, from the `act`
claim in the token — see [Authentication](/guides/authentication/). A handler that decides what an
agent may do by reading `ctx.invoked_by` has placed the security boundary inside the WebView, where
it does not exist.

Every action declared in the manifest needs a handler. An action without one fails `sollar validate`
at build time, not at 3 a.m. in production.

## Confirmation

`confirm` in the manifest sets the **floor**. Tenant and user policy may tighten it; nothing can
loosen it.

| Value | Behaviour |
|---|---|
| `never` | Runs directly. Only acceptable with `read_only: true`. |
| `if_destructive` | Resolves to `always` when `annotations.destructive` is true. |
| `always` | Always asks a human first. |

The prompt shows the action's `title`, the resolved arguments in readable language, and the mini
app's name — never raw JSON. A human approving something they do not understand is not approval.

For high-risk operations a tenant may require a second human approver. That is tenant policy, not
mini app configuration, because authority limits belong to the organisation.

## Elicitation — when information is missing

If the agent calls an action without enough information, your handler can ask the human directly:

```js
sollar.actions.handle('approve_purchase_order', async (input) => {
  if (!input.note && await requiresNote(input.purchase_order_id)) {
    const note = await sollar.actions.elicit({
      prompt: 'This order exceeds your standing limit. Add a justification note.',
      schema: { type: 'string', maxLength: 500 },
    })
    if (note === null) return { status: 'cancelled' }
    input.note = note
  }
  // …
})
```

Three outcomes, and they are different: **accept**, **decline** ("no, I will not justify it") and
**cancel** ("I closed the screen"). An agent that treats declining and cancelling as the same thing
makes the wrong decision on its next attempt.

> **DANGER**
**Elicitation never asks for a credential, a password, or sensitive personal data.** That is what the
authentication flow is for, and it runs outside the WebView for exactly this reason.

`sollar.actions.elicit()` throws `ERR_ELICITATION_UNAVAILABLE` when there is no interactive surface.
Handle it — a background invocation is a normal case.

## Progress

```js
sollar.actions.progress('Fetching the order…')
sollar.actions.progress('Checking your approval limit…')
```

Surfaced in the agent's thinking view. Useful for anything over a second or two; noise below that.

## Writing a description an agent uses correctly

The `description` is what decides whether the agent gets it right, and writing it is engineering,
not documentation.

**Make implicit context explicit.**

> Returns only the current user's approval queue, never another user's.

**Fully qualify parameter names.** `purchase_order_id`, not `id`. An agent holding six tools with a
parameter called `id` will pass the wrong one.

**State units and formats.** `amount_minor` in the currency's minor unit; `currency_code` in ISO
4217. "Amount" invites the agent to guess whether 1240 means €12.40 or €1,240.

**Say what is irreversible.** This is what makes an agent ask for confirmation even when it could
have skipped it.

> This is final: an approved order is released to the vendor and cannot be revoked from within this
> mini app.

**Return errors that teach.**

```js
// The agent will retry the identical call.
throw new Error('ERR_CONFLICT')
```

```js
// The agent stops and can explain.
throw new Error(
  'Purchase order 4471 was already approved on 2026-08-30 by another approver. ' +
  'No further approval is needed.')
```

**Prefer a few coarse actions to many fine ones.** One `list_pending_approvals` beats
`get_approval_count` + `get_approval_ids` + `get_approval_detail`: fewer round trips, and fewer
places for the agent to lose the thread mid-sequence. The manifest caps you at 64 actions, and you
should be nowhere near it.

## Agents in encrypted rooms

In the Enterprise and Sovereign tiers, messaging is end-to-end encrypted by default. An agent that
needs to act on room content is admitted as a **verified, cross-signed device inside the room
itself**. Its visibility is exactly that of an invited human member: honest and auditable, not
confidentiality preserved during processing.

The consequence for your mini app is direct: **your backend cannot post into an encrypted room.**
Either the message goes through `sollar.message.send()` from the user's own client, or an agent that
is a member of the room posts under its own identity. Design for this before you build the
notification feature.

## Audit

Every agent invocation writes an immutable record: mini app and version, action name, arguments
(with schema-marked sensitive fields redacted), user identity, agent identity, whether a human
confirmed and which one, the outcome, and a timestamp.

Tenant administrators can query and export it. In tiers with E2EE, the record stays inside the
customer's perimeter.
