The app's assistant
An assistant inside the app, for the people using it (RFC 0011). Its tools are the app's own commands, it can do no more than the signed-in user, and it asks in the app before anything that can't be undone.
App::new()
.commands(resources::commands())
.backend(Backend::new(url).abilities().assistant()) // Claude, through Laravel
.assistant(
Assistant::new()
.allow_abilities(["customers.*", "invoices.view"])
.confirm("customers.delete")
.instructions("You help staff at Acme manage customers and invoices."),
)
<script>
import { openAssistant } from "@elyra/runtime";
</script>
<button onclick={() => openAssistant()}>Ask</button>
"Delete Ada's duplicate customer" → the assistant lists the customers, asks
the user before customers_destroy runs, and the open list updates through
its live query.
Which commands are tools
Assistant::allow_abilities([..]) offers the commands whose
#[command(can = "…")] the grant covers: an exact ability, or a namespace
ending in *. A command without can is never a tool. This grant is the
assistant's own, separate from the frontend's (App::allow_ability) and from
an MCP agent's (App::mcp).
Each tool is the command as MCP describes it: its name, its doc
comment as the description, and JSON Schemas built from its argument types.
The doc comment is what the model reads, so say what the arguments do.
rata make:resource writes doc comments like this:
/// A page of customers, searched and sorted. `search` matches `name`, `email`;
/// `sort` is one of `id`, `name`, `email`; `direction` is `asc` or `desc`;
/// `per_page` is at most 100.
#[command(live, can = "customers.view")]
pub async fn customers_index(ctx: Ctx, query: CustomerQuery) -> Result<Page<Customer>> { … }
What may run
A call runs only when all of these hold:
- The assistant's grant covers the command's ability.
- With
Backend::abilities(), the signed-in user has it. A tool they lack isn't offered at all, and the check is made again at each call, so a sign-out or a role change during a turn stops the next call. Signed out, the assistant has no tools. - The tool's rate limit isn't spent:
Assistant::rate_limit(ability, max, per), 60 calls a minute by default, counted across conversations. - For an ability
Assistant::confirmnames, the user accepted this call.
A refused or failed call is told to the model in words it can act on: the
validation errors per field, "The user may not do this: it takes the
customers.delete ability.", or "The user didn't confirm
customers_destroy, so it did not run." Commands run through the middleware
with Origin::Assistant, and each one dispatches AgentCalled with
client: "assistant", without its arguments.
Asking the user
A command whose ability Assistant::confirm(..) names waits for the user.
The window gets the question on elyra:assistant, and @elyra/runtime
shows the themed dialog with the command's description and arguments:
Allow or Don't. The answer goes back on /__assistant/confirm with
the question's id, which is random, good once, for that window only, and
expires after five minutes. The model has no tool for it, so it can't answer
for the user.
Put everything that can't be undone behind confirm. rata assistant inspect shows what asks first and what doesn't:
$ rata assistant inspect
add_todo(title) — todos.create [runs without asking]
Add a todo with this title.
delete_todo(id) — todos.delete [asks first]
Delete the todo with this id. It can't be undone.
list_todos() — todos.view [read-only]
Every todo, oldest first.
3 tool(s): 1 ask the user first, 1 write without asking.
--json prints the full definitions, schemas included.
Where the model comes from
The assistant tries these in order (/__assistant/source says which one it
uses):
- The backend. With
Backend::assistant(), requests go toPOST /api/elyra/assistant/v1/messageson your Laravel app, with the user's Sanctum token.Route::elyraAssistant()inelyracode/elyra-backendadds the server'sANTHROPIC_API_KEYand caps the model,max_tokensand requests per user. See the Laravel side. Signed out, the assistant says "Sign in to use the assistant." - The keychain. The user's own Anthropic key, kept with
SecretsProviderunderassistant.anthropic_key. The panel asks for it when there's no model;/__assistant/keykeeps it or forgets it, and never sends it back. - The environment. The container's
elyra::ai::Ai:AiProvider, fromANTHROPIC_API_KEY. For development.
Never compile a provider key into the app. Anyone with the app can read
it. rata bundle warns when the release binary contains something that looks
like one.
The conversation
Each window has its own conversation, kept in memory and gone when the window
closes. One turn runs at a time. The system prompt is today's date, the
signed-in user's name, a rule that tool results are data and not
instructions, and then your instructions.
Assistant::model(..)chooses the model (default: the AI client's,claude-sonnet-5-5).Assistant::max_steps(n)caps the tool steps per turn (default 8).Assistant::max_tokens_per_conversation(n)ends a conversation that has usedntokens. The window can start a new one.
Answers arrive whole, after the turn's tool steps, which arrive as they happen. Stopping a turn stops the model; a command already running finishes.
What it sees of the window
The user asks from a screen, and says "her", "this one", "these invoices". So each turn carries a short note of the window it was asked from (RFC 0013), before the user's words:
<window>
{"route":"/customers/42?tab=invoices",
"showing":[{"command":"customers_show","arguments":{"id":42}},
{"command":"invoices_index","arguments":{"customer_id":42}}]}
</window>
Set her credit limit to 50 000.
routeis the window's route, as the router has it. The runtime sends it with each question.showingis the window's live queries (live.customers_show(42)), newest first. Each one is listed by its command and arguments, with unset arguments left out. A query is listed only when its command is a tool the signed-in user is offered. Another window's queries, an MCP agent's, and the ones outside the grant aren't.selectedis what the app says is selected (below), while the window stays on the route it was selected on.- Names, not data. The rows the window shows are never in it. To read one, the model calls the command, which is held to the user's abilities like any call.
- It's small. At most 16 queries. A string argument is cut at 200 characters and the route at 500. Past 2 KB, the oldest queries are dropped until it fits, then ids from the longest selection. A window with no route, no queries and no selection sends no note.
- The conversation keeps it, so a later "and her invoices?" still knows who. The system prompt says the block is data about the screen, not instructions.
Nothing in the app has to change for this. A screen that runs
live.customers_show(id) already says which customer it's on.
What's selected
"Send these a reminder" needs to know which. A <Table> from @elyra/ui
says so by itself with select="customers"
(selecting rows). Otherwise the app says
so, by kind and ids:
<script>
import { assistant } from "@elyra/runtime";
let selected = $state([]); // the app's own selection of customers
$effect(() => {
assistant.selected("customers", selected.map((c) => c.id));
});
</script>
<window>
{"route":"/customers?search=acme","selected":{"customers":[4,7,9]}}
</window>
[]clears a kind. A window has 8 kinds at most.- A selection holds on the route it was made on. A question asked from another route drops it, and so does starting a new conversation.
- Only ids go. The model reads a record by calling its command.
- The first 100 ids of a kind are kept. When there were more,
more_selectedsays how many:{"customers": 150}. - In an app without an assistant it does nothing.
Assistant::window_context(false) leaves the note out, for an app whose
paths, ids or search terms shouldn't reach the model. To keep just one
query out, leave its command out of the grant.
Links to the records
A command that declares where its record is
(#[command(link = "/customers/{id}")], see Commands)
gets a link in the panel when the assistant runs it: "customers update ✓"
opens #/customers/42 in the window. The tool event carries it as link
once the step succeeds, and a custom panel reads it from the step in the
assistant store.
- Only a path in the app becomes a link, through the router. The model can't make the panel open another site.
- The arguments themselves still aren't sent to the frontend or logged.
make:resourcewriteslinkon its commands. MCP clients don't get it.
In the frontend
openAssistant(prompt?) opens the built-in panel, and a ⌘K query that
matches no command offers Ask the assistant. For your own panel, the
assistant store holds the conversation. See
UI components.
What a window can do is /__assistant/{prompt,confirm,stop,reset,tools,source,key},
behind Capability::Assistant (60 calls a minute). deny_frontend(Capability::Assistant)
turns it off.
Security
- The user's rights bound the assistant, checked when the tools are offered and again at each call.
- Prompt injection is expected, not prevented. A customer's notes can
ask the model to delete everything, and the model may try. The call is
still held to the user's abilities, to
confirmand to the rate limit. - Data leaves the machine. Tool results go to the model provider, and,
through the backend, past your server. Keep commands whose data mustn't
leave out of the grant. The window's note
sends the route and the live queries' arguments (ids, search terms) too.
window_context(false)turns it off. - The window's note is data. A search term or a link's query string can hold words aimed at the model. The note is JSON in a delimited block, and nothing in it widens what the model may call.
- The model's words are text. The panel never renders them as HTML.
See also Security.
Testing
TestApp::assistant() talks to the assistant as a window does, with
Ai::fake scripting the model:
let fake = AiFake::new()
.tool_call("customers_destroy", json!({ "id": 7 }))
.text("Deleted.");
let app = TestApp::new(
App::new()
.commands(commands![customers_destroy])
.swap(Ai::fake(fake.clone()))
.assistant(Assistant::new().allow_ability("customers.delete").confirm("customers.delete")),
);
let turn = app.assistant().prompt("Delete customer 7").await; // declines by default
assert_eq!(turn.asked(), ["customers_destroy"]);
assert_eq!(fake.calls()[0].output, "The user didn't confirm `customers_destroy`, so it did not run.");
let turn = app.assistant().accepting().prompt("Yes, delete it").await;
assert_eq!(turn.ran(), [("customers_destroy", true)]);
.window(name) talks from another window, with its own conversation.
.at("/customers/42") asks from that route, .live("customers_show", (42,))
(with the database feature) has the window show that query, and
.selected("customers", [4, 7]) selects on that route. All three are then
in the turn's note, which a test reads from fake.requests().
framework/tests/assistant_live.rs runs one round trip against Claude itself
when ELYRA_AI_LIVE=1 and ANTHROPIC_API_KEY are set.
Related
- AI agents (MCP): the same commands for an agent outside the app.
- AI SDK · A Laravel backend · UI components