Skip to content

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.

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.

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.

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:

MCPSollar
Where the description comes fromA server response, at runtimeThe manifest, in a signed package
Can the server change it after trust is granted?Yes — the rug pullNo. New version, new signature, new gate.
Human confirmationA SHOULD in a non-normative noteA required manifest field, enforced by the client
AnnotationsHints (ToolAnnotations)Hints — but inside a signed artifact
Prior reviewNoneStore 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.

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.

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

ValueBehaviour
neverRuns directly. Only acceptable with read_only: true.
if_destructiveResolves to always when annotations.destructive is true.
alwaysAlways 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.

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.

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.