Elyra
Elyra The coding agent e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Elyra SQL Server MySQL-compatible SQL server in Rust Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine
Release notes
Changelog
Elyra

AI SDK

An ergonomic, Laravel-inspired AI SDK for Elyra apps — agents, tools, structured output, images, and embeddings over Anthropic and OpenAI. It lives in the elyra-ai crate and is re-exported as elyra::ai behind the ai feature. Everything runs in the Rust backend, so API keys never reach the frontend.

elyra = { version = "0.3", features = ["ai"] }

Configuration

Keys are read from the environment:

ANTHROPIC_API_KEY=…
OPENAI_API_KEY=…
# optional proxy/gateway overrides
ANTHROPIC_BASE_URL=…
OPENAI_BASE_URL=…

The default provider is Anthropic with the claude-sonnet-5 text model; images default to OpenAI gpt-image-1, embeddings to text-embedding-3-small. Build a client with Ai::from_env() or Ai::builder().

Binding into the app

Add the provider so commands can resolve the client:

use elyra::App;
use elyra::ai::{Ai, AiProvider};

App::new().provider(AiProvider).run()?;
#[command]
async fn ask(ctx: Ctx, prompt: String) -> Result<String, String> {
    ctx.get::<Ai>()
        .chat()
        .instructions("You are a concise assistant.")
        .prompt(prompt)
        .await
        .map(|r| r.text().to_string())
        .map_err(|e| e.to_string())
}

Anonymous agents (one-off chats)

The Rust analogue of Laravel's agent(...) helper:

let reply = ai.chat()
    .instructions("You are a concise Rust expert.")
    .temperature(0.3)
    .prompt("What is ownership?")
    .await?;
println!("{reply}");                 // Display = the text
println!("{:?}", reply.usage());     // token usage

Override the provider/model per call:

use elyra::ai::Provider;

ai.chat()
    .provider(Provider::OpenAI)
    .model("gpt-4o-mini")
    .prompt("Summarize this…")
    .await?;

Named agents

Implement [Agent] for reusable agents (like a Laravel agent class). Only instructions is required; override the rest for context, tools, or model config.

use elyra::ai::{Agent, Message, Provider, Tool};

struct SalesCoach { history: Vec<Message> }

impl Agent for SalesCoach {
    fn instructions(&self) -> String {
        "You are a sales coach. Give concise, actionable feedback.".into()
    }
    fn messages(&self) -> Vec<Message> { self.history.clone() }
    fn provider(&self) -> Option<Provider> { Some(Provider::Anthropic) }
    fn max_steps(&self) -> u32 { 6 }
}

let resp = ai.prompt(&SalesCoach { history: vec![] }, "Analyze this transcript…").await?;

Tools

Implement [Tool] — a name, description, a JSON-Schema for the parameters, and call. The SDK runs the tool loop automatically (up to max_steps).

use elyra::ai::{async_trait, json, Result, Tool, Value};

struct RandomNumber;

#[async_trait]
impl Tool for RandomNumber {
    fn name(&self) -> String { "random_number".into() }
    fn description(&self) -> String { "Generate a random integer in [min, max].".into() }
    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "min": { "type": "integer" },
                "max": { "type": "integer" }
            },
            "required": ["min", "max"]
        })
    }
    async fn call(&self, args: Value) -> Result<String> {
        let min = args["min"].as_i64().unwrap_or(0);
        let max = args["max"].as_i64().unwrap_or(100);
        Ok(((min + max) / 2).to_string())
    }
}

let reply = ai.chat()
    .instructions("Use tools when useful.")
    .tool(RandomNumber)
    .prompt("Pick a number between 1 and 10.")
    .await?;

Sub-agents

