Documentation Index
Fetch the complete documentation index at: https://mintlify.com/Augani/kael/llms.txt
Use this file to discover all available pages before exploring further.
Kael isolates every plugin in its own OS process and routes all communication through a typed, versioned IPC channel. The design has three independent layers that you compose together: a low-level framed transport, a typed RPC protocol built on top of it, and a process supervisor that keeps child processes healthy. A capability-based permission broker sits across all three layers, deciding what each process is allowed to do before any message is acted on.
IPC transport layer
The Transport trait
All transport implementations share a single interface:
pub trait Transport: Send {
fn send_frame(&mut self, data: &[u8]) -> Result<()>;
fn recv_frame(&mut self) -> Result<Vec<u8>>;
fn close(&mut self) -> Result<()>;
}
The trait is object-safe and Send, so you can box it and move it across threads.
Frame encoding
Messages are length-prefixed with a 4-byte big-endian header:
/// Encode a payload as a length-prefixed frame.
pub fn encode_frame(payload: &[u8]) -> Vec<u8> {
let len = payload.len() as u32;
let mut frame = Vec::with_capacity(4 + payload.len());
frame.extend_from_slice(&len.to_be_bytes());
frame.extend_from_slice(payload);
frame
}
/// Decode a length-prefixed frame.
/// Returns the payload and the number of bytes consumed, or None if the
/// buffer is incomplete.
pub fn decode_frame(buf: &[u8]) -> Result<Option<(Vec<u8>, usize)>> {
if buf.len() < 4 { return Ok(None); }
let len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize;
if buf.len() < 4 + len { return Ok(None); }
let payload = buf[4..4 + len].to_vec();
Ok(Some((payload, 4 + len)))
}
You generally do not call these functions directly — ExtensionTransport handles framing for you.
Socket path resolution
pub fn ipc_socket_path(app_id: &str, process_name: &str) -> PathBuf
ipc_socket_path returns a platform-appropriate path:
| Platform | Path format |
|---|
| macOS / Linux | Resolved via platform socket helper |
| Windows | \\.\pipe\<name> named pipe |
| Other Unix | $TMPDIR/<app_id>-<process_name>.sock |
Concrete transport implementations
Unix domain socket
In-memory (testing)
Used on macOS and Linux for extension communication:use kael::ipc_transport::UnixDomainSocketTransport;
// Connect to an existing socket (plugin side)
let transport = UnixDomainSocketTransport::connect("/tmp/my-app-my-plugin.sock")?;
// Listen and accept one connection (host side)
let transport = UnixDomainSocketTransport::listen("/tmp/my-app-my-plugin.sock")?;
// Create a connected pair for testing
let (host_side, plugin_side) = UnixDomainSocketTransport::pair()?;
InMemoryTransport is a channel-backed pair useful in unit tests:use kael::ipc_transport::InMemoryTransport;
let (mut host_transport, mut plugin_transport) = InMemoryTransport::pair();
// Both ends implement Transport — use them with ExtensionTransport
let mut host = ExtensionTransport::new(Box::new(host_transport));
let mut plugin = ExtensionTransport::new(Box::new(plugin_transport));
RPC protocol
IpcMessage
The base protocol type carries requests, responses, progress updates, and cancellations over a shared correlation id:
pub enum IpcMessage<Request, Response, Progress, Error> {
Request { id: u64, body: Request },
Response { id: u64, result: Result<Response, Error> },
Progress { id: u64, body: Progress },
Cancel { id: u64 },
}
Extension-specific messages use concrete types for all four type parameters.
Extension message types
ExtensionRequest is sent from the host to the plugin:
pub enum ExtensionRequest {
Activate,
Deactivate,
ExecuteCommand { command_id: String, args: Option<serde_json::Value> },
GetContributions,
Shutdown,
}
ExtensionResponse is sent from the plugin back to the host:
pub enum ExtensionResponse {
Ack,
Contributions(Contributions),
Error(String),
}
ExtensionNotification is a one-way fire-and-forget from the plugin to the host:
pub enum ExtensionNotification {
CommandExecuted { command_id: String, result: Option<serde_json::Value> },
PanelUpdated { panel_id: String, state: Option<serde_json::Value> },
SettingsChanged { key: String, value: serde_json::Value },
}
All three are unified in ExtensionMessage:
pub enum ExtensionMessage {
Rpc(IpcMessage<ExtensionRequest, ExtensionResponse, (), String>),
Notification(ExtensionNotification),
Handshake(ExtensionHandshake),
}
Handshake
When the host spawns an extension process, it immediately sends a Handshake::Host message. The extension must respond with Handshake::Extension before any other RPC traffic is sent:
pub enum ExtensionHandshake {
Host {
version: u32, // EXTENSION_RPC_VERSION (currently 1)
capabilities: Vec<serde_json::Value>,
},
Extension {
version: u32,
accepted: bool,
},
}
ExtensionTransport
ExtensionTransport wraps any Box<dyn Transport> and provides typed send/receive methods:
pub struct ExtensionTransport {
inner: Box<dyn Transport>,
}
impl ExtensionTransport {
pub fn new(inner: Box<dyn Transport>) -> Self;
pub fn send_request(&mut self, id: u64, body: ExtensionRequest) -> Result<()>;
pub fn send_response(&mut self, id: u64, result: Result<ExtensionResponse, String>) -> Result<()>;
pub fn send_notification(&mut self, notification: ExtensionNotification) -> Result<()>;
pub fn send_handshake(&mut self, handshake: ExtensionHandshake) -> Result<()>;
pub fn recv_message(&mut self) -> Result<ExtensionMessage>;
}
Typical plugin main loop
use kael::{
ExtensionHandshake, ExtensionMessage, ExtensionRequest, ExtensionResponse,
ExtensionTransport, EXTENSION_RPC_VERSION, UnixDomainSocketTransport,
};
fn main() {
let socket = std::env::var("KAEL_EXTENSION_SOCKET").unwrap();
let raw = UnixDomainSocketTransport::connect(&socket).unwrap();
let mut transport = ExtensionTransport::new(Box::new(raw));
// Complete handshake
if let Ok(ExtensionMessage::Handshake(ExtensionHandshake::Host { .. })) =
transport.recv_message()
{
transport
.send_handshake(ExtensionHandshake::Extension {
version: EXTENSION_RPC_VERSION,
accepted: true,
})
.unwrap();
}
// Process requests
loop {
match transport.recv_message() {
Ok(ExtensionMessage::Rpc(kael::process_model::IpcMessage::Request { id, body })) => {
match body {
ExtensionRequest::Shutdown => {
transport.send_response(id, Ok(ExtensionResponse::Ack)).unwrap();
break;
}
ExtensionRequest::GetContributions => {
let contributions = build_contributions();
transport
.send_response(id, Ok(ExtensionResponse::Contributions(contributions)))
.unwrap();
}
_ => {
transport.send_response(id, Ok(ExtensionResponse::Ack)).unwrap();
}
}
}
Err(_) => break,
_ => {}
}
}
}
Process model
ProcessId and ProcessInfo
Every supervised child process is identified by a ProcessId(u64) and described by a ProcessInfo:
pub struct ProcessInfo {
pub id: ProcessId,
pub class: ProcessClass,
pub name: String,
pub executable: PathBuf,
pub args: Vec<String>,
pub env: HashMap<String, String>,
pub working_dir: Option<PathBuf>,
}
ProcessClass describes the role of the process:
pub enum ProcessClass {
Ui, // main application process
Worker, // background CPU/IO worker
Media, // media/capture pipeline
Extension, // plugin host process
}
Use the convenience constructors to build a ProcessInfo:
let info = ProcessInfo::extension(ProcessId(1), "com.example.my-plugin")
.executable("/app/extensions/my-plugin/my-plugin")
.env("KAEL_EXTENSION_ID", "com.example.my-plugin")
.env("KAEL_API_VERSION", "1.0.0");
RestartPolicy
RestartPolicy tells the ProcessSupervisor what to do when a child exits:
pub enum RestartPolicy {
Never,
OnFailure {
max_restarts: u32,
backoff: Duration,
},
Always {
backoff: Duration,
},
}
Never (default)
OnFailure
Always
The process is not restarted after it exits or crashes. Use this for plugins that should only run once.let opts = ProcessSpawnOptions {
restart_policy: RestartPolicy::Never,
..Default::default()
};
Restart only on non-zero exit or crash. Useful for long-lived worker extensions that should survive transient errors.let opts = ProcessSpawnOptions {
restart_policy: RestartPolicy::OnFailure {
max_restarts: 3,
backoff: Duration::from_secs(2),
},
..Default::default()
};
Restart unconditionally, even on clean exit. Useful for persistent background services.let opts = ProcessSpawnOptions {
restart_policy: RestartPolicy::Always {
backoff: Duration::from_secs(1),
},
..Default::default()
};
ProcessSupervisor
ProcessSupervisor is the runtime engine that spawns, monitors, and restarts child processes. ExtensionHostRuntime holds one internally. When you call ExtensionHostRuntime::new, a supervisor is created automatically:
pub struct ExtensionHostRuntime {
host: ExtensionHost,
supervisor: ProcessSupervisor,
// ...
}
impl ExtensionHostRuntime {
pub fn new(extensions_dir: impl AsRef<Path>, app_id: impl Into<String>) -> Self {
Self {
host: ExtensionHost::new(),
supervisor: ProcessSupervisor::new(),
// ...
}
}
}
Security model
The Capability enum
Capabilities are explicit grants for dangerous actions. The default for every process is deny:
pub enum Capability {
OpenExternalUrl,
FilesystemRead { scope: PathScope },
FilesystemWrite { scope: PathScope },
ShellExecute,
ClipboardRead,
ClipboardWrite,
Notification,
Network { hosts: Vec<String> },
Microphone,
Camera,
ScreenCapture,
}
High-risk capabilities (those where capability.is_high_risk() returns true) are: ShellExecute, ClipboardRead, Network, Camera, ScreenCapture, FilesystemRead { scope: Any }, and FilesystemWrite { scope: Any }.
PermissionBroker
PermissionBroker is the central authority for capability decisions. You configure it at application startup and pass it to the host runtime:
pub struct PermissionBroker {
grants: HashMap<ProcessId, HashSet<Capability>>,
process_classes: HashMap<ProcessId, ProcessClass>,
class_defaults: HashMap<ProcessClass, HashSet<Capability>>,
prompt_handler: Option<PromptHandler>,
}
Configuring the broker
Create the broker
use kael::security::PermissionBroker;
let mut broker = PermissionBroker::new();
Register processes and set class defaults
use kael::process_model::{ProcessClass, ProcessId};
use kael::security::Capability;
broker.register_process(ProcessId(1), ProcessClass::Extension);
broker.set_default_capabilities(
ProcessClass::Extension,
[Capability::Notification, Capability::ClipboardWrite],
);
Grant per-process capabilities
broker.grant(ProcessId(1), Capability::Network {
hosts: vec!["api.example.com".to_string()],
});
Check capabilities at runtime
use kael::security::PermissionResult;
match broker.check(ProcessId(1), &Capability::Notification) {
PermissionResult::Granted => { /* proceed */ }
PermissionResult::Denied => { /* block */ }
PermissionResult::Prompt => { /* show UI prompt */ }
}
Install a prompt handler (optional)
A prompt handler intercepts capabilities that are not pre-granted and lets you show a user-facing dialog:broker.set_prompt_handler(|process_id, capability| {
// show a dialog, return the user's choice
PermissionResult::Granted
});
Checking multiple capabilities
// Returns Granted if any of the listed capabilities is granted
let result = broker.check_any(ProcessId(1), &[
Capability::ClipboardRead,
Capability::ClipboardWrite,
]);
// Inspect all capabilities held by a process
let caps: Vec<Capability> = broker.capabilities(ProcessId(1));
Revoking access
// Revoke a single capability
broker.revoke(ProcessId(1), &Capability::Notification);
// Revoke everything for a process
broker.revoke_all(ProcessId(1));
// Clean up when the process exits
broker.unregister_process(ProcessId(1));
Always call unregister_process when a child process exits. Leaving stale entries in the broker can grant capabilities to a recycled ProcessId if the same numeric identifier is reused.
How the layers connect
Plugin process Host process
────────────── ────────────
main()
connect(KAEL_EXTENSION_SOCKET) ──── UnixDomainSocketTransport::listen()
ExtensionTransport::new(...) ExtensionTransport::new(...)
recv_message() ◄──── Handshake::Host ──── send_handshake()
send_handshake() ──── Handshake::Extension ──►
loop:
recv_message() ◄──── Request { Activate } ── send_request()
send_response() ──── Response { Ack } ──►
broker.check(pid, &cap)
The host’s PermissionBroker is consulted before the host acts on any response that exercises a guarded resource. The plugin itself never touches the broker; it simply declares capabilities in its manifest and the host enforces them.