Actions and risk levels
The copilot can only do what you register, and risk decides the ceremony.
An action is a typed capability you register in code: name, description, parameter schema, risk level, and a handler that runs inside your app. The copilot holds no credentials and no elevated privileges; your handler uses the same session and backend calls the rest of your app uses.
expired is the one apps forget: it is decided on the server, ten minutes on, to a card the user may still be looking at.Risk levels
| Level | Behavior |
|---|---|
read | Executes immediately. The handler runs, the result flows back, the answer renders. |
write | A confirmation card renders first. The handler only runs after the user confirms. Expires after 10 minutes if ignored. |
destructive | Explicit, unambiguous confirmation with a stricter UI affordance: the card raises its edge to full ink and says the action cannot be undone. Expires after 10 minutes if ignored. |
The console can make an action's risk stricter than declared, never looser, and can disable any action with immediate effect.
Registration
Rendel.registerAction(RendelAction(
name: 'cancel_order',
description: 'Cancel an order that has not shipped yet',
params: RendelSchema.object({'order_id': RendelSchema.string()}),
risk: RendelRisk.destructive,
confirmTemplate: 'Cancel order {order_id}? This cannot be undone.',
handler: (params) async => RendelResult.ok({'refund_eta_days': 5}),
));confirmTemplate feeds the server-synthesized confirmation card. Parameters are validated against your schema before the card is ever shown: types, enums, bounds and required keys, with anything you did not declare rejected outright. A proposal that fails goes back to the model as a correction rather than to your handler. Your handler still receives a Map, so coerce rather than cast at the boundary.
Action names are snake_case: lowercase, starting with a letter. The SDK asserts this where you write it, because the server rejects the whole manifest over one bad name.
timeout is a Duration and defaults to fifteen seconds. Past it the handler is abandoned and the model is told the action failed.
Parameter shapes
RendelSchema covers strings, numbers, integers, booleans, enums, objects and lists. Mark a property optional with .optional; everything else is required.
params: RendelSchema.object({
'order_id': RendelSchema.string(),
'item_ids': RendelSchema.list(RendelSchema.string(), minItems: 1),
'reason': RendelSchema.enumOf(['damaged', 'late', 'wrong_item']).optional,
}),For anything beyond that subset, RendelSchema.raw({...}) takes a hand-written JSON Schema. The server validates whatever the manifest declares, so a raw schema is enforced exactly like a built one.
Running an action at most once
A handler normally receives just the parameters. When running the action twice would be worse than not running it, take the whole invocation instead and use its id as an idempotency key:
Rendel.registerAction(RendelAction(
name: 'refund_order',
description: 'Refund an order to the original payment method',
params: RendelSchema.object({'order_id': RendelSchema.string()}),
risk: RendelRisk.destructive,
confirmTemplate: 'Refund order {order_id}? This cannot be undone.',
onInvoke: (invocation) async {
// The same id comes back if the turn is resumed after a dropped stream.
if (await refunds.alreadyProcessed(invocation.id)) {
return RendelResult.ok({'status': 'already_refunded'});
}
return RendelResult.ok(await refunds.create(
orderId: invocation.params['order_id'] as String,
idempotencyKey: invocation.id,
));
},
));The case this exists for is an app killed between running the handler and reporting the outcome: the server never hears the result, and the model is free to propose the same thing again.
Actions from the console
An action does not have to be code. In the console, Actions → Add an API reads your API's docs (a Swagger or OpenAPI page, a YAML spec, or an uploaded file), lists the endpoints, and your team ticks the ones the copilot may use. The server calls them; nothing is registered in the app.
Who the call is made as decides what it may do:
| Signed in as | May |
|---|---|
| A key, a header secret, or an account made for the copilot | Read only (GET, or a POST that reads). None of these says whose data it is, so none may change it. |
The signed-in user (their app passes their token: RendelConfig.userToken in Flutter, setUserToken on the web) | Read, and change something: POST, PUT, PATCH or DELETE, as write or destructive. |
A console write always takes a confirmation, and the server, not the device, decides it happened: the card is drawn from the action's confirmation question, the tap is recorded by the confirm endpoint for the device that owns the conversation, and only then does the server call your endpoint, once, with the token of the person who tapped. Retrying a continuation never sends it twice; if an answer is lost on the way back, the copilot says it does not know whether the change happened rather than trying again. Writes are not called while you import or preview them, and they are offered only to builds that can confirm them (Flutter 0.27.0 and web 0.5.0 or later).
A write also needs to know who it is for. It is offered only on a turn where your app has named the signed-in person with identify, and the card remembers them. The server runs it only if the request confirming it names the same person, so a phone that switches accounts without calling reset() cannot spend one person's confirmation on another person's account; a token refreshed for the same person still works. If you have turned on identity verification (a userHmac from your backend), the confirming request must prove the person with it. Without verification, the binding is as good as the id your app passes to identify.
The manifest
At startup the SDK canonicalizes your action set and syncs it as an immutable, hashed manifest. Different app versions in the field coexist cleanly; every conversation records exactly which manifest it ran under. The manifest uses the MCP tool shape, so exposing your actions to other agents later is a serializer, not a rewrite.
Failure is a message, not a crash
A handler that throws or times out becomes an error result the model can read. It apologizes, adjusts or retries; your app keeps running.