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
Section titled “What the agent sees”For each installed and tenant-permitted mini app, the agent receives the declared actions in the format it already uses:
{ "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
Section titled “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, outcomeStep 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
Section titled “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
Section titled “Registering a handler”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()})interface ActionContext { invoked_by: 'agent' | 'user' agent_id?: string confirmed: boolean // did a human approve this specific call?}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
Section titled “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
Section titled “Elicitation — when information is missing”If the agent calls an action without enough information, your handler can ask the human directly:
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.
sollar.actions.elicit() throws ERR_ELICITATION_UNAVAILABLE when there is no interactive surface.
Handle it — a background invocation is a normal case.
Progress
Section titled “Progress”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
Section titled “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.
// The agent will retry the identical call.throw new Error('ERR_CONFLICT')// 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
Section titled “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.
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.