Delegate to a specialized agent by adding it as a sub-agent — an [Agent] used as a tool. The delegate runs in isolation (it does not see the parent's history), exactly like Laravel's sub-agents. Override name/description so the parent knows when to call it.

use elyra::ai::{Agent, Provider};

struct RefundsAgent;
impl Agent for RefundsAgent {
    fn instructions(&self) -> String {
        "You are a refunds specialist. Give concise eligibility guidance.".into()
    }
    fn name(&self) -> String { "refunds_specialist".into() }
    fn description(&self) -> String { "Answer refund eligibility questions.".into() }
    fn provider(&self) -> Option<Provider> { Some(Provider::Anthropic) }
}

let reply = ai.chat()
    .instructions("You help customers. Delegate refund questions to the specialist.")
    .sub_agent(RefundsAgent)
    .prompt("Can I return an item I bought 40 days ago?")
    .await?;

Sub-agents can have their own tools and sub-agents, composing into a hierarchy. For full control over the tool name/description at the call site, build the wrapper directly:

use elyra::ai::AgentTool;

let tool = AgentTool::new(&ai, RefundsAgent)
    .with_name("refunds")
    .with_description("Refund policy expert.");

Structured output

Return typed JSON with prompt_as::<T>(), where T derives serde::Deserialize and schemars::JsonSchema. The SDK forces the model to emit matching JSON (via a synthetic tool) and deserializes it — works across both providers.

use elyra::ai::JsonSchema;

#[derive(serde::Deserialize, JsonSchema)]
struct Sentiment {
    label: String,   // positive | negative | neutral
    score: i32,      // 1–10
}

let s: Sentiment = ai.chat()
    .instructions("Classify the sentiment.")
    .prompt_as("I love building with Elyra!")
    .await?;

Flat structs work best; deeply nested schemas depend on provider strictness.

Streaming

Stream a plain-text answer token-by-token with stream(input) — ideal for piping to the event bus so the UI paints as tokens arrive. Tools and structured output are not used in streaming mode.

use elyra::ai::StreamChunk;

let mut chunks = ai.chat().instructions("Be brief.").stream("Explain lifetimes.");
while let Some(chunk) = chunks.next().await {
    match chunk? {
        StreamChunk::Delta(text) => print!("{text}"),
        StreamChunk::Done(usage) => eprintln!("\n{usage:?}"),
    }
}
// or: let full = ai.chat().stream("…").collect_text().await?;

Streaming to the frontend

Emit each delta on a channel and subscribe in Svelte:

#[command]
async fn ask_stream(ctx: Ctx, prompt: String) -> Result<(), String> {
    use elyra::ai::StreamChunk;
    let bus = ctx.get::<EventBus>();
    let mut chunks = ctx.get::<Ai>().chat().stream(prompt);
    while let Some(chunk) = chunks.next().await {
        if let StreamChunk::Delta(text) = chunk.map_err(|e| e.to_string())? {
            let _ = bus.emit("elyra:ai", &text);
        }
    }
    Ok(())
}
import { channel, api } from "@elyra/runtime";

let answer = "";
channel<string>("elyra:ai").subscribe((delta) => { if (delta) answer += delta; });
await api.ask_stream("Explain lifetimes.");

Images

let image = ai.image("A donut on a kitchen counter, warm light")
    .landscape()          // or .portrait() / .square() / .size("1024x1024")
    .quality("high")
    .generate()
    .await?;
image.save("donut.png")?;         // or image.bytes()

Embeddings

let vectors = ai.embeddings(["Napa Valley has great wine.", "Elyra is a Rust framework."])
    .dimensions(1536)
    .generate()
    .await?;                       // Vec<Vec<f32>>

Retrieval (RAG)

A portable, in-memory [VectorStore] ranks embeddings by cosine similarity in Rust. Elyra's database layer uses the sqlx Any driver, which has no native vector type (no pgvector), so ranking happens in-process — a good fit for the small-to-medium corpora typical of desktop apps.

use elyra::ai::VectorStore;

let mut store = VectorStore::new();
store.add_texts(&ai, vec![
    ("Napa Valley is famous for wine.", 1u32),      // payload = row id
    ("Rust has a strong ownership model.", 2u32),
    ("Elyra is a Rust + Svelte framework.", 3u32),
]).await?;

let hits = store.search_text(&ai, "best wineries", 3).await?;
for hit in &hits {
    println!("{:.3}  id={}", hit.score, hit.payload);
}

Feed the top hits into a prompt as context:

let context = hits.iter().map(|h| h.payload.to_string()).collect::<Vec<_>>().join("\n");
let answer = ai.chat()
    .instructions(format!("Answer using only this context:\n{context}"))
    .prompt("Where should I taste wine?")
    .await?;

Persisting embeddings

Store each embedding as a JSON/text column and rebuild the store per query (or keep it warm in the container):

// migration: add `embedding TEXT` to your documents table
let json = serde_json::to_string(&embedding)?;      // Vec<f32> -> text
// on load:
let embedding: Vec<f32> = serde_json::from_str(&row_json)?;
store.add(embedding, row_id);

Use [cosine_similarity] directly if you rank rows yourself.

Verification status

The SDK compiles, is clippy-clean, and has offline unit tests (provider metadata, builder, message helpers). The live provider calls are exercised only when ELYRA_AI_LIVE=1 and the relevant key are set (ai/tests/live.rs) — they make real, paid API calls and are skipped in CI and by default.

Related